a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
286 lines
11 KiB
Go
286 lines
11 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// These tests pin the SECURITY.md "confined to indexed repository roots"
|
|
// invariant on the WRITE path. Before the fix, resolveFilePath honoured any
|
|
// absolute path verbatim and the write/edit handlers never re-checked it, so
|
|
// a single write_file / edit_file / batch_edit / move_symbol / generate_skill
|
|
// call could create or overwrite a file anywhere the daemon user could reach
|
|
// (e.g. ~/.zshrc, .git/hooks/pre-commit) — arbitrary write → code execution.
|
|
// The dry_run diff additionally echoed the on-disk content of any out-of-root
|
|
// file, an arbitrary-read oracle. Confinement is now enforced centrally in
|
|
// resolveFilePath, so every sink below must refuse and leak nothing.
|
|
//
|
|
// newReadGuardServer / readText live in read_security_test.go; callBatchEdit
|
|
// in batch_edit_hetero_test.go; setupMoveInlineRepo in tools_move_inline_test.go;
|
|
// callTool / setupTestServer in server_test.go — all package mcp.
|
|
|
|
func writeFileTool(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "write_file"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handleWriteFile(context.Background(), req)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
func editFileTool(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "edit_file"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handleEditFile(context.Background(), req)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
// write_file with a bare absolute path outside every repo root is refused and
|
|
// the file is never created (MkdirAll no longer runs).
|
|
func TestWriteFile_AbsolutePathOutsideRootRefused(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
outside := t.TempDir()
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
victim := filepath.Join(outside, "pwn.txt")
|
|
res := writeFileTool(t, srv, map[string]any{
|
|
"path": victim,
|
|
"content": "OWNED-by-write_file-outside-every-repo-root",
|
|
})
|
|
require.True(t, res.IsError, "write_file to an absolute path outside the repo must be refused")
|
|
require.Contains(t, readText(t, res), "outside every indexed repository")
|
|
|
|
_, statErr := os.Stat(victim)
|
|
require.True(t, os.IsNotExist(statErr), "the out-of-root file must not be created")
|
|
}
|
|
|
|
// write_file through an in-repo symlink whose real target escapes the root is
|
|
// refused, and the out-of-root target is not modified.
|
|
func TestWriteFile_InRepoSymlinkEscapeRefused(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
outside := t.TempDir()
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
target := filepath.Join(outside, "target.txt")
|
|
require.NoError(t, os.WriteFile(target, []byte("ORIGINAL"), 0o644))
|
|
link := filepath.Join(repoRoot, "link.txt")
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
res := writeFileTool(t, srv, map[string]any{
|
|
"path": link,
|
|
"content": "CLOBBERED-THROUGH-SYMLINK",
|
|
})
|
|
require.True(t, res.IsError, "writing through an in-repo symlink escaping the root must be refused")
|
|
|
|
got, err := os.ReadFile(target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ORIGINAL", string(got), "the out-of-root symlink target must not be modified")
|
|
}
|
|
|
|
// write_file dry_run must refuse an out-of-root file BEFORE reading it, so its
|
|
// content cannot leak through the unified-diff preview.
|
|
func TestWriteFile_DryRunDoesNotLeakOutsideContent(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
outside := t.TempDir()
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
const secret = "TOP-SECRET-DRYRUN-LEAK-CANARY"
|
|
victim := filepath.Join(outside, "secret.txt")
|
|
require.NoError(t, os.WriteFile(victim, []byte(secret), 0o600))
|
|
|
|
res := writeFileTool(t, srv, map[string]any{
|
|
"path": victim,
|
|
"content": "x",
|
|
"dry_run": true,
|
|
})
|
|
require.True(t, res.IsError, "dry_run write to an out-of-root file must be refused before reading it")
|
|
require.NotContains(t, readText(t, res), secret, "dry_run diff must not leak the out-of-root file content")
|
|
}
|
|
|
|
// edit_file on an out-of-root file is refused both for real and dry_run, and
|
|
// the file is left untouched with no content leak.
|
|
func TestEditFile_AbsolutePathOutsideRootRefused(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
outside := t.TempDir()
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
const secret = "SECRET-EDIT-CANARY"
|
|
victim := filepath.Join(outside, "victim.txt")
|
|
require.NoError(t, os.WriteFile(victim, []byte(secret+"\n"), 0o644))
|
|
|
|
res := editFileTool(t, srv, map[string]any{
|
|
"path": victim,
|
|
"old_string": secret,
|
|
"new_string": "REPLACED",
|
|
})
|
|
require.True(t, res.IsError, "edit_file on an out-of-root file must be refused")
|
|
got, err := os.ReadFile(victim)
|
|
require.NoError(t, err)
|
|
require.Equal(t, secret+"\n", string(got), "the out-of-root file must be unchanged")
|
|
|
|
dry := editFileTool(t, srv, map[string]any{
|
|
"path": victim,
|
|
"old_string": secret,
|
|
"new_string": "REPLACED",
|
|
"dry_run": true,
|
|
})
|
|
require.True(t, dry.IsError, "dry_run edit on an out-of-root file must be refused")
|
|
require.NotContains(t, readText(t, dry), secret, "dry_run diff must not leak the out-of-root file content")
|
|
}
|
|
|
|
// batch_edit's edit_file op must honour the same confinement: an out-of-root
|
|
// target neither succeeds nor mutates the file.
|
|
func TestBatchEdit_EditFileOutsideRootRefused(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
outside := t.TempDir()
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
const original = "ORIGINAL-BATCH-CONTENT"
|
|
victim := filepath.Join(outside, "victim.txt")
|
|
require.NoError(t, os.WriteFile(victim, []byte(original), 0o644))
|
|
|
|
res := callBatchEdit(t, srv, map[string]any{
|
|
"edits": []any{
|
|
map[string]any{"op": "edit_file", "path": victim, "old_string": original, "new_string": "PWNED"},
|
|
},
|
|
})
|
|
require.Contains(t, readText(t, res), "outside every indexed repository", "the op must report the confinement refusal")
|
|
|
|
got, err := os.ReadFile(victim)
|
|
require.NoError(t, err)
|
|
require.Equal(t, original, string(got), "batch edit_file must not modify an out-of-root file")
|
|
}
|
|
|
|
// move_symbol with a target_file outside every repo root is refused and the
|
|
// out-of-root target is never created.
|
|
func TestMoveSymbol_TargetOutsideRootRefused(t *testing.T) {
|
|
srv, _ := setupMoveInlineRepo(t, map[string]string{
|
|
"pkga/a.go": "package pkga\n\nfunc Foo() int { return 42 }\n",
|
|
})
|
|
outside := t.TempDir()
|
|
target := filepath.Join(outside, "evil.go")
|
|
|
|
res := callTool(t, srv, "move_symbol", map[string]any{
|
|
"id": "pkga/a.go::Foo",
|
|
"target_file": target,
|
|
})
|
|
require.True(t, res.IsError, "move_symbol to an out-of-root target must be refused")
|
|
|
|
_, statErr := os.Stat(target)
|
|
require.True(t, os.IsNotExist(statErr), "the out-of-root target file must not be created")
|
|
}
|
|
|
|
// generate_skill must refuse an output_dir outside every repo root rather than
|
|
// fall back to the literal path and write a skill bundle there.
|
|
func TestGenerateSkill_OutputDirOutsideRootRefused(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
src := filepath.Join(repoRoot, "src")
|
|
require.NoError(t, os.MkdirAll(src, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(src, "main.go"),
|
|
[]byte("package main\n\nfunc main() {}\n"), 0o644))
|
|
|
|
srv := newReadGuardServer(t, repoRoot)
|
|
|
|
outside := t.TempDir()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "generate_skill"
|
|
req.Params.Arguments = map[string]any{
|
|
"directory": src,
|
|
"output_dir": outside,
|
|
}
|
|
res, err := srv.handleGenerateSkill(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.True(t, res.IsError, "generate_skill output_dir outside the repo must be refused")
|
|
|
|
_, statErr := os.Stat(filepath.Join(outside, "SKILL.md"))
|
|
require.True(t, os.IsNotExist(statErr), "no SKILL.md must be written outside the repo")
|
|
}
|
|
|
|
// TestPoC_GHSA_w42c_h7hr_f67p reproduces the reported advisory PoC 1:1
|
|
// against the same harness it used (newSingleRepoServer — the multi-indexer
|
|
// single-repo daemon shape) and asserts the exploit is now fully closed:
|
|
// - write_file to an absolute path outside every repo root is REFUSED;
|
|
// - AtomicWriteFile's MkdirAll never runs, so a non-existent parent
|
|
// (the "~/victim-home/.zshrc" RCE path) is NOT auto-created;
|
|
// - the write_file / edit_file dry_run diff does NOT echo the on-disk
|
|
// content of an out-of-root file (the arbitrary-read oracle is closed).
|
|
func TestPoC_GHSA_w42c_h7hr_f67p(t *testing.T) {
|
|
srv, _, repoRoot := newSingleRepoServer(t)
|
|
outside := t.TempDir() // stands in for $HOME — outside every indexed root
|
|
|
|
// 1. Arbitrary write outside every repo root, with a non-existent parent
|
|
// dir (mirrors writing ~/victim-home/.zshrc with MkdirAll auto-create).
|
|
victimHome := filepath.Join(outside, "victim-home")
|
|
victim := filepath.Join(victimHome, ".zshrc")
|
|
res := writeFileTool(t, srv, map[string]any{
|
|
"path": victim,
|
|
"content": "echo PWNED_BY_GORTEX_RCE # injected via write_file",
|
|
})
|
|
require.True(t, res.IsError, "write_file outside every repo root must be refused")
|
|
require.Contains(t, readText(t, res), "outside every indexed repository")
|
|
_, statErr := os.Stat(victim)
|
|
require.True(t, os.IsNotExist(statErr), "the out-of-root file must not be created")
|
|
_, dirErr := os.Stat(victimHome)
|
|
require.True(t, os.IsNotExist(dirErr), "MkdirAll must not auto-create the out-of-root parent dir")
|
|
|
|
// 2. Confinement boundary sanity: an in-root write through the SAME server
|
|
// still works, proving the refusal above is the guard, not a dead server.
|
|
ok := writeFileTool(t, srv, map[string]any{
|
|
"path": filepath.Join(repoRoot, "scratch.txt"),
|
|
"content": "in-root ok",
|
|
})
|
|
require.False(t, ok.IsError, "an in-root write must still succeed: %s", readText(t, ok))
|
|
|
|
// 3. Arbitrary-read oracle: write_file / edit_file dry_run must NOT leak the
|
|
// content of an existing out-of-root file via the unified-diff preview.
|
|
const canary = "TOP-SECRET-ORACLE-CANARY-7f3a"
|
|
secret := filepath.Join(outside, "secret.txt")
|
|
require.NoError(t, os.WriteFile(secret, []byte(canary+"\n"), 0o600))
|
|
|
|
wDry := writeFileTool(t, srv, map[string]any{
|
|
"path": secret,
|
|
"content": "overwrite",
|
|
"dry_run": true,
|
|
})
|
|
require.True(t, wDry.IsError, "write_file dry_run on an out-of-root file must be refused")
|
|
require.NotContains(t, readText(t, wDry), canary, "write_file dry_run diff must not leak out-of-root content")
|
|
|
|
eDry := editFileTool(t, srv, map[string]any{
|
|
"path": secret,
|
|
"old_string": canary,
|
|
"new_string": "x",
|
|
"dry_run": true,
|
|
})
|
|
require.True(t, eDry.IsError, "edit_file dry_run on an out-of-root file must be refused")
|
|
require.NotContains(t, readText(t, eDry), canary, "edit_file dry_run diff must not leak out-of-root content")
|
|
|
|
// The secret file is untouched on disk.
|
|
got, err := os.ReadFile(secret)
|
|
require.NoError(t, err)
|
|
require.Equal(t, canary+"\n", string(got))
|
|
}
|
|
|
|
// Guard against a false positive from the new central check: an ordinary
|
|
// in-root write must still succeed.
|
|
func TestWriteFile_InRootStillAllowed(t *testing.T) {
|
|
srv, dir := setupTestServer(t)
|
|
res := writeFileTool(t, srv, map[string]any{
|
|
"path": filepath.Join(dir, "new.go"),
|
|
"content": "package main\n",
|
|
})
|
|
require.False(t, res.IsError, "an in-root write must still succeed: %s", readText(t, res))
|
|
|
|
got, err := os.ReadFile(filepath.Join(dir, "new.go"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "package main\n", string(got))
|
|
}
|