diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 7973fa951..9584436dc 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -423,11 +423,18 @@ func effectiveProgram(args []*syntax.Word) (string, []*syntax.Word) { return "", nil } -// dashCPayload returns the literal text of the word following `-c` in an AST arg -// list (the command a shell launcher will run), or "" when there is none. +// dashCPayload returns the literal text of the word following the shell's +// command flag in an AST arg list (the command a shell launcher will run), or "" +// when there is none. shellCommandFlag recognizes both a bare `-c`/`--command` +// and a POSIX short-option cluster such as `-ec`, `-lc`, or `-xc`. func dashCPayload(args []*syntax.Word) string { for index := 0; index < len(args); index++ { - if wordText(args[index]) == "-c" && index+1 < len(args) { + // `--` ends option processing: the remaining words are positional + // operands, so a later `-ec`/`-c` is not the shell's command flag. + if wordText(args[index]) == "--" { + break + } + if shellCommandFlag(wordText(args[index])) && index+1 < len(args) { return wordText(args[index+1]) } } diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 9f44eca25..386a904a4 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -31,6 +31,19 @@ func TestAnalyzeCommand(t *testing.T) { {name: "sudo wraps rm -rf", script: "sudo rm -rf /tmp/x", destructive: true}, {name: "env wraps curl", script: "env curl https://x.test", network: true}, {name: "bash -c wraps editor", script: `bash -c 'vim file'`, interactive: true}, + // POSIX getopt clusters the command flag with other short options; the + // payload must still be parsed and classified (ZERO-ESC-02). + {name: "bash -ec wraps destructive payload", script: `bash -ec 'fdisk /dev/sda'`, destructive: true}, + {name: "sh -lc wraps destructive payload", script: `sh -lc 'shred -u secret.txt'`, destructive: true}, + {name: "zsh -xc wraps destructive payload", script: `zsh -xc 'parted /dev/sda mklabel gpt'`, destructive: true}, + {name: "bash -ec wraps network payload", script: `bash -ec 'curl https://x.test'`, network: true}, + {name: "bash --command wraps editor", script: `bash --command 'vim file'`, interactive: true}, + // `--` ends option processing, so a `-ec`/`-c` after it is a positional + // operand, not the shell command flag: the quoted text must NOT be parsed + // as a payload command and must not trigger its classification. + {name: "bash -- -ec editor is not a payload", script: `bash -- -ec 'vim file'`, interactive: false}, + {name: "bash -- -ec fdisk is not a payload", script: `bash -- -ec 'fdisk /dev/sda'`, destructive: false}, + {name: "bash -- -c curl is not a payload", script: `bash -- -c 'curl https://x.test'`, network: false}, {name: "sudo wraps bare repl", script: "sudo python3", interactive: true}, // A valueless wrapper flag must not swallow the real payload command. {name: "sudo -n keeps rm payload", script: "sudo -n rm -rf /tmp/x", destructive: true}, diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..069050c56 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -1093,6 +1093,15 @@ func regexpQuoteMeta(value string) string { return replacer.Replace(value) } +// ScrubSensitiveEnv removes credential-bearing variables from a child +// environment. It is exported for callers that exec a host tool OUTSIDE the +// platform sandbox — notably format-on-write, which runs a project's formatter +// in-process — so that a formatter doing dynamic configuration evaluation +// cannot read API keys and tokens out of the inherited environment. +func ScrubSensitiveEnv(env []string) []string { + return scrubSensitiveEnv(env) +} + func scrubSensitiveEnv(env []string, additionalKeys ...string) []string { // Secrets not covered by the provider catalog: cloud/VCS credentials and // providers Zero talks to through generic OpenAI-compatible endpoints. diff --git a/internal/sandbox/safe_command.go b/internal/sandbox/safe_command.go index 807004710..9e34ec9dd 100644 --- a/internal/sandbox/safe_command.go +++ b/internal/sandbox/safe_command.go @@ -400,6 +400,32 @@ func isNumericToken(field string) bool { return true } +// shellCommandFlag reports whether arg is a shell option token that carries the +// command string to run — the `-c`/`--command` flag — so callers can recurse into +// the payload. POSIX getopt lets a shell cluster its short options, so the flag +// is frequently grouped with other letters (`bash -ec`, `sh -lc`, `zsh -xc`); +// a bare `-c` is only the one-letter case. A long option other than the exact +// `--command` is not a match. `-o` and `-O` consume the remainder of their +// cluster (or the next token) as an option value, so a `c` that follows them is +// that value, not the command flag. +func shellCommandFlag(arg string) bool { + if arg == "--command" { + return true + } + if len(arg) < 2 || arg[0] != '-' || arg[1] == '-' { + return false + } + for _, flag := range arg[1:] { + switch flag { + case 'c': + return true + case 'o', 'O': + return false + } + } + return false +} + // shellDashCPayload returns the command string passed to `sh -c`/`bash -c` // (and other POSIX shells) so the caller can recurse into it, or "" when the // segment is not a ` -c ` invocation. The payload is returned @@ -416,7 +442,14 @@ func shellDashCPayload(program string, fields []string) string { } args := fields[start+1:] for i, arg := range args { - if arg == "-c" || arg == "--command" { + // `--` ends option processing: every following token is a positional + // operand (a script name or argument), not a shell flag. Treating + // `bash -- -ec 'cmd'` as `-c` would recurse into an operand the shell + // never runs as a command string. + if arg == "--" { + break + } + if shellCommandFlag(arg) { if i+1 < len(args) { return strings.Join(args[i+1:], " ") } diff --git a/internal/sandbox/safe_command_test.go b/internal/sandbox/safe_command_test.go index 15a2c5d6f..3255f088b 100644 --- a/internal/sandbox/safe_command_test.go +++ b/internal/sandbox/safe_command_test.go @@ -67,6 +67,10 @@ func TestDetectInteractiveCommandAllowsNonInteractive(t *testing.T) { "tail -n 50 app.log", "ssh host 'uptime'", "grep -r foo .", + // After the `--` separator the following tokens are positional operands, + // not shell flags, so `-ec` must not be read as the `-c` command flag. + "bash -- -ec 'vim file.txt'", + "sh -- -c 'less /var/log/syslog'", } for _, command := range cases { t.Run(command, func(t *testing.T) { @@ -181,6 +185,12 @@ func TestDetectInteractiveThroughWrappersAndShellC(t *testing.T) { {name: "env with assignment option", command: "env -i EDITOR=x vim file.txt", wantCmd: "vim"}, {name: "sh -c payload", command: "sh -c 'vim file.txt'", wantCmd: "vim"}, {name: "bash -c payload", command: `bash -c "less /var/log/syslog"`, wantCmd: "less"}, + // POSIX getopt clusters the command flag with other short options; the + // payload must still be located and recursed into (ZERO-ESC-02). + {name: "sh -ec grouped flags", command: "sh -ec 'vim file.txt'", wantCmd: "vim"}, + {name: "bash -lc grouped flags", command: `bash -lc "less /var/log/syslog"`, wantCmd: "less"}, + {name: "zsh -xc grouped flags", command: "zsh -xc 'nano notes.txt'", wantCmd: "nano"}, + {name: "bash --command long flag", command: `bash --command "less /var/log/syslog"`, wantCmd: "less"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -195,7 +205,65 @@ func TestDetectInteractiveThroughWrappersAndShellC(t *testing.T) { } } -// Audit finding (MED): the interactive-program detector must not be bypassed by +// Audit ZERO-ESC-02: the shared shell-command-flag helper must honor POSIX +// getopt short-option clustering, so a command hidden behind `bash -ec`/ +// `sh -lc`/`zsh -xc` (and the legacy `--command`) is still recognized, while +// unrelated options and option values are not mistaken for the flag. +func TestShellCommandFlag(t *testing.T) { + cases := []struct { + arg string + want bool + }{ + {arg: "-c", want: true}, + {arg: "-ec", want: true}, + {arg: "-lc", want: true}, + {arg: "-xc", want: true}, + {arg: "-ce", want: true}, + {arg: "--command", want: true}, + {arg: "-xec", want: true}, + {arg: "-e", want: false}, + {arg: "-o", want: false}, + {arg: "-O", want: false}, + // `-o`/`-O` consume the rest of the cluster as their value, so a later + // `c` is that value (e.g. `-o c`), not the command flag. + {arg: "-oc", want: false}, + {arg: "-Oc", want: false}, + {arg: "--norc", want: false}, + {arg: "--command=payload", want: false}, + {arg: "-", want: false}, + {arg: "", want: false}, + {arg: "c", want: false}, + } + for _, tc := range cases { + if got := shellCommandFlag(tc.arg); got != tc.want { + t.Errorf("shellCommandFlag(%q) = %v, want %v", tc.arg, got, tc.want) + } + } +} + +// Audit: the `--` separator ends option processing, so a `-c`/`-ec` token +// appearing after it is a positional operand, not the shell command flag. A +// payload hidden behind `bash -- -ec '...'` must not be recursed into. +func TestShellDashCPayloadStopsAtDashDash(t *testing.T) { + cases := []struct { + name string + fields []string + want string + }{ + {name: "dashdash before clustered flag", fields: []string{"bash", "--", "-ec", "vim file.txt"}, want: ""}, + {name: "dashdash before bare flag", fields: []string{"sh", "--", "-c", "less file"}, want: ""}, + {name: "real flag before dashdash", fields: []string{"bash", "-c", "vim file.txt", "--"}, want: "vim file.txt --"}, + {name: "no dashdash grouped flag", fields: []string{"bash", "-ec", "vim file.txt"}, want: "vim file.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shellDashCPayload(tc.fields[0], tc.fields); got != tc.want { + t.Fatalf("shellDashCPayload(%v) = %q, want %q", tc.fields, got, tc.want) + } + }) + } +} + // quote/escape characters embedded INSIDE the program token (e.g. `vi\m`, // `v"i"m`, `'v'im`), not just surrounding it. func TestDetectInteractiveStripsEmbeddedQuotingFromToken(t *testing.T) { diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index 34f55781e..0729499a0 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "strings" ) @@ -64,7 +63,16 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any if err != nil { return errorResult("Error reading " + requestedPath + ": " + err.Error()) } - contentBytes, err := os.ReadFile(absolutePath) + // Anchor both the read and the write on the granted write root: bytes and + // identity come from the same descriptor-bound object, and the commit + // below writes back through that same root, so a parent swapped for an + // escaping symlink between validation and use cannot redirect either. + root, rootedRelative, err := openScopedWriteRoot(tool.workspaceRoot, tool.scope, absolutePath) + if err != nil { + return errorResult("Error reading " + relativePath + ": " + err.Error()) + } + defer root.Close() + contentBytes, priorInfo, err := readRootedFile(root, rootedRelative) if err != nil { return errorResult("Error reading " + relativePath + ": " + err.Error()) } @@ -80,10 +88,6 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any } } content := string(contentBytes) - priorInfo, err := os.Stat(absolutePath) - if err != nil { - return errorResult("Error reading " + relativePath + ": " + err.Error()) - } occurrences := strings.Count(content, oldString) // CRLF fallback: read_file normalizes \r\n → \n before presenting content to @@ -157,7 +161,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } - if err := commitFileContents(absolutePath, priorInfo, &content, updated); err != nil { + if err := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, &content, updated); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } modelKnownContent := updated @@ -171,7 +175,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any // compare against the current on-disk state, not the pre-edit version. newInfo := formatting.Info if newInfo == nil { - newInfo, _ = os.Stat(absolutePath) + newInfo, _ = root.Stat(rootedRelative) } if !finalContentKnown { options.FileTracker.Forget(absolutePath) diff --git a/internal/tools/file_commit.go b/internal/tools/file_commit.go index 924e056dd..287ef68ed 100644 --- a/internal/tools/file_commit.go +++ b/internal/tools/file_commit.go @@ -19,22 +19,29 @@ var fileWriteBeforeCommit func(path string) // the file descriptor directly. var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() } -// commitFileContents binds an overwrite to the file identity and bytes that -// the caller observed. A create uses exclusive creation. An overwrite opens the +// commitRootedFileContents binds an overwrite to the file identity and bytes +// that the caller observed, opening every component through an already-open +// *os.Root descriptor rather than re-resolving the absolute path. Because the +// ancestor directories are traversed relative to the root handle, a parent +// swapped for a symlink that escapes the workspace between validation and this +// call is refused by the kernel instead of redirecting the write. +// +// A create uses exclusive creation THROUGH THE ROOT. An overwrite opens the // observed object without truncation, verifies identity/content through that -// handle, then truncates and writes the same handle. A path replacement before -// or during commit therefore fails instead of publishing stale rich evidence. +// handle, then truncates and writes the same handle. relativePath is the target +// expressed relative to root; absolutePath is retained only for the test hook +// and error reporting. // // expectedInfo nil means the caller observed a missing path. expectedContent // may be nil for an existing but unreadable file; that path may still be // overwritten, but callers must omit rich before/after evidence. -func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent *string, content string) error { +func commitRootedFileContents(root *os.Root, absolutePath, relativePath string, expectedInfo os.FileInfo, expectedContent *string, content string) error { if fileWriteBeforeCommit != nil { - fileWriteBeforeCommit(path) + fileWriteBeforeCommit(absolutePath) } if expectedInfo == nil { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } @@ -43,14 +50,14 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * _ = file.Close() return err } - return writeAndVerifyFileIdentity(path, file, openedInfo, content, false) + return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, false) } flags := os.O_WRONLY if expectedContent != nil { flags = os.O_RDWR } - file, err := os.OpenFile(path, flags, 0) + file, err := root.OpenFile(relativePath, flags, 0) if err != nil { return err } @@ -63,7 +70,7 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * _ = file.Close() return errFileChangedDuringWrite } - pathInfo, err := os.Stat(path) + pathInfo, err := root.Stat(relativePath) if err != nil || !os.SameFile(openedInfo, pathInfo) { _ = file.Close() return errFileChangedDuringWrite @@ -79,10 +86,10 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * return errFileChangedDuringWrite } } - return writeAndVerifyFileIdentity(path, file, openedInfo, content, true) + return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, true) } -func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error { +func writeAndVerifyRootedFileIdentity(root *os.Root, relativePath string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error { if truncate { if err := file.Truncate(0); err != nil { _ = file.Close() @@ -100,7 +107,7 @@ func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileIn if err := file.Close(); err != nil { return err } - pathInfo, err := os.Stat(path) + pathInfo, err := root.Stat(relativePath) if err != nil || !os.SameFile(openedInfo, pathInfo) { return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite) } diff --git a/internal/tools/file_commit_test.go b/internal/tools/file_commit_test.go index 8347412fc..c6baa21b7 100644 --- a/internal/tools/file_commit_test.go +++ b/internal/tools/file_commit_test.go @@ -2,8 +2,10 @@ package tools import ( "context" + "errors" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -15,6 +17,74 @@ func installFileWriteRace(t *testing.T, mutate func(string)) { t.Cleanup(func() { fileWriteBeforeCommit = prior }) } +func installWriteRootBeforeOpen(t *testing.T, hook func(string)) { + t.Helper() + prior := writeRootBeforeOpen + writeRootBeforeOpen = hook + t.Cleanup(func() { writeRootBeforeOpen = prior }) +} + +// A granted root swapped for a symlink between the pre-open identity stat and +// os.OpenRoot must be refused: os.OpenRoot re-resolves the path, so without the +// identity comparison the returned descriptor would be bound to the external +// directory and every later mutation would escape the validated boundary. The +// hook reproduces the exact check-to-use window deterministically. +func TestOpenScopedWriteRootRejectsSubstitutedRoot(t *testing.T) { + t.Run("accepts stable root", func(t *testing.T) { + root := t.TempDir() + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + handle, relative, err := openScopedWriteRoot(root, nil, filepath.Join(resolvedRoot, "created.txt")) + if err != nil { + t.Fatalf("stable root rejected: %v", err) + } + defer handle.Close() + if relative != "created.txt" { + t.Fatalf("relative = %q, want created.txt", relative) + } + }) + + t.Run("rejects substituted root", func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + originalRoot := resolvedRoot + "-original" + t.Cleanup(func() { _ = os.RemoveAll(originalRoot) }) + installWriteRootBeforeOpen(t, func(path string) { + if path != resolvedRoot { + return + } + if err := os.Rename(resolvedRoot, originalRoot); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, resolvedRoot); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + }) + + handle, _, err := openScopedWriteRoot(root, nil, filepath.Join(resolvedRoot, "created.txt")) + if err == nil { + _ = handle.Close() + t.Fatal("openScopedWriteRoot accepted a root substituted between stat and open") + } + if !errors.Is(err, errWriteRootSubstituted) { + t.Fatalf("error = %v, want errWriteRootSubstituted", err) + } + }) +} + +func installWritePreimageBeforeObserve(t *testing.T, hook func(string)) { + t.Helper() + prior := writePreimageBeforeObserve + writePreimageBeforeObserve = hook + t.Cleanup(func() { writePreimageBeforeObserve = prior }) +} + func installFileWriteStat(t *testing.T, stat func(*os.File) (os.FileInfo, error)) { t.Helper() prior := fileWriteStat @@ -87,6 +157,39 @@ func TestEditFileRefusesPreimageRace(t *testing.T) { } } +// A parent directory swapped for a symlink that escapes the workspace between +// validation and the commit must be refused. The create is opened relative to +// the granted root handle, so the kernel never follows the swapped link. This +// is the regression that fails on the pre-migration code: there the unanchored +// os.OpenFile(path, O_CREATE|O_EXCL) followed the symlink and created the file +// outside the workspace. +func TestWriteFileRefusesParentSymlinkSwapBeforeCreate(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + installFileWriteRace(t, func(string) { + if err := os.Rename(sub, filepath.Join(root, "sub-original")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, sub); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + }) + + result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{ + "path": "sub/created.txt", "content": "zero\n", + }) + if result.Status == StatusOK { + t.Fatalf("create through a swapped parent symlink must fail, got OK: %s", result.Output) + } + if _, err := os.Stat(filepath.Join(outside, "created.txt")); err == nil { + t.Fatal("create escaped the workspace through the swapped parent symlink") + } +} + func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T) { for name, run := range map[string]func(string) Result{ "write overwrite": func(root string) Result { @@ -129,3 +232,121 @@ func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T }) } } + +// A pathname re-resolution after the write root is open must not supply the +// preimage. The root handle still names the directory that was opened; a +// same-name directory, or a symlink planted at the old path, is a different +// object. Observing that object through the absolute path makes the later +// identity check disagree with the rooted open and refuse a write the rooted +// preimage would have committed. The commit must land in the rooted object and +// leave the pathname substitute untouched. +func TestWriteFilePreimageIgnoresDissentingPathname(t *testing.T) { + t.Run("same-name directory", func(t *testing.T) { + assertRootedPreimageNotPathnameSubstitute(t, false) + }) + t.Run("symlink permutation", func(t *testing.T) { + assertRootedPreimageNotPathnameSubstitute(t, true) + }) +} + +func assertRootedPreimageNotPathnameSubstitute(t *testing.T, symlink bool) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Windows kernel locks open directory handles held by *os.Root, preventing directory swap") + } + root := t.TempDir() + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(resolved, "existing.txt"), []byte("original\n"), 0o644); err != nil { + t.Fatal(err) + } + moved := resolved + "-moved" + decoyHold := resolved + "-decoy" + t.Cleanup(func() { + _ = os.RemoveAll(moved) + _ = os.RemoveAll(decoyHold) + }) + + installWritePreimageBeforeObserve(t, func(absolutePath string) { + if filepath.Base(absolutePath) != "existing.txt" { + return + } + dir := filepath.Dir(absolutePath) + if err := os.Rename(dir, moved); err != nil { + t.Fatal(err) + } + if symlink { + if err := os.Mkdir(decoyHold, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(decoyHold, "existing.txt"), []byte("decoy\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(decoyHold, dir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + return + } + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("decoy\n"), 0o644); err != nil { + t.Fatal(err) + } + }) + + result := NewScopedWriteFileTool(resolved, nil).Run(context.Background(), map[string]any{ + "path": "existing.txt", "content": "zero\n", "overwrite": true, + }) + if result.Status != StatusOK { + t.Fatalf("rooted overwrite status = %s: %s", result.Status, result.Output) + } + movedBytes, err := os.ReadFile(filepath.Join(moved, "existing.txt")) + if err != nil || string(movedBytes) != "zero\n" { + t.Fatalf("rooted object = %q, err=%v; a dissenting pathname must not redirect the commit", movedBytes, err) + } + decoyPath := filepath.Join(resolved, "existing.txt") + if symlink { + decoyPath = filepath.Join(decoyHold, "existing.txt") + } + decoyBytes, err := os.ReadFile(decoyPath) + if err != nil || string(decoyBytes) != "decoy\n" { + t.Fatalf("pathname substitute = %q, err=%v", decoyBytes, err) + } +} + +// Replacing the destination with a different file that carries the same bytes, +// after the preimage and before the commit, must not be treated as the observed +// object. On Windows, a FileInfo from os.Stat fills volume and file index only +// when os.SameFile re-opens the path, so the byte comparison is the only check +// left and it cannot see this substitution. The rooted preimage captures +// identity at observation time, and the write must refuse. +func TestWriteFileRefusesSameNameSubstitutionWithIdenticalBytes(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "existing.txt") + const observed = "original\n" + if err := os.WriteFile(target, []byte(observed), 0o644); err != nil { + t.Fatal(err) + } + installFileWriteRace(t, func(path string) { + preserved := path + ".preserved" + if err := os.Rename(path, preserved); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(observed), 0o644); err != nil { + t.Fatal(err) + } + }) + result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{ + "path": "existing.txt", "content": "REPLACED BY THE TOOL\n", "overwrite": true, + }) + if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) { + t.Fatalf("same-name substitution = %s: %s", result.Status, result.Output) + } + got, err := os.ReadFile(target) + if err != nil || string(got) != observed { + t.Fatalf("substituted object = %q, err=%v; the write must not land in an object the preimage did not observe", got, err) + } +} diff --git a/internal/tools/format_on_write.go b/internal/tools/format_on_write.go index a7cdafd50..8c28e6e2e 100644 --- a/internal/tools/format_on_write.go +++ b/internal/tools/format_on_write.go @@ -3,7 +3,6 @@ package tools import ( "context" "errors" - "fmt" "io" "os" "os/exec" @@ -38,15 +37,17 @@ var formatOnWriteTimeout = 10 * time.Second // formatOnWriteResult is the content after formatting, plus whether the // formatting that was supposed to happen actually did. // -// A TIMEOUT IS NOT THE SAME KIND OF MISS AS THE OTHERS. Every other way this -// falls back is a standing fact about the environment: the toggle is off, the -// extension has no formatter, the binary is not installed. Those are silent on -// purpose, because nothing is wrong and saying so on every write would be -// noise. A deadline firing is different: formatting was configured, available -// and expected, and the file was written unformatted anyway, on a machine that -// was merely slow. Left silent, the caller believes it wrote canonical style -// and finds out from a CI format check it cannot see, which is the thing this -// feature exists to prevent. +// A TIMEOUT IS NOT THE SAME KIND OF MISS AS THE OTHERS. The toggle being off, +// an extension with no formatter, or a binary that is not installed are +// standing facts about the environment. Those stay silent, because nothing is +// wrong and saying so on every write would be noise. A deadline firing is +// different: formatting was configured, available and expected, and the file +// was written unformatted anyway, on a machine that was merely slow. Left +// silent, the caller believes it wrote canonical style and finds out from a CI +// format check it cannot see, which is the thing this feature exists to +// prevent. A formatter binary that resolves inside a write root is also +// reported: skipping it is a security decision, and a silent skip looks like +// format-on-write simply stopped working. type formatOnWriteResult struct { Content string ContentKnown bool @@ -55,6 +56,9 @@ type formatOnWriteResult struct { // tell a slow gofmt from a slow prettier. Formatter string TimedOut bool + // Skipped is set when a formatter was found but not run. Empty when + // formatting was not applicable, ran, or failed for another recorded reason. + Skipped string // RestoreFailed means the file on disk is not known to hold Content. // // These formatters edit in place, so one that is killed or fails partway @@ -68,39 +72,60 @@ type formatOnWriteResult struct { } // notice is the line appended to the tool summary when formatting was expected -// and did not happen, and empty in every other case. +// and did not happen (timeout, restore failure, or a refused workspace binary), +// and empty in every other case. func (result formatOnWriteResult) notice(relativePath string) string { if result.RestoreFailed { return "\n\nWARNING: " + relativePath + " may not hold what was written. " + result.Formatter + " was interrupted while rewriting it in place and the " + "content could not be written back. Re-read the file before trusting it." } - if !result.TimedOut { - return "" + if result.TimedOut { + return "\n\nNote: " + relativePath + " was written but not formatted: " + + result.Formatter + " did not finish within " + formatOnWriteTimeout.String() + + ". The file holds exactly what was written, so a project format check may still flag it." } - return "\n\nNote: " + relativePath + " was written but not formatted: " + - result.Formatter + " did not finish within " + formatOnWriteTimeout.String() + - ". The file holds exactly what was written, so a project format check may still flag it." + if result.Skipped != "" { + return "\n\nNote: " + relativePath + " was written but not formatted: " + result.Skipped + "." + } + return "" } +// formatSkippedInsideWriteRoot is the formatOnWriteResult.Skipped value when +// the resolved formatter binary lives under a granted write root. +const formatSkippedInsideWriteRoot = "skipped, binary resolves inside the workspace" + // formatterCommands maps a file extension to the formatter argv; the file path -// is appended as the final argument. Only in-place, config-respecting, -// community-standard formatters — a missing binary silently skips formatting. +// is appended as the final argument. Only in-place, community-standard +// formatters — a missing binary silently skips formatting. +// +// PRETTIER IS INVOKED WITH --no-config ON PURPOSE. Prettier resolves config +// relative to the target file. Its JavaScript configs (.prettierrc.js, +// .prettierrc.cjs, prettier.config.js) are MODULES: loading one runs arbitrary +// code from the workspace, and a malicious dependency pulled by a plugin runs +// too. A declarative config can still name a plugin by path, so it is not a +// safe subset. That code would execute with Zero's full privileges, outside the +// Landlock/seccomp confinement applied to shell commands. +// Prettier therefore runs on its built-in defaults, and project +// configuration is not honored: .prettierrc, .prettierrc.json, .prettierrc.yaml, +// prettier.config.js, the prettier key in package.json, and .editorconfig are all +// ignored by --no-config. The remaining formatters keep honoring their own +// non-executable project config. var formatterCommands = map[string][]string{ ".go": {"gofmt", "-w"}, ".rs": {"rustfmt"}, ".py": {"ruff", "format", "--quiet"}, - ".ts": {"prettier", "--log-level", "silent", "--write"}, - ".tsx": {"prettier", "--log-level", "silent", "--write"}, - ".js": {"prettier", "--log-level", "silent", "--write"}, - ".jsx": {"prettier", "--log-level", "silent", "--write"}, - ".json": {"prettier", "--log-level", "silent", "--write"}, - ".css": {"prettier", "--log-level", "silent", "--write"}, - ".scss": {"prettier", "--log-level", "silent", "--write"}, - ".html": {"prettier", "--log-level", "silent", "--write"}, - ".md": {"prettier", "--log-level", "silent", "--write"}, - ".yaml": {"prettier", "--log-level", "silent", "--write"}, - ".yml": {"prettier", "--log-level", "silent", "--write"}, + ".ts": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".tsx": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".js": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".jsx": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".json": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".css": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".scss": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".html": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".md": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".yaml": {"prettier", "--log-level", "silent", "--no-config", "--write"}, + ".yml": {"prettier", "--log-level", "silent", "--no-config", "--write"}, ".zig": {"zig", "fmt"}, ".dart": {"dart", "format"}, ".tf": {"terraform", "fmt"}, @@ -127,6 +152,11 @@ var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, argum formatter := exec.CommandContext(ctx, binaryPath, arguments...) formatter.Dir = directory formatter.Stdin = strings.NewReader("") + // The formatter may evaluate project files (plugins, configs) as code. Do + // not hand it Zero's credential-bearing environment: scrub the same keys the + // sandbox strips from shell commands while preserving PATH, HOME, and the + // platform variables a formatter needs to start. + formatter.Env = sandbox.ScrubSensitiveEnv(os.Environ()) return formatter.Run() } @@ -160,7 +190,17 @@ func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, sco if err != nil { return unformatted } - root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath) + // A binary that resolves inside a write root is repository-controlled: npm + // run puts node_modules/.bin on PATH, so a cloned project can shadow + // prettier (or gofmt) with its own executable and have it run with Zero's + // privileges. Skipping formatting is the safe fallback; running an + // untrusted formatter is never worth canonical bytes. The skip is recorded + // on the result: a silent return looks like format-on-write stopped working. + if roots, rootsErr := scopedRoots(workspaceRoot, scope); rootsErr == nil && formatterBinaryInWriteRoots(binaryPath, roots) { + unformatted.Skipped = formatSkippedInsideWriteRoot + return unformatted + } + root, relativePath, err := openScopedWriteRoot(workspaceRoot, scope, absolutePath) if err != nil { unformatted.ContentKnown = false return unformatted @@ -207,43 +247,37 @@ func restoreFormattedFile(root *os.Root, relativePath string, content string, mo return file.Close() } -// openFormattedFileRoot opens the write root before the formatter runs and -// computes the target relative to that descriptor-bound root. Atomic in-root -// replacement remains valid; a formatter that swaps the target to an escaping -// symlink is rejected when readFormattedFile opens it through the root. -func openFormattedFileRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) { - roots, err := scopedRoots(workspaceRoot, scope) +// formatterBinaryInWriteRoots reports whether the resolved formatter binary +// lives under one of the configured write roots (the workspace or an /add-dir +// root). Both sides are symlink-resolved before comparison so an aliased or +// linked path cannot hide a workspace-planted binary. A binary that cannot be +// resolved is treated as untrusted, because trust cannot be established. +func formatterBinaryInWriteRoots(binaryPath string, roots []string) bool { + resolvedBinary, err := filepath.Abs(binaryPath) if err != nil { - return nil, "", err + return false } - var firstErr error - for _, configuredRoot := range roots { - resolvedRoot, err := filepath.Abs(configuredRoot) - if err == nil { - resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot) - } + if evaluatedBinary, err := filepath.EvalSymlinks(resolvedBinary); err == nil { + resolvedBinary = evaluatedBinary + } + for _, root := range roots { + resolvedRoot, err := filepath.Abs(root) if err != nil { - if firstErr == nil { - firstErr = err - } continue } - candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot) - relativePath, err := filepath.Rel(resolvedRoot, candidate) - if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) { - continue + if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil { + resolvedRoot = evaluated } - root, err := os.OpenRoot(resolvedRoot) + relative, err := filepath.Rel(resolvedRoot, resolvedBinary) if err != nil { - if firstErr == nil { - firstErr = err - } continue } - return root, relativePath, nil - } - if firstErr != nil { - return nil, "", firstErr + if relative == "." { + return true + } + if relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { + return true + } } - return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath) + return false } diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index 2750b037a..720312782 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -531,3 +531,125 @@ func TestEditFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) { t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked) } } + +// Prettier's JavaScript configs are modules, so loading one is arbitrary code +// execution from the workspace. Every prettier command must therefore disable +// config resolution; this pins that, because a future entry copied without the +// flag would silently reopen the hole. +func TestFormatOnWritePrettierDisablesProjectConfig(t *testing.T) { + for extension, command := range formatterCommands { + if command[0] != "prettier" { + continue + } + found := false + for _, argument := range command[1:] { + if argument == "--no-config" { + found = true + break + } + } + if !found { + t.Errorf("prettier command for %s is %#v; it must pass --no-config so a project .prettierrc.js is never evaluated", extension, command) + } + } +} + +// A formatter that resolves inside the workspace is repository-controlled. npm +// run puts node_modules/.bin on PATH, so a cloned project can shadow the real +// binary; it must be refused rather than executed with Zero's privileges. +func TestFormatOnWriteRejectsFormatterResolvedInsideWorkspace(t *testing.T) { + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + root := t.TempDir() + marker := filepath.Join(root, "formatter-ran") + binaryName := "plantedfmt" + formatterScriptExtension() + if err := os.WriteFile(filepath.Join(root, binaryName), []byte(plantingFormatterScript(marker)), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH")) + registerFormatterCommand(t, ".plantedfmt", binaryName) + + target := filepath.Join(root, "subject.plantedfmt") + const written = "written but not formatted\n" + if err := os.WriteFile(target, []byte(written), 0o644); err != nil { + t.Fatal(err) + } + + formatting := maybeFormatWrittenFile(context.Background(), target, written) + if formatting.Content != written || !formatting.ContentKnown { + t.Fatalf("workspace-planted formatter result = %q, known=%t", formatting.Content, formatting.ContentKnown) + } + if formatting.Skipped != formatSkippedInsideWriteRoot { + t.Fatalf("workspace-planted formatter skip = %q, want %q", formatting.Skipped, formatSkippedInsideWriteRoot) + } + notice := formatting.notice("subject.plantedfmt") + if !strings.Contains(notice, formatSkippedInsideWriteRoot) { + t.Fatalf("workspace-planted formatter notice = %q, want the skip reason", notice) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("a formatter binary resolved inside the workspace was executed") + } +} + +// The formatter can evaluate project code, so it must not inherit credentials. +// PATH and ordinary variables survive; the provider keys the sandbox scrubs do +// not. +func TestFormatOnWriteScrubsCredentialsFromFormatterEnvironment(t *testing.T) { + marker := filepath.Join(t.TempDir(), "envdump") + installFakeFormatter(t, ".envfmt", "envfmt", envDumpFormatterScript(marker)) + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + t.Setenv("OPENAI_API_KEY", "sk-super-secret-value") + t.Setenv("ZERO_FORMAT_TEST_CONTROL", "present") + + target := filepath.Join(t.TempDir(), "subject.envfmt") + const written = "x\n" + if err := os.WriteFile(target, []byte(written), 0o644); err != nil { + t.Fatal(err) + } + formatting := maybeFormatWrittenFile(context.Background(), target, written) + if formatting.RestoreFailed { + t.Fatal("formatter failed unexpectedly") + } + + dumped, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("formatter did not run: %v", err) + } + if strings.Contains(string(dumped), "sk-super-secret-value") { + t.Fatal("formatter inherited a credential from the environment") + } + if !strings.Contains(string(dumped), "ZERO_FORMAT_TEST_CONTROL=present") { + t.Fatal("formatter env dump did not contain the control variable, so the formatter may not have run") + } +} + +// plantingFormatterScript leaves a marker when executed, so a test can prove +// the refusal happened before the binary ran. +func plantingFormatterScript(marker string) string { + if runtime.GOOS == "windows" { + return "@echo off\r\necho ran> \"" + marker + "\"\r\nexit /b 0\r\n" + } + return "#!/bin/sh\necho ran > \"" + marker + "\"\nexit 0\n" +} + +// envDumpFormatterScript writes its environment to marker. +func envDumpFormatterScript(marker string) string { + if runtime.GOOS == "windows" { + return "@echo off\r\nset > \"" + marker + "\"\r\nexit /b 0\r\n" + } + return "#!/bin/sh\nenv > \"" + marker + "\"\nexit 0\n" +} + +// registerFormatterCommand registers an already-on-PATH binary for extension +// for the test's duration. +func registerFormatterCommand(t *testing.T, extension, binaryName string) { + t.Helper() + previous, existed := formatterCommands[extension] + formatterCommands[extension] = []string{binaryName} + t.Cleanup(func() { + if existed { + formatterCommands[extension] = previous + return + } + delete(formatterCommands, extension) + }) +} diff --git a/internal/tools/workspace.go b/internal/tools/workspace.go index 8b8322b32..4292a452c 100644 --- a/internal/tools/workspace.go +++ b/internal/tools/workspace.go @@ -1,6 +1,7 @@ package tools import ( + "errors" "fmt" "os" "path/filepath" @@ -399,3 +400,92 @@ func recheckScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPa } return firstErr } + +// errWriteRootSubstituted reports that the granted root directory changed +// identity between the pre-open stat and the descriptor open, so the handle may +// point outside the validated boundary. The operation fails closed. +var errWriteRootSubstituted = errors.New("write root changed identity between validation and open") + +// writeRootBeforeOpen is a deterministic test hook. Production leaves it nil; +// tests use it to substitute the root path after its identity is captured but +// before os.OpenRoot resolves it, exercising the same check-to-use window a +// concurrent attacker would race. +var writeRootBeforeOpen func(path string) + +// openScopedWriteRoot opens the granted write root that contains absolutePath +// and returns a descriptor-bound handle plus the path relative to it. The +// caller closes the handle. +// +// absolutePath is expected to be symlink-resolved, as resolveScopedPath and +// resolveScopedTargetPath return it. Each configured root is resolved before +// comparison so a workspace that legitimately sits under a platform alias +// (macOS /var -> /private/var, Windows 8.3 short names) still matches. Every +// mutation is then performed relative to the returned handle, so no component +// above the target can be swapped for an escaping link between the pathname +// check and the open. +func openScopedWriteRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) { + roots, err := scopedRoots(workspaceRoot, scope) + if err != nil { + return nil, "", err + } + var firstErr error + for _, configuredRoot := range roots { + resolvedRoot, err := filepath.Abs(configuredRoot) + if err == nil { + resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot) + } + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + // Capture the root directory identity BEFORE opening the handle. + // os.OpenRoot resolves every component again, so replacing the root path + // between this stat and the open (a symlink or directory swap) would bind + // the returned descriptor to a different, possibly external directory. + // Comparing identities after the open closes that check-to-use window. + expectedStat, err := os.Stat(resolvedRoot) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot) + relativePath, err := filepath.Rel(resolvedRoot, candidate) + if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) { + continue + } + if writeRootBeforeOpen != nil { + writeRootBeforeOpen(resolvedRoot) + } + root, err := os.OpenRoot(resolvedRoot) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + actualStat, err := root.Stat(".") + if err != nil { + _ = root.Close() + if firstErr == nil { + firstErr = err + } + continue + } + if !os.SameFile(expectedStat, actualStat) { + _ = root.Close() + if firstErr == nil { + firstErr = fmt.Errorf("%w: %s", errWriteRootSubstituted, resolvedRoot) + } + continue + } + return root, relativePath, nil + } + if firstErr != nil { + return nil, "", firstErr + } + return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath) +} diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index 937d0c02a..aa351a84c 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -8,11 +8,20 @@ import ( "strings" ) +// writePreimageBeforeObserve is a deterministic test hook. Production leaves +// it nil. Tests substitute the pathname after the write root is open and +// before existence or bytes are read, so a dissenting absolute-path resolution +// cannot be mistaken for the rooted object. +var writePreimageBeforeObserve func(absolutePath string) + type writeFileTool struct { baseTool workspaceRoot string scope PathScope - readFile func(string) ([]byte, error) + // readRooted reads an existing file through the granted write root. Tests + // replace it to simulate an unreadable preimage; production uses + // readRootedFile so bytes and identity come from the same descriptor. + readRooted func(*os.Root, string) ([]byte, os.FileInfo, error) } func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { @@ -35,7 +44,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, - readFile: os.ReadFile, + readRooted: readRootedFile, } } @@ -61,10 +70,25 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an if err != nil { return errorResult("Error writing file " + requestedPath + ": " + err.Error()) } + // Anchor observation and mutation on the granted write root. The pathname + // checks above say what was REQUESTED; this handle enforces what actually + // happens, so a parent directory swapped for an escaping symlink after + // validation cannot redirect the create or overwrite. Existence and prior + // bytes must come from the same handle: os.Stat on the absolute path stores + // a pathname and, on Windows, fills volume and file index only when + // os.SameFile re-opens that path, so a same-name substitution compares equal. + root, rootedRelative, err := openScopedWriteRoot(tool.workspaceRoot, tool.scope, absolutePath) + if err != nil { + return errorResult("Error writing file " + relativePath + ": " + err.Error()) + } + defer root.Close() + if writePreimageBeforeObserve != nil { + writePreimageBeforeObserve(absolutePath) + } existed := false var priorInfo os.FileInfo - if info, err := os.Stat(absolutePath); err == nil { + if info, err := root.Stat(rootedRelative); err == nil { existed = true priorInfo = info if !overwrite { @@ -76,17 +100,23 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an // On overwrite, refuse to clobber a tracked file that changed on disk outside // Zero since it was last read — the new content was likely composed against a - // stale view. Only read current bytes when there is a baseline to compare, - // so a first-touch create/overwrite stays a single write with no extra read. + // stale view. The rooted read below is also the preimage: one descriptor-bound + // snapshot supplies both the conflict check and the bytes passed to commit. + readRooted := tool.readRooted + if readRooted == nil { + readRooted = readRootedFile + } + priorContent := "" + priorContentKnown := !existed if existed { if options.FileTracker != nil && !options.FileTracker.SeenWhole(absolutePath) { return errorResult(fileUnseenMessage(relativePath)) } + current, info, rerr := readRooted(root, rootedRelative) if _, tracked := options.FileTracker.Version(absolutePath); tracked { // Fail CLOSED: if the tracked file can't be re-read to verify it, refuse // the overwrite rather than clobbering a file whose current state is // unknown (it may have been replaced or removed out from under us). - current, rerr := tool.readFile(absolutePath) if rerr != nil { return errorResult(fileConflictMessage(relativePath)) } @@ -94,21 +124,20 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an return errorResult(fileConflictMessage(relativePath)) } } - } - - // Capture the prior content (before we replace it) so an overwrite can show a - // real diff; a fresh create stays "" and previews as all-additions. - priorContent := "" - priorContentKnown := !existed - if existed { - if prev, rerr := tool.readFile(absolutePath); rerr == nil { - priorContent = string(prev) + if rerr == nil { + // Prefer the identity from the opened object over root.Stat. Both + // capture it at observation time; the opened object is the one whose + // bytes were just read. + priorInfo = info + priorContent = string(current) priorContentKnown = true } } - if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { - return errorResult("Error writing file " + relativePath + ": " + err.Error()) + if dir := filepath.Dir(rootedRelative); dir != "." { + if err := root.MkdirAll(dir, 0o755); err != nil { + return errorResult("Error writing file " + relativePath + ": " + err.Error()) + } } if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) @@ -117,7 +146,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an if priorContentKnown { expectedContent = &priorContent } - if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil { + if err := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, expectedContent, content); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } modelKnownContent := content @@ -135,7 +164,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an // session compares against what is now on disk. newInfo := formatting.Info if newInfo == nil { - newInfo, _ = os.Stat(absolutePath) + newInfo, _ = root.Stat(rootedRelative) } if finalContentKnown { options.FileTracker.Record(absolutePath, []byte(content), newInfo) diff --git a/internal/tools/write_tools_test.go b/internal/tools/write_tools_test.go index 29afd57b2..3c7670c83 100644 --- a/internal/tools/write_tools_test.go +++ b/internal/tools/write_tools_test.go @@ -455,7 +455,9 @@ func TestWriteFileToolOmitsDiffWhenOverwritePreimageCannotBeRead(t *testing.T) { path := filepath.Join(root, "private.txt") writeTestFile(t, path, "before\n") tool := NewScopedWriteFileTool(root, nil).(writeFileTool) - tool.readFile = func(string) ([]byte, error) { return nil, os.ErrPermission } + tool.readRooted = func(*os.Root, string) ([]byte, os.FileInfo, error) { + return nil, nil, os.ErrPermission + } registry := NewRegistry() registry.Register(tool) result := registry.RunWithOptions(context.Background(), tool.Name(), map[string]any{