diff --git a/internal/agent/file_diagnostics.go b/internal/agent/file_diagnostics.go index 739921b2e..67bac0e48 100644 --- a/internal/agent/file_diagnostics.go +++ b/internal/agent/file_diagnostics.go @@ -2,12 +2,13 @@ package agent import ( "context" - "os" + "io" "path/filepath" "strings" "time" "github.com/Gitlawb/zero/internal/lsp" + "github.com/Gitlawb/zero/internal/tools" ) // fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a @@ -15,6 +16,10 @@ import ( // edit simply reports without a diagnostics block. const fileDiagnosticsTimeout = 10 * time.Second +type fileDiagnosticsChecker interface { + Check(context.Context, string, string) ([]lsp.Diagnostic, error) +} + // NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics // callback (tools.RunOptions.Diagnostics): it reads the just-written file, // checks it against the file's language server, and formats error-severity @@ -26,14 +31,29 @@ const fileDiagnosticsTimeout = 10 * time.Second // on every edit. Returns nil when manager is nil, disabling inline diagnostics // entirely. func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string { + if manager == nil { + return nil + } + return newFileDiagnostics(manager, workspaceRoot) +} + +// newFileDiagnostics accepts the narrow operation used here so race tests can +// observe exactly what would be sent to LSP without installing process-global +// hooks or starting a language server. +func newFileDiagnostics(manager fileDiagnosticsChecker, workspaceRoot string) func(context.Context, string) string { if manager == nil { return nil } return func(ctx context.Context, absPath string) string { - text, err := os.ReadFile(absPath) + file, _, err := tools.ProtectedReadOpen(absPath, workspaceRoot) if err != nil { return "" } + text, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + return "" + } checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout) defer cancel() diagnostics, err := manager.Check(checkCtx, absPath, string(text)) diff --git a/internal/agent/file_diagnostics_test.go b/internal/agent/file_diagnostics_test.go index 5770016c4..348e83a12 100644 --- a/internal/agent/file_diagnostics_test.go +++ b/internal/agent/file_diagnostics_test.go @@ -1,10 +1,26 @@ package agent import ( + "context" + "os" "path/filepath" + "strings" "testing" + + "github.com/Gitlawb/zero/internal/lsp" ) +type recordingDiagnosticsChecker struct { + called bool + text string +} + +func (checker *recordingDiagnosticsChecker) Check(_ context.Context, _ string, text string) ([]lsp.Diagnostic, error) { + checker.called = true + checker.text = text + return nil, nil +} + // Diagnostics are model-facing: absolute paths would leak the local username // and directory layout into the prompt and session transcript on every edit. func TestDiagnosticsDisplayPath(t *testing.T) { @@ -22,3 +38,68 @@ func TestDiagnosticsDisplayPath(t *testing.T) { } } } + +func TestFileDiagnosticsSwapDoesNotSendTokenToLSP(t *testing.T) { + for _, aliasKind := range []string{"symlink", "hardlink"} { + t.Run(aliasKind, func(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "bridge-token") + target := filepath.Join(dir, "ordinary.go") + const secret = "diagnostics-swap-secret" + if err := os.WriteFile(token, []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("package ordinary\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + + // This swap occurs after the mutating tool would have completed its + // rooted write and immediately before its diagnostics callback reads. + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + var err error + if aliasKind == "symlink" { + err = os.Symlink(token, target) + } else { + err = os.Link(token, target) + } + if err != nil { + t.Skipf("%s unavailable: %v", aliasKind, err) + } + checker := &recordingDiagnosticsChecker{} + output := newFileDiagnostics(checker, dir)(context.Background(), target) + if checker.called || strings.Contains(checker.text, secret) || strings.Contains(output, secret) { + t.Fatalf("token reached LSP/output: called=%v text=%q output=%q", checker.called, checker.text, output) + } + got, readErr := os.ReadFile(token) + if readErr != nil || string(got) != secret { + t.Fatalf("token changed: content=%q err=%v", got, readErr) + } + }) + } +} + +func TestFileDiagnosticsOrdinaryPositiveControlReachesLSP(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "ordinary.go") + token := filepath.Join(dir, "bridge-token") + const ordinary = "package ordinary\n" + if err := os.WriteFile(target, []byte(ordinary), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(token, []byte("positive-control-secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + checker := &recordingDiagnosticsChecker{} + _ = newFileDiagnostics(checker, dir)(context.Background(), target) + if !checker.called || checker.text != ordinary { + t.Fatalf("ordinary diagnostics input: called=%v text=%q", checker.called, checker.text) + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0d06d82f4..ad40e4e76 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2190,6 +2190,10 @@ func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestio func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permissionGranted bool, permissionMode PermissionMode, options Options) sandbox.Request { safety := tool.Safety() + var patchPaths []string + if toolName == "apply_patch" { + patchPaths = tools.ApplyPatchPaths(args) + } return sandbox.Request{ WorkspaceRoot: "", ToolName: toolName, @@ -2198,6 +2202,7 @@ func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permi PermissionGranted: permissionGranted, PermissionMode: sandbox.PermissionMode(permissionMode), Args: args, + PatchPaths: patchPaths, Reason: safety.Reason, } } diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 7233f2f08..6dcf3c1ff 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -27,6 +27,38 @@ type mockProvider struct { requests []zeroruntime.CompletionRequest } +func TestSandboxRequestApplyPatchPreflight(t *testing.T) { + root := t.TempDir() + engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: sandbox.DefaultPolicy()}) + tool := tools.NewScopedApplyPatchTool(root, nil) + for _, tc := range []struct { + path string + want sandbox.Action + }{ + {"notes.txt", sandbox.ActionAllow}, + {".agents/notes.md", sandbox.ActionPrompt}, + } { + t.Run(tc.path, func(t *testing.T) { + args := map[string]any{"patch": "--- /dev/null\n+++ b/" + tc.path + "\n@@ -0,0 +1 @@\n+hello\n"} + request := sandboxRequest("apply_patch", tool, args, false, PermissionModeAsk, Options{}) + decision := engine.Evaluate(context.Background(), request) + if decision.Action != tc.want { + t.Fatalf("agent preflight = %#v, want %s", decision, tc.want) + } + if !reflect.DeepEqual(request.PatchPaths, []string{tc.path}) { + t.Fatalf("risk classification paths = %q, want %q", request.PatchPaths, tc.path) + } + if tc.want == sandbox.ActionPrompt && !shouldRequestPermission(tool, args, false, &decision) { + t.Fatal("protected metadata must offer approval") + } + }) + } + request := sandboxRequest("apply_patch", tool, map[string]any{"patch": "not a patch"}, false, PermissionModeAsk, Options{}) + if decision := engine.Evaluate(context.Background(), request); decision.Action != sandbox.ActionDeny { + t.Fatalf("malformed patch preflight = %#v, want deny", decision) + } +} + func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) { secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{ diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index e9d3d0cea..f002b4935 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -63,6 +63,11 @@ Commands: daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN (or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables git-bundle uploads, extracted into per-link work trees. + An inline token takes precedence, so a stale token-file pointer is intentionally not protected as the live credential. + macOS shell commands require the inline token because + Seatbelt cannot deny inode aliases. Linux accepts a token + file only when it has no hard-link aliases and no + shell-writable root shares its filesystem. link --remote --repo --id [--out ] Upload repo's git history to the remote as a bundle and print the extracted remote path. --out saves a session @@ -514,11 +519,10 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - token, err := remote.TokenFromEnv() - if err != nil { - return writeAppError(stderr, err.Error(), exitCrash) - } - auth, err := remote.NewTokenAuthenticator(token) + // Carry both the configured absolute spelling and the resolved startup object + // before workers inherit the environment. The configured identity reserves the + // authority boundary across restart; the resolved identity protects this run. + auth, err := remote.NewAuthenticatorFromEnv() if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } @@ -551,7 +555,9 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int } // Serve the local control socket too, so local clients keep working. + localServeDone := make(chan struct{}) go func() { + defer close(localServeDone) if serveErr := srv.Serve(); serveErr != nil { logf("local serve error: " + serveErr.Error()) } @@ -562,6 +568,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr) select { @@ -569,10 +576,15 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int srv.Shutdown() _ = bridge.Close() <-serveErr // wait for the accept loop to unwind + <-localServeDone return exitSuccess case err := <-serveErr: // Bind/serve failed before any signal (e.g. address in use). srv.Shutdown() + // Shutdown interrupts Serve even during startup. Join its cleanup and + // final logging before writing the terminal error or returning ownership + // of the writers/runtime directory to the caller. + <-localServeDone return writeAppError(stderr, err.Error(), exitCrash) } } @@ -624,7 +636,9 @@ func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int { return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show )") } if strings.TrimSpace(token) == "" { - token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token + // A one-shot client that never ran CanonicalizeTokenFileEnv must not trust + // an inherited resolved marker — see TokenFromFreshEnv. + token, _ = remote.TokenFromFreshEnv() // best effort; UploadRepoBundle rejects an empty token } link, err := remote.UploadRepoBundle(remote.RemoteConfig{ Address: addr, @@ -667,7 +681,9 @@ func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) { } token := strings.TrimSpace(flags.Token) if token == "" { - token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token + // A one-shot client that never ran CanonicalizeTokenFileEnv must not trust + // an inherited resolved marker — see TokenFromFreshEnv. + token, _ = remote.TokenFromFreshEnv() // best effort; DialRemote rejects an empty token } return remote.DialRemote(remote.RemoteConfig{ Address: flags.Addr, diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index 3e316b8c1..044e16dfe 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -2,7 +2,16 @@ package cli import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "errors" + "io" + "math/big" + "net" "os" "os/exec" "path/filepath" @@ -36,6 +45,15 @@ func TestDaemonUsage(t *testing.T) { if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") { t.Fatalf("--help exit=%d out=%q", code, out) } + for _, want := range []string{ + "macOS shell commands require the inline token", + "file only when it has no hard-link aliases", + "shell-writable root shares its filesystem", + } { + if !strings.Contains(out, want) { + t.Fatalf("--help does not state %q in the file-token shell contract: %q", want, out) + } + } } func TestDaemonUnknownSubcommand(t *testing.T) { @@ -209,3 +227,154 @@ func TestDaemonDetachedChildProcess(t *testing.T) { time.Sleep(time.Hour) } } + +func TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers(t *testing.T) { + isolateDaemonPaths(t) + certFile, keyFile := writeDaemonTestCertificate(t) + startDir := t.TempDir() + t.Chdir(startDir) + if err := os.WriteFile("token", []byte("bridge-token"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", "token") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY", "") + + code, _, _ := runDaemonCLI(t, "serve-remote", "--addr", "127.0.0.1:not-a-port", "--tls-cert", certFile, "--tls-key", keyFile) + if code != exitCrash { + t.Fatalf("serve-remote exit = %d, want bind failure", code) + } + configured, err := filepath.Abs(filepath.Join(startDir, "token")) + if err != nil { + t.Fatalf("Abs(token): %v", err) + } + resolved, err := filepath.EvalSymlinks(configured) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", configured, err) + } + if got := os.Getenv("ZERO_DAEMON_REMOTE_TOKEN_FILE"); got != configured { + t.Fatalf("ZERO_DAEMON_REMOTE_TOKEN_FILE = %q, want configured path %q", got, configured) + } + if got := os.Getenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED"); got != resolved { + t.Fatalf("resolved token source = %q, want %q", got, resolved) + } +} + +type daemonTestWriter func([]byte) (int, error) + +func (write daemonTestWriter) Write(data []byte) (int, error) { return write(data) } + +func TestDaemonServeRemoteJoinsLocalServeBeforeReturningBindError(t *testing.T) { + certFile, keyFile := writeDaemonTestCertificate(t) + // Make local Serve fail without opening a socket on any platform. + runtimeFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(runtimeFile, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_RUNTIME_DIR", runtimeFile) + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "test-token") + localLogging := make(chan struct{}) + releaseLocal := make(chan struct{}) + localFinished := make(chan struct{}) + returned := make(chan int, 1) + var terminalError string + stderr := daemonTestWriter(func(data []byte) (int, error) { + if strings.Contains(string(data), "local serve error:") { + close(localLogging) + <-releaseLocal + close(localFinished) + } else { + terminalError += string(data) + } + return len(data), nil + }) + // Do not let the main goroutine reach the TLS failure select until its + // local server goroutine is demonstrably still inside the logging callback. + stdout := daemonTestWriter(func(data []byte) (int, error) { + select { + case <-localLogging: + return len(data), nil + case <-time.After(5 * time.Second): + return 0, io.ErrNoProgress + } + }) + go func() { + returned <- runDaemonServeRemote([]string{"--addr", "127.0.0.1:not-a-port", "--tls-cert", certFile, "--tls-key", keyFile}, stdout, stderr) + }() + select { + case <-localLogging: + case <-time.After(5 * time.Second): + close(releaseLocal) + t.Fatal("local Serve did not reach the synchronized failure") + } + select { + case code := <-returned: + close(releaseLocal) + <-localFinished + t.Fatalf("serve-remote returned %d while local Serve was still logging", code) + case <-time.After(100 * time.Millisecond): + } + close(releaseLocal) + select { + case code := <-returned: + if code != exitCrash || !strings.Contains(terminalError, "not-a-port") { + t.Fatalf("original TLS bind error was lost: code=%d stderr=%q", code, terminalError) + } + select { + case <-localFinished: + default: + t.Fatal("local Serve callback outlived serve-remote") + } + case <-time.After(5 * time.Second): + t.Fatal("serve-remote did not finish after releasing local Serve") + } +} + +func writeDaemonTestCertificate(t *testing.T) (string, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "zero-daemon-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem") + certOut, err := os.Create(certFile) + if err != nil { + t.Fatal(err) + } + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatal(err) + } + if err := certOut.Close(); err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + keyOut, err := os.Create(keyFile) + if err != nil { + t.Fatal(err) + } + if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { + t.Fatal(err) + } + if err := keyOut.Close(); err != nil { + t.Fatal(err) + } + return certFile, keyFile +} diff --git a/internal/cli/exec_checkpoint_credentials_test.go b/internal/cli/exec_checkpoint_credentials_test.go new file mode 100644 index 000000000..8c0983ad8 --- /dev/null +++ b/internal/cli/exec_checkpoint_credentials_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/remotetoken" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" +) + +func TestExecCheckpointBeforeDeniedCredentialMutation(t *testing.T) { + root, data := t.TempDir(), t.TempDir() + const secret = "checkpoint-bearer-must-not-be-copied" + token := filepath.Join(root, "token") + if err := os.WriteFile(token, []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remotetoken.EnvToken, "") + t.Setenv(remotetoken.EnvTokenFile, token) + t.Setenv(remotetoken.EnvTokenFileResolved, "") + t.Setenv(remotetoken.EnvTokenFileIdentity, "") + store := sessions.NewStore(sessions.StoreOptions{RootDir: data}) + session, err := store.Create(sessions.CreateInput{SessionID: "credential-checkpoint"}) + if err != nil { + t.Fatal(err) + } + recorder := execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}} + registry := tools.NewRegistry() + registry.Register(tools.NewScopedWriteFileTool(root, nil)) + registry.Register(tools.NewScopedEditFileTool(root, nil)) + for _, name := range []string{"write_file", "edit_file"} { + args := map[string]any{"path": "token", "content": "replacement", "overwrite": true, "old_string": secret, "new_string": "replacement"} + encoded, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + if _, captured := recorder.captureCheckpoint(root, agent.ToolCall{Name: name, Arguments: string(encoded)}); captured { + t.Fatalf("%s checkpoint captured protected credential", name) + } + result := registry.RunWithOptions(context.Background(), name, args, tools.RunOptions{PermissionGranted: true}) + if result.Status == tools.StatusOK { + t.Fatalf("%s mutation was allowed", name) + } + if !strings.Contains(result.Output, "remote bridge token") { + t.Fatalf("%s failed outside credential protection: %s", name, result.Output) + } + } + // Read every persisted file through the ordinary read tool: a leaked blob + // has a fresh inode and would otherwise evade the credential exclusion. + reader := tools.NewRegistry() + reader.Register(tools.NewScopedReadFileTool(data, nil)) + err = filepath.WalkDir(data, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + result := reader.Run(context.Background(), "read_file", map[string]any{"path": path}) + if result.Status != tools.StatusOK { + t.Fatalf("read persisted file: %+v", result) + } + if strings.Contains(result.Output, secret) { + t.Fatalf("checkpoint leaked credential through %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/daemon/remote/auth.go b/internal/daemon/remote/auth.go index 4f1574a9d..5cd893089 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -26,12 +26,15 @@ import ( "strings" "github.com/Gitlawb/zero/internal/daemon" + "github.com/Gitlawb/zero/internal/remotetoken" ) // Env vars the bridge reads for its bearer token. const ( - EnvToken = "ZERO_DAEMON_REMOTE_TOKEN" - EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE" + EnvToken = remotetoken.EnvToken + EnvTokenFile = remotetoken.EnvTokenFile + EnvTokenFileResolved = remotetoken.EnvTokenFileResolved + EnvTokenFileIdentity = remotetoken.EnvTokenFileIdentity ) // ErrUnauthorized is returned when a token does not match. @@ -45,7 +48,8 @@ type Authenticator interface { // TokenAuthenticator compares a presented token against a fixed secret in // constant time. type TokenAuthenticator struct { - token string + token string + protectionIdentity string } // NewTokenAuthenticator builds a token authenticator, refusing an empty secret @@ -66,14 +70,15 @@ func (a *TokenAuthenticator) Authenticate(token string) error { return ErrUnauthorized } -// TokenFromEnv resolves the bridge token from EnvToken, or a file named by -// EnvTokenFile. It never logs the token. +// TokenFromEnv resolves the bridge token from EnvToken, or the file source +// selected by EnvTokenFile. After daemon startup resolution it reads the pinned +// object rather than re-following a mutable configured symlink. func TokenFromEnv() (string, error) { if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" { return t, nil } - if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" { - data, err := os.ReadFile(file) + if source, selected := remotetoken.SourceFromEnv(); selected { + data, err := os.ReadFile(source.ReadPath()) if err != nil { return "", fmt.Errorf("remote: read token file: %w", err) } @@ -86,6 +91,111 @@ func TokenFromEnv() (string, error) { return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile) } +// TokenFromFreshEnv is TokenFromEnv for a caller that has NOT run +// CanonicalizeTokenFileEnv and must not trust an inherited +// ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED marker. +// +// That marker is a daemon-worker handoff value, not an independently +// authoritative selector: remotetoken.SourceFromEnv binds it to whatever +// ZERO_DAEMON_REMOTE_TOKEN_FILE happens to be set to right now, without proving +// it is THAT value's resolved identity. A one-shot client process — `zero +// daemon link`, or a remote dial — inherits its environment from its own +// parent, which can carry a resolved marker left over from an unrelated prior +// `serve-remote` invocation in the same shell. If the operator then points +// EnvTokenFile at a different file, this client reads the stale target instead: +// it can authenticate with a revoked credential, or fail against a valid one. +// +// remotetoken.ResolveSource ignores the marker and always resolves fresh, which +// is exactly the property a caller that never canonicalized needs. +func TokenFromFreshEnv() (string, error) { + if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" { + return t, nil + } + source, selected, err := remotetoken.ResolveSource() + if err != nil { + return "", fmt.Errorf("remote: %w", err) + } + if !selected { + return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile) + } + data, err := os.ReadFile(source.ReadPath()) + if err != nil { + return "", fmt.Errorf("remote: read token file: %w", err) + } + t := strings.TrimSpace(string(data)) + if t == "" { + return "", errors.New("remote: token file is empty") + } + return t, nil +} + +// CanonicalizeTokenFileEnv records both identities of the selected token file +// before workers start: the operator-configured absolute spelling and the +// symlink-resolved object this daemon authenticated against. Keeping both +// prevents worker CWD changes from retargeting a relative value without losing +// the configured authority boundary across replacement and restart. +// +// It is a deliberate no-op when EnvToken supplies the token: TokenFromEnv +// prefers the inline value, so an unused (even dangling) file pointer must not +// change the outcome or fail the start. +func CanonicalizeTokenFileEnv() error { + source, selected, err := remotetoken.ResolveSource() + if err != nil { + return fmt.Errorf("remote: %w", err) + } + if !selected { + return nil + } + if err := remotetoken.PersistSource(source); err != nil { + return fmt.Errorf("remote: persist token file source: %w", err) + } + return nil +} + +// NewAuthenticatorFromEnv opens a file token once, derives protection identity +// from that handle, reads authentication bytes from the same handle, and only +// then publishes the trusted worker handoff. This prevents atomic replacement +// of the configured name from retiring the startup inode's aliases. +func NewAuthenticatorFromEnv() (*TokenAuthenticator, error) { + if token := strings.TrimSpace(os.Getenv(EnvToken)); token != "" { + return NewTokenAuthenticator(token) + } + source, selected, err := remotetoken.ResolveSource() + if err != nil { + return nil, fmt.Errorf("remote: %w", err) + } + if !selected { + return nil, fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile) + } + file, err := os.Open(source.ReadPath()) + if err != nil { + return nil, fmt.Errorf("remote: read token file: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, errors.New("remote: token file must be a regular file") + } + identity, ok := remotetoken.IdentityOfFile(file) + if !ok { + return nil, errors.New("remote: token file identity is unavailable on this platform") + } + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("remote: read token file: %w", err) + } + auth, err := NewTokenAuthenticator(string(data)) + if err != nil { + return nil, err + } + auth.protectionIdentity = identity + source.Identity = identity + if err := remotetoken.PersistSource(source); err != nil { + return nil, fmt.Errorf("remote: persist token file source: %w", err) + } + return auth, nil +} + // Attestation is an optional post-token hook (e.g. workload attestation). The // default is a no-op; a deployment can supply a stricter implementation. type Attestation interface { diff --git a/internal/daemon/remote/auth_test.go b/internal/daemon/remote/auth_test.go index 448a6d920..4f0386329 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -4,7 +4,10 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" ) func TestTokenAuthenticator(t *testing.T) { @@ -26,6 +29,65 @@ func TestTokenAuthenticator(t *testing.T) { } } +func TestNewAuthenticatorFromEnvPinsOpenedFileIdentityAcrossReplacement(t *testing.T) { + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFileResolved, "") + t.Setenv(EnvTokenFileIdentity, "") + dir := t.TempDir() + token := filepath.Join(dir, "token") + alias := filepath.Join(dir, "startup-token-alias") + if err := os.WriteFile(token, []byte("old-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + t.Setenv(EnvTokenFile, token) + auth, err := NewAuthenticatorFromEnv() + if err != nil { + t.Fatalf("NewAuthenticatorFromEnv: %v", err) + } + if err := auth.Authenticate("old-secret"); err != nil { + t.Fatalf("startup token rejected: %v", err) + } + replacement := filepath.Join(dir, "replacement") + if err := os.WriteFile(replacement, []byte("new-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, token); err != nil { + t.Fatal(err) + } + aliasFile, err := os.Open(alias) + if err != nil { + t.Fatal(err) + } + want, ok := remotetoken.IdentityOfFile(aliasFile) + aliasFile.Close() + if !ok || os.Getenv(EnvTokenFileIdentity) != want { + t.Fatalf("startup identity = %q, want alias identity %q", os.Getenv(EnvTokenFileIdentity), want) + } + if err := auth.Authenticate("new-secret"); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("running authenticator accepted replacement token: %v", err) + } + + restarted, err := NewAuthenticatorFromEnv() + if err != nil { + t.Fatalf("restart NewAuthenticatorFromEnv: %v", err) + } + if err := restarted.Authenticate("new-secret"); err != nil { + t.Fatalf("replacement token rejected after restart: %v", err) + } + if err := restarted.Authenticate("old-secret"); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("restart retained retired authenticator credential: %v", err) + } + if err := auth.Authenticate("old-secret"); err != nil { + t.Fatalf("running authenticator stopped matching its pinned credential: %v", err) + } + if got := os.Getenv(EnvTokenFileIdentity); got == want { + t.Fatal("restart retained old file identity") + } +} + func TestTokenFromEnv(t *testing.T) { // Clear both so the no-config path errors. t.Setenv(EnvToken, "") @@ -57,6 +119,255 @@ func TestTokenFromEnv(t *testing.T) { } } +// TestTokenFromEnvTrustsAStaleInheritedResolvedMarker documents the shape +// TokenFromFreshEnv exists to avoid: SourceFromEnv (behind TokenFromEnv) binds +// EnvTokenFileResolved to whatever EnvTokenFile is set to right now, without +// proving it is THAT value's resolved identity. A process that inherits a +// resolved marker left over from an unrelated prior daemon (same shell, +// different invocation) reads the stale target once EnvTokenFile is repointed. +// This is the vulnerable baseline; TestTokenFromFreshEnvIgnoresAStaleResolvedMarker +// is the fix. +func TestTokenFromEnvTrustsAStaleInheritedResolvedMarker(t *testing.T) { + t.Setenv(EnvToken, "") + oldToken := filepath.Join(t.TempDir(), "old-token") + if err := os.WriteFile(oldToken, []byte("old-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + newDir := t.TempDir() + newToken := filepath.Join(newDir, "new-token") + if err := os.WriteFile(newToken, []byte("new-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + // The shell still carries a resolved marker for the OLD token from an + // unrelated prior serve-remote invocation. + t.Setenv(EnvTokenFileResolved, oldToken) + // The operator has since pointed EnvTokenFile at the new token. + t.Setenv(EnvTokenFile, newToken) + + tok, err := TokenFromEnv() + if err != nil { + t.Fatalf("TokenFromEnv: %v", err) + } + if tok != "old-secret" { + t.Fatalf("TokenFromEnv = %q, want it to demonstrate the stale-marker bug by reading old-secret (got the correct new-secret — has the underlying contract changed?)", tok) + } +} + +// TestTokenFromFreshEnvIgnoresAStaleResolvedMarker is the fix: a caller that +// has not run CanonicalizeTokenFileEnv — a one-shot client such as `zero daemon +// link` or a remote dial — must always resolve EnvTokenFile fresh rather than +// trust an inherited resolved marker that could belong to a different +// configured value. +func TestTokenFromFreshEnvIgnoresAStaleResolvedMarker(t *testing.T) { + t.Setenv(EnvToken, "") + oldToken := filepath.Join(t.TempDir(), "old-token") + if err := os.WriteFile(oldToken, []byte("old-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + newDir := t.TempDir() + newToken := filepath.Join(newDir, "new-token") + if err := os.WriteFile(newToken, []byte("new-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(EnvTokenFileResolved, oldToken) + t.Setenv(EnvTokenFile, newToken) + + tok, err := TokenFromFreshEnv() + if err != nil { + t.Fatalf("TokenFromFreshEnv: %v", err) + } + if tok != "new-secret" { + t.Fatalf("TokenFromFreshEnv = %q, want new-secret (the currently configured file, not the stale marker)", tok) + } + + // Inline precedence is preserved: an inline token still wins over both the + // configured file and any resolved marker. + t.Setenv(EnvToken, "inline-secret") + if tok, err := TokenFromFreshEnv(); err != nil || tok != "inline-secret" { + t.Fatalf("TokenFromFreshEnv(inline) = %q, %v", tok, err) + } + t.Setenv(EnvToken, "") + + // No configured file at all still errors, matching TokenFromEnv. + t.Setenv(EnvTokenFile, "") + t.Setenv(EnvTokenFileResolved, "") + if _, err := TokenFromFreshEnv(); err == nil { + t.Fatal("TokenFromFreshEnv with neither var set must error") + } +} + +// TestSelectedFilePathPreservesFilenameWhitespace pins the pointer as a +// pathname rather than a trimmed word. Trimming it made this boundary select +// "/bridge-token" while the operator had named "/bridge-token " — +// which fails the daemon start at best, and at worst reads and protects a +// different file than the one holding the bearer token. +// +// remotetoken.SelectedFilePath is the single selector behind both SourceFromEnv +// and ResolveSource, so this covers the pathname rule for every consumer of the +// contract rather than one boundary's copy of it. +func TestSelectedFilePathPreservesFilenameWhitespace(t *testing.T) { + t.Setenv(EnvToken, "") + for _, configured := range []string{"/srv/zero/bridge-token ", " /srv/zero/bridge-token", "/srv/zero/ token "} { + t.Setenv(EnvTokenFile, configured) + if got := remotetoken.SelectedFilePath(); got != configured { + t.Fatalf("SelectedFilePath() = %q, want the configured pathname %q", got, configured) + } + } + for _, blank := range []string{"", " ", "\t\n"} { + t.Setenv(EnvTokenFile, blank) + if got := remotetoken.SelectedFilePath(); got != "" { + t.Fatalf("SelectedFilePath() = %q for a blank value, want unset", got) + } + } + // An inline token wins: the file pointer is not selected at all, so nothing + // downstream protects or reads a file the bridge never authenticates against. + t.Setenv(EnvToken, "inline-secret") + t.Setenv(EnvTokenFile, "/srv/zero/bridge-token ") + if got := remotetoken.SelectedFilePath(); got != "" { + t.Fatalf("SelectedFilePath() = %q while an inline token is set, want unset", got) + } +} + +// TestCanonicalizeTokenFileEnvKeepsTrailingSpaceFilename covers the same rule at +// the daemon boundary: the file the bridge authenticates against must survive +// canonicalization byte for byte. +func TestCanonicalizeTokenFileEnvKeepsTrailingSpaceFilename(t *testing.T) { + t.Setenv(EnvTokenFileResolved, "") + if runtime.GOOS == "windows" { + t.Skip("Windows filenames cannot end in a space") + } + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(base, "bridge-token ") + if err := os.WriteFile(token, []byte("from-file\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Chdir(base) + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFile, "bridge-token ") + + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv: %v", err) + } + if got := os.Getenv(EnvTokenFile); got != token { + t.Fatalf("%s = %q, want %q", EnvTokenFile, got, token) + } + t.Chdir(t.TempDir()) + if tok, err := TokenFromEnv(); err != nil || tok != "from-file" { + t.Fatalf("TokenFromEnv after canonicalization = %q, %v", tok, err) + } +} + +// TestCanonicalizeTokenFileEnv pins the configured absolute spelling and the +// resolved startup object for every worker. +func TestCanonicalizeTokenFileEnv(t *testing.T) { + t.Setenv(EnvTokenFileResolved, "") + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(base, "tok") + if err := os.WriteFile(token, []byte("from-file\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + + t.Run("relative value becomes absolute", func(t *testing.T) { + // A worker resolves the inherited value against its own session directory, + // so a relative value must not survive the daemon boundary. + t.Chdir(base) + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFile, "tok") + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv: %v", err) + } + if got := os.Getenv(EnvTokenFile); got != token { + t.Fatalf("%s = %q, want %q", EnvTokenFile, got, token) + } + // Workers run from session directories, so prove the selected path remains + // pinned after crossing that boundary rather than merely inspecting the env. + t.Chdir(t.TempDir()) + if tok, err := TokenFromEnv(); err != nil || tok != "from-file" { + t.Fatalf("TokenFromEnv from a worker directory after canonicalization = %q, %v", tok, err) + } + }) + + t.Run("symlinked pathname retains configured and resolved identities", func(t *testing.T) { + link := filepath.Join(base, "tok-link") + if err := os.Symlink(token, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFile, link) + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv: %v", err) + } + if got := os.Getenv(EnvTokenFile); got != link { + t.Fatalf("%s = %q, want configured path %q", EnvTokenFile, got, link) + } + if got := os.Getenv(EnvTokenFileResolved); got != token { + t.Fatalf("%s = %q, want resolved target %q", EnvTokenFileResolved, got, token) + } + + replacement := filepath.Join(base, "replacement-token") + if err := os.WriteFile(replacement, []byte("replacement\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(replacement, link); err != nil { + t.Fatal(err) + } + if tok, err := TokenFromEnv(); err != nil || tok != "from-file" { + t.Fatalf("TokenFromEnv after symlink replacement = %q, %v, want startup-pinned token", tok, err) + } + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv after restart: %v", err) + } + if tok, err := TokenFromEnv(); err != nil || tok != "replacement" { + t.Fatalf("TokenFromEnv after simulated restart = %q, %v, want replacement target", tok, err) + } + }) + + t.Run("an inline token keeps precedence over a dangling pointer", func(t *testing.T) { + // TokenFromEnv prefers EnvToken, so an unused (even dangling) file pointer + // must neither fail the start nor be rewritten. + dangling := filepath.Join(base, "missing", "tok") + t.Setenv(EnvToken, "from-env") + t.Setenv(EnvTokenFile, dangling) + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv with an inline token: %v", err) + } + if got := os.Getenv(EnvTokenFile); got != dangling { + t.Fatalf("%s = %q, want it left alone", EnvTokenFile, got) + } + if tok, err := TokenFromEnv(); err != nil || tok != "from-env" { + t.Fatalf("TokenFromEnv = %q, %v, want the inline token", tok, err) + } + }) + + t.Run("a selected but unreadable pointer fails closed", func(t *testing.T) { + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFile, filepath.Join(base, "missing", "tok")) + if err := CanonicalizeTokenFileEnv(); err == nil { + t.Fatal("a selected token file that cannot be resolved must error") + } + }) + + t.Run("no pointer is a no-op", func(t *testing.T) { + t.Setenv(EnvToken, "") + t.Setenv(EnvTokenFile, "") + if err := CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv without a pointer: %v", err) + } + if got := os.Getenv(EnvTokenFile); got != "" { + t.Fatalf("%s = %q, want empty", EnvTokenFile, got) + } + }) +} + func TestServerTLSConfigRequiresCertKey(t *testing.T) { if _, err := ServerTLSConfig("", ""); err == nil { t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)") diff --git a/internal/imageinput/imageinput.go b/internal/imageinput/imageinput.go index 2093db183..33f1aee8e 100644 --- a/internal/imageinput/imageinput.go +++ b/internal/imageinput/imageinput.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" + "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -61,6 +62,28 @@ func LoadFile(path string, workspaceRoot string) (zeroruntime.ImageBlock, error) } defer file.Close() + return LoadOpenFile(file, path, workspaceRoot) +} + +// LoadOpenFile validates and consumes the supplied handle without reopening its +// pathname. The caller owns the handle and any workspace confinement; LoadFile +// intentionally permits user-selected images outside the workspace. +func LoadOpenFile(file *os.File, path, workspaceRoot string) (zeroruntime.ImageBlock, error) { + info, err := file.Stat() + if err != nil { + return zeroruntime.ImageBlock{}, fmt.Errorf("cannot stat image %s: %w", path, err) + } + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.FileHandleExcluded(path, file, info) { + return zeroruntime.ImageBlock{}, fmt.Errorf("%s holds the remote bridge token and is never readable", path) + } + if !info.Mode().IsRegular() { + return zeroruntime.ImageBlock{}, fmt.Errorf("image file must be a regular file: %s", path) + } + if info.Size() > MaxImageBytes { + return zeroruntime.ImageBlock{}, fmt.Errorf("image %s is larger than the 10 MiB limit", path) + } + data, err := io.ReadAll(io.LimitReader(file, MaxImageBytes+1)) if err != nil { return zeroruntime.ImageBlock{}, fmt.Errorf("cannot read image %s: %w", path, err) diff --git a/internal/imageinput/imageinput_test.go b/internal/imageinput/imageinput_test.go index 6884380d4..46cc95613 100644 --- a/internal/imageinput/imageinput_test.go +++ b/internal/imageinput/imageinput_test.go @@ -1,12 +1,101 @@ package imageinput import ( + "bytes" "os" "path/filepath" + "strconv" "strings" "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" ) +func TestLoadOpenFileChecksConsumedHandle(t *testing.T) { + for _, protected := range []bool{false, true} { + t.Run(strconv.FormatBool(protected), func(t *testing.T) { + root := t.TempDir() + token, candidate := filepath.Join(root, "token"), filepath.Join(root, "candidate") + const original = "GIF89a original" + if err := os.WriteFile(token, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remotetoken.EnvToken, "") + t.Setenv(remotetoken.EnvTokenFile, token) + t.Setenv(remotetoken.EnvTokenFileResolved, "") + t.Setenv(remotetoken.EnvTokenFileIdentity, "") + if protected { + if err := os.Link(token, candidate); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + } else if err := os.WriteFile(candidate, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Open(candidate) + if err != nil { + t.Fatal(err) + } + defer file.Close() + // An innocent lookup path must not authorize a protected handle or + // replace the bytes read from an ordinary handle. Use a separate name + // instead of unlinking an open file, which Windows does not permit. + lookup := filepath.Join(root, "replacement") + if err := os.WriteFile(lookup, []byte("GIF89a replacement"), 0o600); err != nil { + t.Fatal(err) + } + image, err := LoadOpenFile(file, lookup, root) + if protected { + if err == nil || len(image.Data) != 0 || !strings.Contains(err.Error(), "holds the remote bridge token") { + t.Fatalf("consumed protected handle: %+v, %v", image, err) + } + } else if err != nil || string(image.Data) != original { + t.Fatalf("reopened pathname instead of reading handle: %+v, %v", image, err) + } + }) + } +} + +func TestLoadFileRejectsDaemonTokenAliases(t *testing.T) { + root := t.TempDir() + data := []byte("GIF89a protected image bytes") + token := filepath.Join(root, "token") + if err := os.WriteFile(token, data, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remotetoken.EnvToken, "") + t.Setenv(remotetoken.EnvTokenFile, token) + t.Setenv(remotetoken.EnvTokenFileResolved, "") + t.Setenv(remotetoken.EnvTokenFileIdentity, "") + for _, kind := range []string{"exact", "symlink", "hardlink", "ordinary"} { + t.Run(kind, func(t *testing.T) { + path := token + if kind != "exact" { + path = filepath.Join(root, kind) + var err error + switch kind { + case "symlink": + err = os.Symlink(token, path) + case "hardlink": + err = os.Link(token, path) + case "ordinary": + err = os.WriteFile(path, data, 0o600) + } + if err != nil { + t.Skipf("%s unavailable: %v", kind, err) + } + } + image, err := LoadFile(path, root) + if kind == "ordinary" { + if err != nil || !bytes.Equal(image.Data, data) { + t.Fatalf("ordinary image = %+v, %v", image, err) + } + } else if err == nil || len(image.Data) != 0 || !strings.Contains(err.Error(), "holds the remote bridge token") { + t.Fatalf("protected image consumed: %+v, %v", image, err) + } + }) + } +} + func TestLoadFileReadsAndNormalizes(t *testing.T) { root := t.TempDir() // 1x1 PNG (real PNG signature so http.DetectContentType returns image/png). diff --git a/internal/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go new file mode 100644 index 000000000..1b713b317 --- /dev/null +++ b/internal/mcp/daemon_token_test.go @@ -0,0 +1,304 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" + "github.com/Gitlawb/zero/internal/tools" +) + +func TestServeMCPNavigationAndImagesExcludeDaemonToken(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(workspace, "bridge-token") + const image = "GIF89a mcp-image-secret" + if err := os.WriteFile(token, []byte(image), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + t.Setenv(remote.EnvTokenFileIdentity, "") + registry := tools.NewRegistry() + registry.Register(tools.NewScopedLSPNavigateTool(workspace, nil)) + registry.Register(tools.NewViewImageTool(workspace)) + for _, kind := range []string{"exact", "hardlink", "symlink", "ordinary"} { + t.Run(kind, func(t *testing.T) { + path := token + if kind != "exact" { + path = filepath.Join(workspace, kind+".unknownext") + var err error + switch kind { + case "hardlink": + err = os.Link(token, path) + case "symlink": + err = os.Symlink(token, path) + default: + err = os.WriteFile(path, []byte(image), 0o600) + } + if err != nil { + t.Skipf("%s unavailable: %v", kind, err) + } + } + for _, op := range []string{"definition", "workspace_symbol", "image"} { + name := "lsp_navigate" + args := map[string]any{"path": path, "op": op, "line": 1, "query": "x"} + if op == "image" { + name, args = "view_image", map[string]any{"path": path} + } + result := callServerTool(t, registry, ServeOptions{WorkspaceRoot: workspace}, name, args) + if kind == "ordinary" { + if result.IsError { + t.Fatalf("ordinary %s: %s", op, TextContent(result.Content)) + } + } else if !result.IsError || !strings.Contains(TextContent(result.Content), "holds the remote bridge token") { + t.Errorf("%s credential refusal missing: %+v", op, result) + } + } + }) + } +} + +func TestServeMCPExcludesDaemonTokenFromTools(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(workspace, "bridge-token") + const secret = "mcp-bridge-secret" + if err := os.WriteFile(token, []byte(secret+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "ordinary.txt"), []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + + registry := tools.NewRegistry() + toolset := append(tools.CoreReadOnlyToolsScoped(workspace, nil), tools.CoreWriteToolsScoped(workspace, nil)...) + for _, tool := range toolset { + registry.Register(tool) + } + options := ServeOptions{WorkspaceRoot: workspace, PermissionGranted: true} + + for _, tc := range []struct { + name string + arguments map[string]any + wantError bool + }{ + {name: "read_file", arguments: map[string]any{"path": "bridge-token"}, wantError: true}, + {name: "read_minified_file", arguments: map[string]any{"path": "bridge-token"}, wantError: true}, + {name: "grep", arguments: map[string]any{"pattern": secret, "path": "."}}, + {name: "glob", arguments: map[string]any{"pattern": "*", "cwd": "."}}, + {name: "list_directory", arguments: map[string]any{"path": "."}}, + {name: "write_file", arguments: map[string]any{"path": "bridge-token", "content": "attacker\n", "overwrite": true}, wantError: true}, + {name: "edit_file", arguments: map[string]any{"path": "bridge-token", "old_string": secret, "new_string": "attacker"}, wantError: true}, + {name: "apply_patch", arguments: map[string]any{"patch": "--- a/bridge-token\n+++ b/bridge-token\n@@ -1 +1 @@\n-" + secret + "\n+attacker\n"}, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + result := callServerTool(t, registry, options, tc.name, tc.arguments) + if result.IsError != tc.wantError { + t.Fatalf("IsError = %v, want %v; output=%q", result.IsError, tc.wantError, TextContent(result.Content)) + } + output := TextContent(result.Content) + if strings.Contains(output, secret) { + t.Fatalf("MCP %s disclosed token bytes: %q", tc.name, output) + } + if !tc.wantError && strings.Contains(output, "bridge-token") { + t.Fatalf("MCP %s disclosed token filename: %q", tc.name, output) + } + }) + } + + contents, err := os.ReadFile(token) + if err != nil || string(contents) != secret+"\n" { + t.Fatalf("token changed through MCP mutation tool: contents=%q err=%v", contents, err) + } +} + +func TestServeMCPExcludesDaemonTokenFromResources(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(workspace, "bridge-token") + const secret = "mcp-resource-secret" + if err := os.WriteFile(token, []byte(secret+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "ordinary.txt"), []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + + var input bytes.Buffer + writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "resources/list"}) + writeServerTestMessage(t, &input, rpcMessage{ + ID: 2, + Method: "resources/read", + Params: mustRaw(map[string]any{"uri": fileURI(token)}), + }) + var output bytes.Buffer + if err := Serve(context.Background(), &input, &output, tools.NewRegistry(), ServeOptions{WorkspaceRoot: workspace}); err != nil { + t.Fatalf("Serve() error = %v", err) + } + + reader := newMessageReader(&output) + var listed struct { + Resources []Resource `json:"resources"` + } + decodeServerTestResult(t, readServerTestMessage(t, reader), &listed) + foundOrdinary := false + for _, resource := range listed.Resources { + if resource.Name == "bridge-token" || strings.Contains(resource.URI, "bridge-token") { + t.Fatalf("resources/list advertised the token: %#v", resource) + } + foundOrdinary = foundOrdinary || resource.Name == "ordinary.txt" + } + if !foundOrdinary { + t.Fatalf("resources/list omitted ordinary file: %#v", listed.Resources) + } + + read := readServerTestMessage(t, reader) + if read.Error == nil || len(read.Result) != 0 { + t.Fatalf("resources/read token response = %#v, want not-found without contents", read) + } + if strings.Contains(read.Error.Message, secret) { + t.Fatalf("resources/read error disclosed token bytes: %q", read.Error.Message) + } +} + +// resources/read decides from the handle it opened, not from a pathname it +// checked and then reopened. A hard link is a second name for the same inode, so +// no pathname rule covers it — the exclusion has to compare the object, and the +// object it compares must be the one the read returns. +func TestResourcesReadRefusesHardLinkedToken(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(workspace, "bridge-token") + const secret = "mcp-hardlink-secret" + if err := os.WriteFile(token, []byte(secret+"\n"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(workspace, "notes.txt") + if err := os.Link(token, alias); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + + var input bytes.Buffer + writeServerTestMessage(t, &input, rpcMessage{ + ID: 1, + Method: "resources/read", + Params: mustRaw(map[string]any{"uri": fileURI(alias)}), + }) + var output bytes.Buffer + if err := Serve(context.Background(), &input, &output, tools.NewRegistry(), ServeOptions{WorkspaceRoot: workspace}); err != nil { + t.Fatalf("Serve() error = %v", err) + } + + read := readServerTestMessage(t, newMessageReader(&output)) + if read.Error == nil || len(read.Result) != 0 { + t.Fatalf("resources/read alias response = %#v, want not-found without contents", read) + } + if strings.Contains(read.Error.Message, secret) { + t.Fatalf("resources/read error disclosed token bytes: %q", read.Error.Message) + } +} + +func TestResourcesReadRetainsStartupIdentityAfterTokenReplacement(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(workspace, "bridge-token") + alias := filepath.Join(workspace, "retired-token-alias") + const secret = "mcp-startup-lifecycle-secret" + if err := os.WriteFile(token, []byte(secret+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + t.Setenv(remote.EnvTokenFileIdentity, "") + if _, err := remote.NewAuthenticatorFromEnv(); err != nil { + t.Fatalf("daemon startup: %v", err) + } + replacement := filepath.Join(workspace, "replacement") + if err := os.WriteFile(replacement, []byte("replacement-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, token); err != nil { + t.Fatal(err) + } + + var input bytes.Buffer + writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "resources/read", Params: mustRaw(map[string]any{"uri": fileURI(alias)})}) + var output bytes.Buffer + if err := Serve(context.Background(), &input, &output, tools.NewRegistry(), ServeOptions{WorkspaceRoot: workspace}); err != nil { + t.Fatalf("Serve() error = %v", err) + } + read := readServerTestMessage(t, newMessageReader(&output)) + if read.Error == nil || len(read.Result) != 0 || strings.Contains(read.Error.Message, secret) { + t.Fatalf("resources/read disclosed retired startup token: %#v", read) + } +} + +// TestServeMCPResourcesWorkWithoutADaemonToken pins the ordinary MCP startup +// shape: no ZERO_DAEMON_REMOTE_TOKEN_FILE at all. credentialGuard.ReadExclusions() +// used to return a nil *ReadExclusions whenever the disabled-mode policy had +// nothing protected, and listResources/readResource called methods on it +// directly. Those methods happen to be nil-receiver-safe (Active checks rx != +// nil first), so this never actually panicked — but the shape is exactly what +// a future non-nil-safe method addition would turn into a crash on the most +// common MCP configuration there is. ReadExclusions() now always returns a +// real, inactive matcher for a non-nil engine, removing the landmine outright. +func TestServeMCPResourcesWorkWithoutADaemonToken(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "ordinary.txt"), []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, "") + + var input bytes.Buffer + writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "resources/list"}) + writeServerTestMessage(t, &input, rpcMessage{ + ID: 2, + Method: "resources/read", + Params: mustRaw(map[string]any{"uri": fileURI(filepath.Join(workspace, "ordinary.txt"))}), + }) + var output bytes.Buffer + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Serve panicked with no daemon token configured: %v", r) + } + }() + if err := Serve(context.Background(), &input, &output, tools.NewRegistry(), ServeOptions{WorkspaceRoot: workspace}); err != nil { + t.Fatalf("Serve() error = %v", err) + } + + reader := newMessageReader(&output) + var listed struct { + Resources []Resource `json:"resources"` + } + decodeServerTestResult(t, readServerTestMessage(t, reader), &listed) + found := false + for _, resource := range listed.Resources { + if resource.Name == "ordinary.txt" { + found = true + } + } + if !found { + t.Fatalf("resources/list omitted the ordinary file: %#v", listed.Resources) + } + + var read struct { + Contents []ResourceContents `json:"contents"` + } + decodeServerTestResult(t, readServerTestMessage(t, reader), &read) + if len(read.Contents) != 1 || read.Contents[0].Text != "ordinary\n" { + t.Fatalf("resources/read = %#v, want the ordinary file's contents", read.Contents) + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 29fedc72b..59a03899a 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "mime" "net/http" "net/url" @@ -94,6 +95,9 @@ func (server toolServer) listResources() []Resource { } for _, file := range summary.Files { absolute := filepath.Join(root, filepath.FromSlash(file.Path)) + if server.credentialGuard.ReadExclusions().PathExcluded(absolute) { + continue + } uri := fileURI(absolute) if _, ok := seen[uri]; ok { continue @@ -146,11 +150,25 @@ func (server toolServer) readResource(rawParams json.RawMessage) ([]ResourceCont // outside the granted roots. return nil, jsonRPCResourceNotFound, err } + // Open ONCE, then decide everything from that handle. Checking the pathname + // and re-opening it to stat and read would let a concurrent rename or a + // repointed symlink swap the object between the check and the read — and let + // a file that passed the size check be replaced by an unbounded one before + // os.ReadFile allocates for it. The exclusion test, the type and size checks, + // and the read all describe the same opened object here. + file, err := os.Open(absolute) + if err != nil { + return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) + } + defer file.Close() - info, err := os.Stat(absolute) + info, err := file.Stat() if err != nil { return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) } + if server.credentialGuard.ReadExclusions().FileHandleExcluded(absolute, file, info) { + return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) + } if info.IsDir() { return nil, jsonRPCInvalidParams, fmt.Errorf("resource is a directory, not a file: %s", uri) } @@ -158,10 +176,16 @@ func (server toolServer) readResource(rawParams json.RawMessage) ([]ResourceCont return nil, jsonRPCInvalidParams, fmt.Errorf("resource exceeds %d-byte limit: %s", maxResourceBytes, uri) } - data, err := os.ReadFile(absolute) + // Bound the read by the limit rather than by the size just observed: on a + // growing file the stat is already stale, and one byte over the cap is enough + // to report the overflow without holding an unbounded buffer. + data, err := io.ReadAll(io.LimitReader(file, maxResourceBytes+1)) if err != nil { return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) } + if len(data) > maxResourceBytes { + return nil, jsonRPCInvalidParams, fmt.Errorf("resource exceeds %d-byte limit: %s", maxResourceBytes, uri) + } contents := ResourceContents{ // Echo the requested URI (what resources/list advertised) rather than the diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 42e46cc19..e83493119 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/tools" ) @@ -42,12 +43,19 @@ func Serve(ctx context.Context, input io.Reader, output io.Writer, registry *too reader := newMessageReader(input) writer := newMessageWriter(output) resolvedOptions := options.withDefaults() + // MCP has no user-policy engine, but ModeDisabled intentionally retains the + // non-optional daemon-token guard and recursive read exclusions. + credentialGuard := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: resolvedOptions.WorkspaceRoot, + Policy: sandbox.Policy{Mode: sandbox.ModeDisabled}, + }) server := toolServer{ - registry: registry, - options: resolvedOptions, - writer: writer, - workspaceRoot: resolvedOptions.WorkspaceRoot, - scope: resolvedOptions.Scope, + registry: registry, + options: resolvedOptions, + writer: writer, + workspaceRoot: resolvedOptions.WorkspaceRoot, + scope: resolvedOptions.Scope, + credentialGuard: credentialGuard, } // Run the blocking reads on a goroutine and select on ctx so a @@ -110,11 +118,12 @@ func (options ServeOptions) withDefaults() ServeOptions { } type toolServer struct { - registry *tools.Registry - options ServeOptions - writer *messageWriter - workspaceRoot string - scope tools.PathScope + registry *tools.Registry + options ServeOptions + writer *messageWriter + workspaceRoot string + scope tools.PathScope + credentialGuard *sandbox.Engine } func (server toolServer) handle(ctx context.Context, message rpcMessage) error { @@ -217,6 +226,7 @@ func (server toolServer) callTool(ctx context.Context, rawParams json.RawMessage result := server.registry.RunWithOptions(ctx, params.Name, params.Arguments, tools.RunOptions{ PermissionGranted: server.options.PermissionGranted, + Sandbox: server.credentialGuard, }) return CallToolResult{ Content: []Content{{Type: "text", Text: result.Output}}, diff --git a/internal/remotetoken/identity_unix.go b/internal/remotetoken/identity_unix.go new file mode 100644 index 000000000..533236837 --- /dev/null +++ b/internal/remotetoken/identity_unix.go @@ -0,0 +1,28 @@ +//go:build !windows + +package remotetoken + +import ( + "fmt" + "os" + "syscall" +) + +func fileIdentity(info os.FileInfo) (string, bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", false + } + return fmt.Sprintf("unix:%d:%d", uint64(stat.Dev), uint64(stat.Ino)), true +} + +func openFileIdentity(file *os.File) (string, bool) { + if file == nil { + return "", false + } + info, err := file.Stat() + if err != nil { + return "", false + } + return fileIdentity(info) +} diff --git a/internal/remotetoken/identity_windows.go b/internal/remotetoken/identity_windows.go new file mode 100644 index 000000000..22a788605 --- /dev/null +++ b/internal/remotetoken/identity_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package remotetoken + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +func fileIdentity(os.FileInfo) (string, bool) { return "", false } + +func openFileIdentity(file *os.File) (string, bool) { + if file == nil { + return "", false + } + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &info); err != nil { + return "", false + } + return fmt.Sprintf("windows:%08x:%08x%08x", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true +} diff --git a/internal/remotetoken/source.go b/internal/remotetoken/source.go new file mode 100644 index 000000000..2818238d6 --- /dev/null +++ b/internal/remotetoken/source.go @@ -0,0 +1,127 @@ +// Package remotetoken models the configured and resolved identities of the +// remote daemon's file-backed bearer token. +package remotetoken + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Environment variables used to select the remote daemon bearer token. +const ( + EnvToken = "ZERO_DAEMON_REMOTE_TOKEN" + EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE" + EnvTokenFileResolved = "ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED" + // EnvTokenFileIdentity is a trusted daemon-to-worker handoff containing the + // stable identity of the opened file whose bytes supplied authentication. + EnvTokenFileIdentity = "ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY" +) + +// FileSource carries both identities needed for file-backed token enforcement. +// Configured is the operator-selected absolute spelling. Resolved is the +// symlink-resolved object selected at daemon startup, when one exists. +type FileSource struct { + Configured string + Resolved string + Identity string +} + +// SelectedFilePath returns the configured token-file pathname exactly as the +// operator supplied it, unless an inline token takes precedence or the file +// variable is unset. Filename whitespace is data; only all-whitespace is unset. +func SelectedFilePath() string { + if strings.TrimSpace(os.Getenv(EnvToken)) != "" { + return "" + } + configured := os.Getenv(EnvTokenFile) + if strings.TrimSpace(configured) == "" { + return "" + } + return configured +} + +// SourceFromEnv returns the selected file source without requiring the token to +// exist. It preserves the startup-resolved identity across rotation and falls +// back to resolving the current target for callers outside serve-remote. +func SourceFromEnv() (FileSource, bool) { + configured := SelectedFilePath() + if configured == "" { + return FileSource{}, false + } + absolute, err := filepath.Abs(configured) + if err != nil { + return FileSource{}, false + } + source := FileSource{Configured: absolute} + if resolved := os.Getenv(EnvTokenFileResolved); strings.TrimSpace(resolved) != "" { + if absoluteResolved, err := filepath.Abs(resolved); err == nil { + source.Resolved = absoluteResolved + } + } + if source.Resolved == "" { + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + source.Resolved = resolved + } + } + source.Identity = os.Getenv(EnvTokenFileIdentity) + return source, true +} + +// ResolveSource resolves the currently selected file source for daemon startup. +func ResolveSource() (FileSource, bool, error) { + configured := SelectedFilePath() + if configured == "" { + return FileSource{}, false, nil + } + absolute, err := filepath.Abs(configured) + if err != nil { + return FileSource{}, false, fmt.Errorf("resolve token file %q: %w", configured, err) + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return FileSource{}, false, fmt.Errorf("resolve token file %q: %w", configured, err) + } + return FileSource{Configured: absolute, Resolved: resolved}, true, nil +} + +// PersistSource makes both source identities available to daemon workers. +func PersistSource(source FileSource) error { + if err := os.Setenv(EnvTokenFile, source.Configured); err != nil { + return err + } + if err := os.Setenv(EnvTokenFileResolved, source.Resolved); err != nil { + return err + } + return os.Setenv(EnvTokenFileIdentity, source.Identity) +} + +// IdentityOf returns stable identity when FileInfo exposes it. On Windows, +// FileInfo lacks the required file index; handle-consuming callers must use +// IdentityOfFile instead. +func IdentityOf(info os.FileInfo) (string, bool) { return fileIdentity(info) } + +// IdentityOfFile returns the cross-process stable identity of file's already +// opened handle. It never reopens the pathname represented by file. +func IdentityOfFile(file *os.File) (string, bool) { return openFileIdentity(file) } + +// Paths returns the distinct configured and resolved identities. +func (source FileSource) Paths() []string { + if source.Configured == "" { + return nil + } + if source.Resolved == "" || source.Resolved == source.Configured { + return []string{source.Configured} + } + return []string{source.Configured, source.Resolved} +} + +// ReadPath is the object pinned at startup, falling back to the configured +// spelling for callers that have not persisted a resolved identity. +func (source FileSource) ReadPath() string { + if source.Resolved != "" { + return source.Resolved + } + return source.Configured +} diff --git a/internal/sandbox/apply_patch_paths_test.go b/internal/sandbox/apply_patch_paths_test.go index 3f9a2dc66..c52769d5a 100644 --- a/internal/sandbox/apply_patch_paths_test.go +++ b/internal/sandbox/apply_patch_paths_test.go @@ -18,14 +18,18 @@ func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) { "decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***", "no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch", } { - request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}} + path := "main.js" + if name == "absolute inside workspace" { + path = inside + } + request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}, PatchPaths: []string{path}} if block := applyPatchPathBlock(request); block != nil { t.Fatalf("%s: unexpected block %+v", name, block) } } for _, path := range []string{"../escape.js", ".."} { for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} { - request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}} + request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}, PatchPaths: []string{path}} block := applyPatchPathBlock(request) if block == nil || block.Code != BlockOutsideWorkspace { t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block) @@ -34,18 +38,14 @@ func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) { } } -// Every marker spelling the tool applies must be classified as structured at -// the sandbox boundary too; otherwise the boundary scans the patch as a unified -// diff, extracts no targets, and validates nothing (fail-open). +// Every marker spelling the tool applies must be classified consistently by +// the shared marker helper. func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) { for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} { patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch" if !IsStructuredPatch(patch) { t.Fatalf("%q must classify as a structured patch", header) } - if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" { - t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths) - } } for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} { if IsStructuredPatch(header + "\n-a\n+b") { @@ -74,10 +74,8 @@ func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n") } for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} { - // Failure path: the exact path the boundary parsed must be denied by the - // scope. structuredPatchHeaderPaths normalises separators to "/", so the - // parsed form is compared in slash form and then validated as-is. - paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)}) + // Failure path: the exact executor path must be denied by the scope. + paths := applyPatchRequestPaths(Request{Args: map[string]any{"patch": structured(spelling[0], spelling[1], outside)}, PatchPaths: []string{filepath.ToSlash(outside)}}) if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) { t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths) } @@ -85,7 +83,7 @@ func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block) } // Success path: the parsed inside path must be accepted by the scope. - paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)}) + paths = applyPatchRequestPaths(Request{Args: map[string]any{"patch": structured(spelling[0], spelling[1], inside)}, PatchPaths: []string{filepath.ToSlash(inside)}}) if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) { t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths) } diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index ed7440780..1ce8e8cfc 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -141,36 +141,54 @@ func (engine *Engine) LookupCommandPrefixForSession(toolName string, command []s // ReadExclusions returns the resolved DenyRead/AllowRead exclusion matcher for // this engine's policy, resolving each policy entry ONCE. The search tools build // it a single time per run and reuse it across the whole walk so the predicates -// don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine -// (the matcher's methods treat nil as "exclude nothing"). +// don't re-run Abs/EvalSymlinks per visited path. Returns nil ONLY for a nil +// engine — every other path returns a non-nil matcher, inactive when there is +// nothing to exclude. +// +// A non-nil-engine caller that skipped the nil check (there is one, but a +// second one should never have to exist) would otherwise call Active()/ +// PathExcluded()/FileExcluded() on a nil *ReadExclusions when no token is +// selected and the policy is disabled — the ordinary MCP startup shape. +// Those methods happen to be nil-receiver-safe today (Active checks rx != nil +// first), so that specific call never actually panics, but relying on every +// future method staying nil-safe is a needless landmine when returning a real, +// inactive value costs nothing. func (engine *Engine) ReadExclusions() *ReadExclusions { - // A disabled policy enforces nothing, so it must not filter search results - // either (Evaluate already allows every request under ModeDisabled). + // A disabled policy enforces nothing the USER configured, so it must not + // filter search results by DenyRead either (Evaluate likewise allows every + // request under ModeDisabled). The automatic credential exclusion is not + // policy-derived and survives: the bridge token authenticates the channel + // driving these tools, so turning the sandbox off must not hand a remote + // caller its own credential. if engine == nil { return nil } policy := engine.effectivePolicy(engine.policy) if policy.Mode == ModeDisabled { - return nil - } - return &ReadExclusions{ - workspaceRoot: engine.workspaceRoot, - denyRoots: resolvePolicyPaths(policy.DenyRead), - allowRoots: resolvePolicyPaths(policy.AllowRead), + exclusions := newReadExclusions(engine.workspaceRoot, nil, nil, protectedCredentialPaths()) + return &exclusions } + exclusions := newReadExclusions( + engine.workspaceRoot, + resolvePolicyPaths(policy.DenyRead), + resolvePolicyPaths(policy.AllowRead), + protectedCredentialPaths(), + ) + return &exclusions } // ReadExclusionGlobs returns the ripgrep-style --glob exclusion args for this // engine's policy + scope (see the package-level ReadExclusionGlobs). Empty when // DenyRead is unset or the engine has no scope. func (engine *Engine) ReadExclusionGlobs() []string { - // A disabled policy filters nothing (parity with ReadExclusions / Evaluate). + // A disabled policy filters nothing the user configured, but keeps the + // automatic credential exclusion (parity with ReadExclusions / Evaluate). if engine == nil { return nil } policy := engine.effectivePolicy(engine.policy) if policy.Mode == ModeDisabled { - return nil + return ReadExclusionGlobs(Policy{}, engine.scope) } return ReadExclusionGlobs(policy, engine.scope) } @@ -183,7 +201,14 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode { } // UnsandboxedExecutionAllowed reports whether an escalated shell attempt may -// bypass the native sandbox without dropping active denied-read restrictions. +// bypass the native sandbox without dropping active denied-read restrictions, +// including the automatic remote bridge token exclusion. +// +// ModeDisabled short-circuits to true before the token is consulted, and that is +// the intended reading of the switch rather than an oversight: with the sandbox +// off there is no wrapper for an escalation to bypass, so refusing the +// escalation would deny a capability the operator already granted globally +// without protecting anything. See the ModeDisabled branch in Evaluate. func (engine *Engine) UnsandboxedExecutionAllowed() bool { if engine == nil { return true @@ -192,7 +217,7 @@ func (engine *Engine) UnsandboxedExecutionAllowed() bool { if policy.Mode == ModeDisabled { return true } - return len(normalizeProfilePaths(policy.DenyRead)) == 0 + return len(normalizeProfilePaths(policy.DenyRead)) == 0 && len(protectedCredentialPaths()) == 0 } // toolNetworkExempt reports whether a request is exempt from the engine-level @@ -316,8 +341,33 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision { request.SideEffect = NormalizeSideEffect(request.SideEffect) scope := engine.scopeFor(request.WorkspaceRoot) risk := classifyWithScope(request, scope) + // Patch targets must be established before every policy short-circuit, + // including ModeDisabled. Besides making the automatic daemon-token boundary + // enforceable, this keeps apply_patch anchored to the workspace when user + // sandboxing is off; the tool itself has the same non-optional containment. + if block := applyPatchPathBlock(request); block != nil { + return deny(request, risk, block.Code, block.Path, block.Reason, false) + } if policy.Mode == ModeDisabled { + // Disabling the sandbox drops every user-configured restriction, but not the + // automatic credential exclusion: the remote bridge token authenticates the + // caller driving these tools, so it stays unreadable and unwritable through + // them. + // + // Shell is deliberately left open here, and it is worth being blunt about + // why. ModeDisabled means no OS wrapper is built at all — the runner sets + // SandboxPreferenceForbid and PermissionProfileFromPolicy returns an + // unrestricted filesystem — so there is no layer left that could confine a + // command. Re-wrapping shell just for this file would quietly undo the + // switch the operator flipped, and would not hold anyway: a shell that can + // run anything can read anything this process can. The exclusion below is + // therefore what it says — a guarantee about Zero's own file tools, not + // about the machine. Running a remote bridge with the sandbox disabled + // means trusting whoever can drive that bridge with the token. + if block := protectedCredentialPathBlock(request, request.WorkspaceRoot); block != nil { + return deny(request, risk, block.Code, block.Path, block.Reason, false) + } return Decision{Action: ActionAllow, Risk: risk, Reason: "sandbox disabled"} } if request.Permission == PermissionDeny { @@ -352,9 +402,6 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision { // workspace boundary itself needs a root, so it is gated on having one. Mode is // already known to be enforcing here (ModeDisabled returned above). enforceWorkspace := policy.EnforceWorkspace && request.WorkspaceRoot != "" - if block := applyPatchPathBlock(request); block != nil { - return deny(request, risk, block.Code, block.Path, block.Reason, false) - } var promptableBlock *pathBlock for _, requested := range requestPaths(request) { if block := validatePathWithPolicy(scope, policy, request.SideEffect, enforceWorkspace, request.WorkspaceRoot, requested); block != nil { diff --git a/internal/sandbox/engine_test.go b/internal/sandbox/engine_test.go index 5f9633a40..ca0aa746e 100644 --- a/internal/sandbox/engine_test.go +++ b/internal/sandbox/engine_test.go @@ -236,13 +236,14 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) { engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()}) for _, tc := range []struct { - name string - args map[string]any + name string + args map[string]any + patchPaths []string }{ {name: "write_file", args: map[string]any{"path": "notes.txt"}}, {name: "edit_file", args: map[string]any{"path": "notes.txt"}}, - {name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}}, - {name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}}, + {name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}, patchPaths: []string{"notes.txt"}}, + {name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}, patchPaths: []string{"notes.txt"}}, } { t.Run(tc.name, func(t *testing.T) { decision := engine.Evaluate(context.Background(), Request{ @@ -251,6 +252,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) { Permission: PermissionPrompt, PermissionMode: PermissionModeAsk, Args: tc.args, + PatchPaths: tc.patchPaths, }) if decision.Action != ActionAllow || !decision.AutoAllowed || decision.GrantMatched { t.Fatalf("workspace mutation decision = %#v, want auto allow without grant", decision) @@ -275,12 +277,13 @@ func TestEngineDoesNotAutoAllowProtectedMetadataWrites(t *testing.T) { engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()}) for _, tc := range []struct { - name string - args map[string]any + name string + args map[string]any + patchPaths []string }{ {name: "write_file git hook", args: map[string]any{"path": ".git/hooks/pre-commit"}}, {name: "edit_file zero config", args: map[string]any{"path": ".zero/config.json"}}, - {name: "apply_patch agents metadata", args: map[string]any{"patch": "--- /dev/null\n+++ b/.agents/config.json\n@@ -0,0 +1 @@\n+{}\n"}}, + {name: "apply_patch agents metadata", args: map[string]any{"patch": "--- /dev/null\n+++ b/.agents/config.json\n@@ -0,0 +1 @@\n+{}\n"}, patchPaths: []string{".agents/config.json"}}, } { t.Run(tc.name, func(t *testing.T) { decision := engine.Evaluate(context.Background(), Request{ @@ -289,6 +292,7 @@ func TestEngineDoesNotAutoAllowProtectedMetadataWrites(t *testing.T) { Permission: PermissionPrompt, PermissionMode: PermissionModeAsk, Args: tc.args, + PatchPaths: tc.patchPaths, }) if decision.Action != ActionPrompt || decision.AutoAllowed { t.Fatalf("protected metadata decision = %#v, want prompt without auto-allow", decision) @@ -309,6 +313,7 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) { Args: map[string]any{ "patch": "--- a/notes.txt\n+++ b/../escape.txt\n@@ -0,0 +1 @@\n+escape\n", }, + PatchPaths: []string{"notes.txt", "../escape.txt"}, }) if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace { @@ -330,6 +335,7 @@ func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) { Permission: PermissionPrompt, PermissionMode: PermissionModeAsk, Args: map[string]any{"patch": patch}, + PatchPaths: []string{"../escape.txt"}, }) if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace { t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision) @@ -460,6 +466,7 @@ func TestEngineDeniesAutoAllowedSymlinkEscape(t *testing.T) { Args: map[string]any{ "patch": "--- /dev/null\n+++ b/linked/escape.txt\n@@ -0,0 +1 @@\n+escape\n", }, + PatchPaths: []string{"linked/escape.txt"}, }) if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockSymlinkTraversal { diff --git a/internal/sandbox/export_test.go b/internal/sandbox/export_test.go index c93e99b2e..9fc70c1b8 100644 --- a/internal/sandbox/export_test.go +++ b/internal/sandbox/export_test.go @@ -76,6 +76,7 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) fs.DenyRead = normalizeProfilePaths(policy.DenyRead) credentials := finalizeCredentialDenyPaths(credentialDenyReadPaths(policy, "", os.Environ(), nil), fs.DenyRead) fs.DenyReadIfExists = credentials.Paths + fs.MandatoryDenyReadPaths = credentials.MandatoryPaths fs.DenyReadCarveouts = credentials.Carveouts fs.EnsureDenyReadDirs = credentials.EnsureDirs fs.ProcessTrustedDenyReadFiles = credentials.ProcessTrustedFinalFiles diff --git a/internal/sandbox/filesystem_other.go b/internal/sandbox/filesystem_other.go new file mode 100644 index 000000000..85758395c --- /dev/null +++ b/internal/sandbox/filesystem_other.go @@ -0,0 +1,16 @@ +//go:build !darwin && !linux + +package sandbox + +import "errors" + +// Platforms without a stat-based filesystem identity report "unknown" rather +// than "separate", so a caller that fails closed on an unknown answer keeps +// doing so here. Neither helper is reached outside the Linux planner today. +func pathsShareFilesystem(_, _ string) (shared bool, known bool) { + return false, false +} + +func pathHardLinkCount(_ string) (uint64, error) { + return 0, errors.ErrUnsupported +} diff --git a/internal/sandbox/filesystem_unix.go b/internal/sandbox/filesystem_unix.go new file mode 100644 index 000000000..be08cc12f --- /dev/null +++ b/internal/sandbox/filesystem_unix.go @@ -0,0 +1,74 @@ +//go:build darwin || linux + +package sandbox + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// pathsShareFilesystem reports whether two paths live on the same filesystem. +// +// A path that does not exist YET is answered from its nearest existing +// ancestor: a write root the sandbox creates lands on whatever filesystem its +// parent is on, so "not created yet" is a knowable answer rather than an unknown +// one. That distinction matters because the profile carries roots for every +// platform — /private/tmp and /var/folders are macOS spellings that simply do +// not exist on Linux, and reading each of them as "cannot tell" would refuse +// every file-backed token on Linux. +// +// known is false only when a path's whole ancestor chain is uninspectable. That +// really is unknown, and an uninspectable root is not evidence of separation: +// callers reason about where a hard link COULD be created, so they must treat it +// as "could". +func pathsShareFilesystem(left, right string) (shared bool, known bool) { + leftDevice, leftKnown := pathFilesystemID(left) + if !leftKnown { + return false, false + } + rightDevice, rightKnown := pathFilesystemID(right) + if !rightKnown { + return false, false + } + return leftDevice == rightDevice, true +} + +// pathFilesystemID returns the device ID owning path, walking up to the nearest +// existing ancestor for a path that has not been created yet. +func pathFilesystemID(path string) (uint64, bool) { + current := filepath.Clean(path) + for { + var stat unix.Stat_t + if err := unix.Stat(current, &stat); err == nil { + return uint64(stat.Dev), true + } + parent := filepath.Dir(current) + if parent == current { + return 0, false + } + current = parent + } +} + +// pathHardLinkCount reports the number of directory entries that name the file's +// inode. A count above one proves an alias already exists somewhere the planner +// cannot enumerate, so callers must fail closed rather than mask one pathname. +// +// A missing file returns fs.ErrNotExist and count 0: an absent token has no +// inode to alias, and the pathname reservation that survives rotation is the +// lexical rule's job, not this one's. Any other inspection failure is returned +// as an error so the caller fails closed instead of reading it as "no alias". +// A non-regular file reports 0 with no error: link counts on a directory or a +// symlink do not describe an alias for the token's contents. +func pathHardLinkCount(path string) (uint64, error) { + var stat unix.Stat_t + if err := unix.Lstat(path, &stat); err != nil { + return 0, &os.PathError{Op: "lstat", Path: path, Err: err} + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return 0, nil + } + return uint64(stat.Nlink), nil +} diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index f3ea5c457..a369228b7 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -188,7 +188,10 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB "--new-session", "--die-with-parent", } - filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile) + filesystemPlan, err := buildLinuxBwrapFilesystemPlan(config.PermissionProfile) + if err != nil { + return linuxSandboxBwrapPlan{}, err + } args = append(args, filesystemPlan.Args...) if pathExists(helperPath) { args = append(args, "--ro-bind", helperPath, helperPath) @@ -221,6 +224,9 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB } func validateLinuxBwrapPermissionProfile(profile PermissionProfile) error { + if err := validateLinuxMandatoryDenyReadPaths(profile.FileSystem); err != nil { + return err + } if files := profile.FileSystem.ProcessTrustedDenyReadFiles; len(files) > 0 { return fmt.Errorf("bubblewrap cannot securely deny credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", ")) } @@ -244,12 +250,66 @@ func validateLinuxBwrapPermissionProfile(profile PermissionProfile) error { return nil } -func linuxBwrapFilesystemArgs(profile PermissionProfile) []string { - return buildLinuxBwrapFilesystemPlan(profile).Args +func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { + baseline := make(map[string]struct{}, len(fs.DenyReadIfExists)) + for _, path := range fs.DenyReadIfExists { + if identity := canonicalLinuxMandatoryPath(path); identity != "" { + baseline[identity] = struct{}{} + } + } + for _, path := range fs.MandatoryDenyReadPaths { + if path == "" || !filepath.IsAbs(path) { + return fmt.Errorf("invalid mandatory deny-read path %q: path must be absolute", path) + } + identity := canonicalLinuxMandatoryPath(path) + if _, ok := baseline[identity]; !ok { + return fmt.Errorf("invalid mandatory deny-read path %q: path is not in denyReadIfExists", path) + } + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("bubblewrap cannot enforce missing mandatory credential path %q; restore the daemon token file before running sandboxed commands", path) + } + return fmt.Errorf("inspect mandatory credential path %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q; configure the resolved token path", path) + } + } + return nil } -func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan { +// canonicalLinuxMandatoryPath gives mandatory/baseline membership one physical +// identity across macOS /var aliases and Windows short/long path spellings. +func canonicalLinuxMandatoryPath(path string) string { + if path == "" { + return "" + } + absolute, err := filepath.Abs(path) + if err != nil { + return "" + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return filepath.Clean(resolved) + } + return filepath.Clean(absolute) +} + +func mandatoryDenyReadPathSet(fs FileSystemPolicy) map[string]struct{} { + mandatory := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) + for _, path := range fs.MandatoryDenyReadPaths { + if identity := canonicalLinuxMandatoryPath(path); identity != "" { + mandatory[identity] = struct{}{} + } + } + return mandatory +} + +func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesystemPlan, error) { fs := profile.FileSystem + if err := validateLinuxMandatoryDenyReadPaths(fs); err != nil { + return linuxBwrapFilesystemPlan{}, err + } if fs.Kind == FileSystemUnrestricted { // Disabled filesystem policy means no write jail: expose the host root // read-write, including the host /dev tree, rather than synthesizing a @@ -260,7 +320,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst args = append(args, "--bind", root.Root, root.Root) } } - return linuxBwrapFilesystemPlan{Args: args} + return linuxBwrapFilesystemPlan{Args: args}, nil } args := []string{} @@ -310,8 +370,10 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst // here. Command-controlled credential roots remain deny-if-present and must // never cause host filesystem mutations before sandbox launch. ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs) + mandatory := mandatoryDenyReadPathSet(fs) for _, path := range fs.DenyReadIfExists { - if !pathExists(path) { + _, required := mandatory[canonicalLinuxMandatoryPath(path)] + if !required && !pathExists(path) { // A baseline credential path is emitted for every run, so an absent // entry is the common case on a fresh machine — a third-party store // such as ~/.aws that Zero must not create. The read-all profile starts @@ -321,12 +383,20 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst // (seatbelt) still deny these paths before they exist. continue } - args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + if required { + var err error + args, err = appendMandatoryUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + if err != nil { + return linuxBwrapFilesystemPlan{}, err + } + } else { + args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + } } return linuxBwrapFilesystemPlan{ Args: args, ProtectedCreateTargets: dedupeStrings(protectedCreateTargets), - } + }, nil } func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot { @@ -398,16 +468,39 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { } func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - path = normalizeProfilePath(path) + args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true, false) + return args +} + +func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string) ([]string, error) { + return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false, true) +} + +// appendUnreadableLinuxPathArgsForPath's Lstat is authoritative for planning. +// Mandatory paths are never skipped when they are symlinks: the planner rejects +// a symlink it already observes. For a regular path, the emitted lexical bind +// is also the launch-time guard: if the pathname is swapped to a symlink after +// planning, Bubblewrap refuses that symlink mount destination and fails closed. +func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts []string, allowMissing bool, mandatory bool) ([]string, error) { if path == "" { - return args + return args, nil } - if info, err := os.Stat(path); err == nil && !info.IsDir() { - return append(args, "--ro-bind", "/dev/null", path) + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + if mandatory { + return nil, fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q; configure the resolved token path", path) + } + return args, nil + } + if info, err := os.Stat(path); err == nil { + if !info.IsDir() { + return append(args, "--ro-bind", "/dev/null", path), nil + } + } else if !allowMissing { + return nil, fmt.Errorf("bubblewrap cannot enforce missing mandatory credential path %q: %w", path, err) } nested := nestedCarveoutPaths(path, carveouts) if len(nested) == 0 { - return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path) + return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path), nil } // A carveout has to stay reachable, and traversing into a directory needs the // execute bit, so the mask is 111 (--x--x--x) instead of 000: the directory's @@ -420,7 +513,7 @@ func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []strin args = append(args, "--ro-bind", carveout, carveout) } } - return append(args, "--remount-ro", path) + return append(args, "--remount-ro", path), nil } // nestedCarveoutPaths returns the carveouts that sit strictly inside root, diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index f3edd61eb..0d30a72a8 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -7,9 +7,19 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" ) +func mustBuildLinuxBwrapFilesystemPlan(t *testing.T, profile PermissionProfile) linuxBwrapFilesystemPlan { + t.Helper() + plan, err := buildLinuxBwrapFilesystemPlan(profile) + if err != nil { + t.Fatalf("buildLinuxBwrapFilesystemPlan: %v", err) + } + return plan +} + func TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile(t *testing.T) { profile := PermissionProfile{ FileSystem: FileSystemPolicy{ @@ -190,7 +200,7 @@ func TestLinuxBwrapRootReadUsesReadOnlyHostRoot(t *testing.T) { Network: NetworkPolicy{Mode: NetworkAllow}, } - args := linuxBwrapFilesystemArgs(profile) + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args assertArgsContainSequence(t, args, "--ro-bind", "/", "/") if argsContainSequence(args, "--tmpfs", "/") { t.Fatalf("root-read profile must not start from an empty root: %#v", args) @@ -217,7 +227,7 @@ func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) { Network: NetworkPolicy{Mode: NetworkAllow}, } - args := linuxBwrapFilesystemArgs(profile) + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args if argsContainSequence(args, "--tmpfs", "/tmp") { t.Fatalf("workspace-write temp access must bind host /tmp, not create private tmpfs: %#v", args) } @@ -256,7 +266,7 @@ func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) assertArgsContainSequence(t, plan.Args, "--ro-bind", existing, existing) if argsContainSequence(plan.Args, "--tmpfs", missing) || argsContainSequence(plan.Args, "--ro-bind", missing, missing) { t.Fatalf("missing protected metadata must remain absent inside the sandbox: %#v", plan.Args) @@ -279,7 +289,7 @@ func TestLinuxBwrapSkipsMissingCredentialBaselines(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) if stringSliceContains(plan.Args, missingCredential) { t.Fatalf("absent credential baseline must not become a mount target: %#v", plan.Args) } @@ -293,11 +303,129 @@ func TestLinuxBwrapSkipsMissingCredentialBaselines(t *testing.T) { if err := os.MkdirAll(missingCredential, 0o700); err != nil { t.Fatalf("MkdirAll credential dir: %v", err) } - plan = buildLinuxBwrapFilesystemPlan(profile) + plan = mustBuildLinuxBwrapFilesystemPlan(t, profile) normalizedCredential := normalizeProfilePath(missingCredential) assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredential, "--remount-ro", normalizedCredential) } +func TestLinuxBwrapRejectsMandatoryPathOutsideDenyBaseline(t *testing.T) { + mandatory := filepath.Join(t.TempDir(), "daemon-token") + if err := os.WriteFile(mandatory, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + MandatoryDenyReadPaths: []string{mandatory}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + if _, err := buildLinuxBwrapFilesystemPlan(profile); err == nil || !strings.Contains(err.Error(), "not in denyReadIfExists") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want mandatory-subset validation", err) + } +} + +// TestLinuxBwrapMandatoryPathRotationToUnprotectedSymlinkFailsClosed covers +// the check/use gap between the early validate pass and plan construction: a +// mandatory token file that was a regular file when the profile validated is +// replaced with a symlink to an unrelated location before +// buildLinuxBwrapFilesystemPlan runs (the shape of an external token +// rotation, or a remote worker racing sandbox launch). The plan must fail +// closed rather than silently omit any mask for the pathname, which would +// leave the rotated bearer readable through the configured token-file path. +func TestLinuxBwrapMandatoryPathRotationToUnprotectedSymlinkFailsClosed(t *testing.T) { + dir := t.TempDir() + mandatory := filepath.Join(dir, "daemon-token") + if err := os.WriteFile(mandatory, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: []string{mandatory}, + MandatoryDenyReadPaths: []string{mandatory}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + // The early, best-effort validation pass sees a regular file and passes. + if err := validateLinuxBwrapPermissionProfile(profile); err != nil { + t.Fatalf("validateLinuxBwrapPermissionProfile on the regular file: %v", err) + } + + // Simulate the rotation: replace the regular file with a symlink whose + // target is NOT itself a mandatory (or even deny-read) path. + outside := filepath.Join(dir, "replacement-target") + if err := os.WriteFile(outside, []byte("attacker bearer"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(mandatory); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, mandatory); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + plan, err := buildLinuxBwrapFilesystemPlan(profile) + if err == nil { + t.Fatalf("buildLinuxBwrapFilesystemPlan after rotation to an unprotected symlink = plan %#v, want an error", plan) + } + if !strings.Contains(err.Error(), "mandatory credential symlink") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want a mandatory-symlink error", err) + } +} + +// A mandatory symlink is rejected even when its current target is another +// mandatory path. Bubblewrap cannot mount over the lexical symlink, and +// accepting it would leave a repoint interval before namespace construction. +func TestLinuxBwrapMandatoryPathSymlinkFailsClosed(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "real-daemon-token") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "daemon-token-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: []string{link, target}, + MandatoryDenyReadPaths: []string{link, target}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + if _, err := buildLinuxBwrapFilesystemPlan(profile); err == nil || + !strings.Contains(err.Error(), "mandatory credential symlink") { + t.Fatalf("buildLinuxBwrapFilesystemPlan() error = %v, want mandatory-symlink rejection", err) + } +} + +func TestLinuxMandatoryPathMembershipUsesCanonicalIdentity(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "daemon-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(token) + if err != nil { + t.Fatal(err) + } + profile := PermissionProfile{FileSystem: FileSystemPolicy{ + DenyReadIfExists: []string{resolved}, + MandatoryDenyReadPaths: []string{token}, + }} + if err := validateLinuxMandatoryDenyReadPaths(profile.FileSystem); err != nil { + t.Fatalf("canonical-equivalent mandatory/baseline paths rejected: %v", err) + } +} + // TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking covers the long-lived // session race: bubblewrap cannot mount over a path that does not exist, so a // store written after the namespace was assembled would stay readable through @@ -318,7 +446,7 @@ func TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) if info, err := os.Stat(ownedDir); err != nil || !info.IsDir() { t.Fatalf("owned credential dir was not created: err=%v", err) } @@ -354,7 +482,7 @@ func TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) normalizedCredentialDir := normalizeProfilePath(credentialDir) normalizedPluginRoot := normalizeCredentialCarveoutPath(pluginRoot) // 111 rather than 000: a 000 directory cannot be traversed, so the re-bound @@ -397,7 +525,7 @@ func TestLinuxBwrapDoesNotBindSymlinkCarveout(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) normalizedCredentialDir := normalizeProfilePath(credentialDir) assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredentialDir, "--remount-ro", normalizedCredentialDir) if argsContainSequence(plan.Args, "--ro-bind", pluginRoot, pluginRoot) || argsContainSequence(plan.Args, "--ro-bind", secret, secret) { @@ -414,7 +542,7 @@ func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, } - args := linuxBwrapFilesystemArgs(profile) + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args assertArgsContainSequence(t, args, "--bind", "/", "/") if argsContainSequence(args, "--ro-bind", "/", "/") { t.Fatalf("unrestricted filesystem profile must not make host root read-only: %#v", args) diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index eaf604b00..a44d60d6e 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -2,10 +2,14 @@ package sandbox import ( "errors" + "fmt" + "io/fs" "os" "path/filepath" "runtime" "strings" + + "github.com/Gitlawb/zero/internal/remotetoken" ) // errWindowsSandboxNotInitialized is returned only to a caller that explicitly @@ -232,8 +236,27 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative { return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend) } - if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && policyHasExplicitDeny(policy) { - return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny_read or deny_write rules cannot be enforced") + protectedCredentials := protectedCredentialPaths() + protectedCredentialNeedsNative := policy.Mode != ModeDisabled && len(protectedCredentials) > 0 + if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && + (policyHasExplicitDeny(policy) || protectedCredentialNeedsNative) { + return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny rules or protected credentials cannot be enforced") + } + if request.ValidateExecution && preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled && + manager.goos == "darwin" && len(protectedCredentials) > 0 { + return SandboxExecutionRequest{}, errors.New("macOS Seatbelt cannot protect a file-backed remote token from inode aliases across its lifecycle; sandboxed shell commands require ZERO_DAEMON_REMOTE_TOKEN") + } + if request.ValidateExecution && preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled && + manager.goos == "windows" && len(protectedCredentials) > 0 { + return SandboxExecutionRequest{}, errors.New("the Windows sandbox enforces writes through ACLs and has no filesystem deny-read rule, so it cannot keep a file-backed remote token out of a sandboxed shell; sandboxed shell commands require ZERO_DAEMON_REMOTE_TOKEN") + } + if request.ValidateExecution && preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled && manager.goos == "linux" { + if credential, changed := protectedCredentialStartupIdentityChanged(protectedCredentials); changed { + return SandboxExecutionRequest{}, fmt.Errorf("bubblewrap cannot protect the remote token file %q because it no longer names the token object opened at daemon startup; restart the daemon or restore the original token file", credential) + } + if credential, ok := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentials); ok { + return SandboxExecutionRequest{}, fmt.Errorf("bubblewrap cannot protect the remote token file %q from hard-link aliases in a shell-accessible root: a /dev/null bind covers one pathname, not the inode; use ZERO_DAEMON_REMOTE_TOKEN, place the file on a separate filesystem, or remove that root from the sandbox", credential) + } } // Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup` // (it applies WFP network filters + workspace ACLs and writes a marker). @@ -294,6 +317,100 @@ func policyHasExplicitDeny(policy Policy) bool { return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0 } +// Windows deliberately has no placement exception either: credentialDenyReadPaths +// returns nothing there (the ACL model has no read-deny rule — see #662), so a +// wrapped shell would run with the token readable under every pathname. The +// in-process tool gate still applies on Windows, which is what keeps read_file +// and friends covered while the shell boundary stays refused. +// +// macOS deliberately has no placement exception for file-backed tokens. +// Seatbelt can deny configured and resolved pathnames, but not every existing or +// future inode alias, and a restart authorizes the configured pathname again. +// BuildExecutionRequest therefore rejects shell execution whenever file-backed +// remote authentication is selected. In-process tools remain protected. + +// protectedCredentialStartupIdentityChanged rejects sequential token rotation: +// masking a replacement pathname does not hide the still-authorized startup +// object after it has been renamed elsewhere. This is an admission check, not a +// lock over concurrent rotation and sandbox startup. +func protectedCredentialStartupIdentityChanged(protected []string) (string, bool) { + source, selected := remotetoken.SourceFromEnv() + if !selected || source.Identity == "" { + return "", false + } + for _, path := range protected { + file, err := os.Open(path) + if err != nil { + return path, true + } + identity, ok := remotetoken.IdentityOfFile(file) + closeErr := file.Close() + if !ok || closeErr != nil || identity != source.Identity { + return path, true + } + } + return "", false +} + +// protectedCredentialLinkableIntoLinuxShellRoot reports a mandatory token that a +// shell command could reach through a second directory entry for the same inode, +// and the offending pathname. +// +// Bubblewrap masks each selected pathname. A READ root containing that masked +// spelling does not expose another alias by itself; an existing alias is proven +// separately by the link count. A FUTURE alias requires a shell-writable root on +// the same filesystem. Keeping those classes separate makes the documented +// separate-filesystem placement usable even when the normal read root is "/". +func protectedCredentialLinkableIntoLinuxShellRoot(profile PermissionProfile, protected []string) (string, bool) { + if len(protected) == 0 { + return "", false + } + if profile.FileSystem.Kind == FileSystemUnrestricted { + return protected[0], true + } + if profile.FileSystem.Kind != FileSystemRestricted { + return "", false + } + writeRoots := make([]string, 0, len(profile.FileSystem.WriteRoots)+len(sandboxWritableSubpaths)) + for _, root := range profile.FileSystem.WriteRoots { + writeRoots = append(writeRoots, normalizeProfilePath(root.Root)) + } + if profile.FileSystem.AllowTemp { + writeRoots = append(writeRoots, normalizeProfilePaths(sandboxWritableSubpaths)...) + } + for _, credential := range protected { + credential = filepath.Clean(credential) + if credential == "." || credential == "" { + continue + } + // An absent token has no inode to alias; the lexical pathname rule is + // what reserves it through rotation. Any OTHER inspection failure leaves + // the alias question unanswered, and an unanswered question about a + // bearer token is a refusal. + count, err := pathHardLinkCount(credential) + switch { + case err == nil && count > 1: + return credential, true + case err != nil && !errors.Is(err, fs.ErrNotExist): + return credential, true + } + for _, root := range writeRoots { + if root == "" { + continue + } + if pathWithinRoot(root, credential) { + return credential, true + } + // An uninspectable root is not proof of a separate filesystem: it is + // a root a hard link might still be creatable in. + if shared, known := pathsShareFilesystem(root, credential); !known || shared { + return credential, true + } + } + } + return "", false +} + func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) { execRequest, err := manager.BuildExecutionRequest(request) if err != nil { diff --git a/internal/sandbox/manager_darwin_test.go b/internal/sandbox/manager_darwin_test.go new file mode 100644 index 000000000..a4a45e3e5 --- /dev/null +++ b/internal/sandbox/manager_darwin_test.go @@ -0,0 +1,48 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSandboxManagerRejectsMacOSTokenHardLinkableIntoWritableWorkspace(t *testing.T) { + workspace := t.TempDir() + token := filepath.Join(t.TempDir(), "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + + probe := filepath.Join(workspace, "token-hard-link-probe") + if err := os.Link(token, probe); err != nil { + t.Skipf("fixture paths are not hard-linkable: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatal(err) + } + + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, "") + policy := DefaultPolicy() + backend := Backend{ + Name: BackendMacOSSeatbelt, + Available: true, + Executable: "/usr/bin/sandbox-exec", + Platform: "darwin", + CommandWrapping: true, + NativeIsolation: true, + } + _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "file-backed remote token") { + t.Fatalf("BuildCommandPlan error = %v, want macOS file-token shell refusal", err) + } +} diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 6e2941602..5f4230016 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -351,6 +351,90 @@ func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) { } } +func TestSandboxManagerRejectsUnavailableBackendForProtectedToken(t *testing.T) { + workspace, _ := protectedTokenFixture(t) + policy := DefaultPolicy() + backend := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "native sandbox unavailable"} + _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "protected credentials cannot be enforced") { + t.Fatalf("BuildCommandPlan error = %v, want protected-credential enforcement failure", err) + } +} + +func TestSandboxManagerRejectsMacOSFileTokenShell(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "/credentials/bridge-token") + workspace := "/workspace" + policy := DefaultPolicy() + backend := Backend{ + Name: BackendMacOSSeatbelt, + Available: true, + Executable: "/usr/bin/sandbox-exec", + Platform: "darwin", + CommandWrapping: true, + NativeIsolation: true, + } + _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfile{FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + }}, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "file-backed remote token") { + t.Fatalf("BuildCommandPlan error = %v, want macOS file-token shell refusal", err) + } +} + +func TestSandboxManagerLeavesProtectedTokenShellOpenWhenSandboxDisabled(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "/workspace/bridge-token") + policy := DefaultPolicy() + policy.Mode = ModeDisabled + backend := Backend{Name: BackendUnavailable, Platform: "darwin", Fallback: true, Message: "native sandbox unavailable"} + request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: "/workspace", + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: "/workspace"}, + Policy: policy, + Profile: PermissionProfileFromPolicy("/workspace", policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err != nil { + t.Fatalf("BuildExecutionRequest disabled policy: %v", err) + } + if request.EnforcementLevel != EnforcementDisabled || request.CommandWrapped { + t.Fatalf("disabled request = %#v, want intentionally open shell", request) + } + + policy = DefaultPolicy() + request, err = NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: "/workspace", + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: "/workspace"}, + Policy: policy, + Profile: PermissionProfileFromPolicy("/workspace", policy, nil), + Preference: SandboxPreferenceForbid, + ValidateExecution: true, + }) + if err != nil { + t.Fatalf("BuildExecutionRequest forbidden sandbox preference: %v", err) + } + if request.EnforcementLevel != EnforcementDisabled || request.CommandWrapped { + t.Fatalf("forbidden-sandbox request = %#v, want intentionally open shell", request) + } +} + func TestSandboxManagerSelectsPlatformBackend(t *testing.T) { tests := []struct { name string @@ -542,7 +626,6 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Fatal(err) } } - options := credentialPathOptions{ Homes: []string{home}, ConfigDirs: []string{configDir}, @@ -638,7 +721,6 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { if stringSliceContains(optedOut.Carveouts, normalizeProfilePaths([]string{filepath.Join(zeroDir, "plugins")})[0]) { t.Errorf("credential carveouts = %#v, want no allow-back inside an opted-out deny", optedOut.Carveouts) } - if got := credentialDenyReadPathsIn(credentialPathOptions{}, nil); len(got.Paths) != 0 { t.Errorf("credential deny paths for blank home = %#v, want none", got.Paths) } @@ -1062,7 +1144,7 @@ func TestKeyringOAuthOverrideDoesNotFailClosedOnBubblewrap(t *testing.T) { if err := validateLinuxBwrapPermissionProfile(profile); err != nil { t.Fatalf("keyring override must not make bubblewrap fail closed: %v", err) } - _ = buildLinuxBwrapFilesystemPlan(profile) + _, _ = buildLinuxBwrapFilesystemPlan(profile) for _, publicationDir := range credentialPublicationDirs(tokenPath) { if _, err := os.Stat(publicationDir); !errors.Is(err, os.ErrNotExist) { t.Fatalf("keyring planning created unused publication directory %q: %v", publicationDir, err) @@ -1118,7 +1200,7 @@ func TestCommandCredentialDirectoriesFailClosedWithoutHostMutation(t *testing.T) if err := validateLinuxBwrapPermissionProfile(profile); err == nil || !strings.Contains(err.Error(), "created after launch") { t.Fatalf("validateLinuxBwrapPermissionProfile error = %v, want future-directory failure", err) } - _ = buildLinuxBwrapFilesystemPlan(profile) + _, _ = buildLinuxBwrapFilesystemPlan(profile) if _, err := os.Stat(commandConfig); !errors.Is(err, os.ErrNotExist) { t.Fatalf("planning created command-controlled directory %q: %v", commandConfig, err) } @@ -1138,7 +1220,7 @@ func TestCommandCredentialDirectoriesFailClosedWithoutHostMutation(t *testing.T) if err := validateLinuxBwrapPermissionProfile(profile); err != nil { t.Fatalf("existing command credential directory must remain maskable: %v", err) } - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) if !strings.Contains(strings.Join(plan.Args, "\x00"), "--perms\x00000\x00--tmpfs\x00"+normalizeProfilePath(existingRoot)) { t.Fatalf("existing command credential directory was not masked: %#v", plan.Args) } @@ -1169,7 +1251,7 @@ func TestCommandCredentialSettingsDoNotMaskAllowedRoots(t *testing.T) { if !stringSliceContains(fs.DenyReadIfExists, serviceAccount) { t.Fatalf("non-overlapping credential file was dropped: %#v", fs.DenyReadIfExists) } - bwrap := buildLinuxBwrapFilesystemPlan(profile) + bwrap := mustBuildLinuxBwrapFilesystemPlan(t, profile) if strings.Contains(strings.Join(bwrap.Args, "\x00"), "--perms\x00000\x00--tmpfs\x00"+normalizeProfilePath(workspace)) { t.Fatalf("bwrap overlaid an unreadable mask on the workspace: %#v", bwrap.Args) } @@ -1308,7 +1390,7 @@ func TestLegacyMCPOverrideDoesNotFailClosedOnBubblewrap(t *testing.T) { if err := validateLinuxBwrapPermissionProfile(profile); err != nil { t.Fatalf("legacy MCP migration input must not make Bubblewrap fail closed: %v", err) } - args := buildLinuxBwrapFilesystemPlan(profile).Args + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args foundMask := false for i := 0; i+2 < len(args); i++ { if args[i] == "--ro-bind" && args[i+1] == "/dev/null" && args[i+2] == normalizeProfilePath(legacy) { @@ -1573,7 +1655,7 @@ func TestPermissionProfileDropsAutomaticMasksCoveredByUserDeny(t *testing.T) { t.Fatalf("automatic credential fields beneath user deny were retained: paths=%#v carveouts=%#v ensure=%#v", fs.DenyReadIfExists, fs.DenyReadCarveouts, fs.EnsureDenyReadDirs) } zeroDir := filepath.Join(home, ".config", "zero") - plan := buildLinuxBwrapFilesystemPlan(profile) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) if stringSliceContains(plan.Args, zeroDir) { t.Fatalf("bwrap args contain a nested automatic mount beneath user deny %q: %#v", home, plan.Args) } @@ -1657,6 +1739,90 @@ func TestPermissionProfileIncludesZeroCredentialPaths(t *testing.T) { } } +// TestPermissionProfileDeniesAbsentDaemonTokenFile covers the window an external +// secret rotation opens: the token file is gone, but the pathname is still the +// one the next `serve-remote` start will read. Existence-filtering it out of the +// profile would let a sandboxed process recreate it with an attacker-chosen +// bearer, so the mandatory entry survives the file's absence for backend +// enforcement. +func TestPermissionProfileDeniesAbsentDaemonTokenFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read paths are disabled on Windows pending the ACL model") + } + workspace := t.TempDir() + tokenFile := filepath.Join(workspace, "daemon-token") + if err := os.WriteFile(tokenFile, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, tokenFile) + + // Rotation removes the file after the bridge has already loaded its token. + if err := os.Remove(tokenFile); err != nil { + t.Fatal(err) + } + + profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil) + want := tokenFile + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want the absent token path %q still denied", profile.FileSystem.DenyReadIfExists, want) + } + if !stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, want) { + t.Fatalf("MandatoryDenyReadPaths = %#v, want absent token path %q", profile.FileSystem.MandatoryDenyReadPaths, want) + } + + // Seatbelt must also deny writes because the workspace root is otherwise + // writable. The replacement is read by the next bridge start, not this one. + rules := credentialDenyWriteRules(profile.FileSystem, DefaultPolicy()) + if !slices.ContainsFunc(rules, func(rule string) bool { return strings.Contains(rule, want) }) { + t.Fatalf("seatbelt credential write rules = %#v, want a deny for %q", rules, want) + } + + // The complete Bubblewrap planner must distinguish this mandatory path from + // absent optional stores and fail closed instead of creating a host mountpoint + // or silently omitting the deny. + if _, err := buildLinuxBwrapFilesystemPlan(profile); err == nil || !strings.Contains(err.Error(), "missing mandatory credential path") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want missing mandatory-path failure", err) + } +} + +func TestMandatoryDaemonTokenSurvivesOptionalCredentialParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read paths are disabled on Windows pending the ACL model") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + tokenFile := filepath.Join(home, ".aws", "daemon-token") + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, tokenFile) + + profile := PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, tokenFile) { + t.Fatalf("DenyReadIfExists = %#v, optional parent must not collapse mandatory child %q", profile.FileSystem.DenyReadIfExists, tokenFile) + } + if !stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, tokenFile) { + t.Fatalf("MandatoryDenyReadPaths = %#v, want %q", profile.FileSystem.MandatoryDenyReadPaths, tokenFile) + } +} + +func TestCommandEnvironmentCannotNominateMandatoryDaemonTokenPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read paths are disabled on Windows pending the ACL model") + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "") + workspace := t.TempDir() + nominated := filepath.Join(workspace, "command-selected-token") + profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, []string{ + daemonRemoteTokenFileEnv + "=" + nominated, + }) + if stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, nominated) { + t.Fatalf("command environment nominated mandatory host path %q: %#v", nominated, profile.FileSystem.MandatoryDenyReadPaths) + } +} + func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) { root := t.TempDir() configHome := filepath.Join(root, "xdg") @@ -1766,3 +1932,47 @@ func TestCredentialPathOptionsFromEnvironmentResolvesToolPathLists(t *testing.T) t.Errorf("credential deny paths = %#v, want absolute KUBECONFIG entry %q", credentials.Paths, absoluteKubeConfig) } } + +func TestPermissionProfileDeniesDaemonTokenFile(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "daemon-token") + if err := os.WriteFile(tokenFile, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, tokenFile) + + profile := PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + if runtime.GOOS == "windows" { + if stringSliceContains(profile.FileSystem.DenyRead, tokenFile) || + stringSliceContains(profile.FileSystem.DenyReadIfExists, tokenFile) || + stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, tokenFile) { + t.Fatalf("Windows automatic profile denies = %#v, ACL enforcement is deliberately deferred", profile.FileSystem) + } + return + } + wants := []string{tokenFile} + if resolved, err := filepath.EvalSymlinks(tokenFile); err == nil && resolved != tokenFile { + wants = append(wants, resolved) + } + for _, want := range wants { + if stringSliceContains(profile.FileSystem.DenyRead, want) { + t.Fatalf("DenyRead = %#v, automatic token path must stay separate from user policy", profile.FileSystem.DenyRead) + } + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want daemon token file %q", profile.FileSystem.DenyReadIfExists, want) + } + if !stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, want) { + t.Fatalf("MandatoryDenyReadPaths = %#v, want daemon token file %q", profile.FileSystem.MandatoryDenyReadPaths, want) + } + } + + // TokenFromEnv selects the inline token when both variables are set, so the + // unused file pointer must not become an automatic OS-sandbox deny. + t.Setenv(daemonRemoteTokenEnv, "from-env") + profile = PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + for _, want := range wants { + if stringSliceContains(profile.FileSystem.DenyReadIfExists, want) || stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, want) { + t.Fatalf("automatic denies = %#v, must not protect unused token file %q when inline token takes precedence", profile.FileSystem, want) + } + } +} diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index f6815a2c7..82677ed0b 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/Gitlawb/zero/internal/remotetoken" ) // This file implements the fine-grained AllowRead/DenyRead/AllowWrite/DenyWrite @@ -41,6 +43,334 @@ func resolvePolicyPath(entry string) (string, bool) { return resolved, true } +// These aliases keep the sandbox tests and environment boundary tied to the +// shared token-source model instead of duplicating string literals. +const ( + daemonRemoteTokenEnv = remotetoken.EnvToken + daemonRemoteTokenFileEnv = remotetoken.EnvTokenFile + daemonRemoteTokenFileResolvedEnv = remotetoken.EnvTokenFileResolved + daemonRemoteTokenFileIdentityEnv = remotetoken.EnvTokenFileIdentity +) + +// The daemon-token pathname contract +// +// Every layer that interprets ZERO_DAEMON_REMOTE_TOKEN_FILE must agree on the +// SAME bytes, or one layer protects a pathname a different layer never checks. +// Each round of review on this surface came from a new place that disagreed, so +// the rule is written down once here: +// +// 1. The env value is pathname DATA, not a word. Only an all-whitespace value +// counts as unset (remotetoken.SelectedFilePath). It is never trimmed, +// never shell-split, and "~" is never expanded — os.ReadFile, the daemon's +// own reader, treats it literally. +// 2. The token source carries two identities: the operator-configured absolute +// spelling and the object resolved at daemon startup. Both remain protected; +// resolving never overwrites the only configured identity. +// 3. Tool arguments are compared as EXACT bytes, because that is what the tool +// opens (aliasedStringArg does not trim). requestPaths gates on the exact +// argument. Filesystem-derived case equivalence is applied only by the final +// containment comparison; it does not rewrite the argument bytes. +// 4. Protection is NOT re-includable. AllowRead, a permission grant, and a +// session profile all leave it in place, on every platform. +// +// A new consumer of the token pathname belongs on one of these four rules. If +// it needs a fifth, the contract is what changes — not just that call site. +// +// protectedCredentialPaths returns credential files that Zero's own in-process +// file tools must never read or modify, independent of Policy. +// +// The shared token source retains the operator-configured absolute spelling and +// the object resolved at daemon startup. The former reserves the authority +// boundary across replacement and restart; the latter protects the bearer object +// currently held by the daemon. A non-daemon caller without the resolved marker +// resolves the current target best-effort. +func protectedCredentialPaths() []string { + source, selected := remotetoken.SourceFromEnv() + if !selected { + return nil + } + return dedupeStrings(source.Paths()) +} + +// protectedCredentialPathBlock returns the block for the first requested path +// that targets a protected credential file, or nil. It exists for the callers +// that bypass validatePathWithPolicy — currently the ModeDisabled short-circuit, +// where the exclusion still applies because it is not policy-derived. +// +// Only the side effects that name a path are covered. SideEffectShell is not, +// and cannot be: a shell request carries a command line, not a file path, so +// there is nothing here to compare against the token. Shell is confined by the +// OS wrapper instead — which under ModeDisabled does not exist. See the +// ModeDisabled short-circuit in Engine.Evaluate for what that boundary means. +func protectedCredentialPathBlock(request Request, workspaceRoot string) *pathBlock { + switch request.SideEffect { + case SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace: + default: + return nil + } + protected := protectedCredentialPaths() + if len(protected) == 0 { + return nil + } + verb := "readable" + if request.SideEffect != SideEffectRead { + verb = "writable" + } + for _, path := range requestPaths(request) { + if protectedPathDenied(protected, workspaceRoot, path) { + return &pathBlock{ + Code: BlockDenied, + Path: path, + Reason: path + " holds the remote bridge token and is never " + verb, + } + } + } + return nil +} + +// protectedPathDenied reports whether path targets one of the protected +// credential files. There is no allow-list consultation by design. +func protectedPathDenied(protected []string, workspaceRoot, path string) bool { + return newProtectedPathCache(protected).denied(workspaceRoot, path) +} + +// protectedPathCache resolves the parts of the check that depend only on the +// protected set and not on the path being tested: the owning filesystem’s case +// semantics per entry, and each entry’s os.FileInfo for the inode comparison. A +// search walk builds one and reuses it for every visited file, so PathExcluded +// stops paying several stat calls per entry across a large repository. +// +// The cached FileInfo is a snapshot taken when the exclusions are built. That is +// the right lifetime for one walk or one request; the lexical rule below needs +// no stat at all and is what keeps the configured pathname reserved when the +// token is rotated underneath a long walk. +type protectedPathCache struct { + roots []string + folds []bool + infos []os.FileInfo + identity string +} + +func newProtectedPathCache(roots []string) *protectedPathCache { + if len(roots) == 0 { + return nil + } + cache := &protectedPathCache{ + roots: roots, + folds: make([]bool, len(roots)), + infos: make([]os.FileInfo, len(roots)), + } + cache.identity = os.Getenv(daemonRemoteTokenFileIdentityEnv) + for index, root := range roots { + cache.folds[index] = protectedPathFoldsCase(root) + if info, err := os.Stat(root); err == nil { + cache.infos[index] = info + } + } + return cache +} + +// denied reports whether path targets one of the protected credential files. +// There is no allow-list consultation by design. +func (cache *protectedPathCache) denied(workspaceRoot, path string) bool { + if cache == nil { + return false + } + for index, root := range cache.roots { + if pathUnderProtectedRootFolding(path, root, workspaceRoot, cache.folds[index]) { + return true + } + } + + // Keep the lexical check above: in particular, it protects a configured + // pathname even when the file is absent, preventing its replacement. For an + // existing request, also compare the object reached by the filesystem. This + // closes aliases created after the token path was selected: EvalSymlinks + // catches symbolic links, while SameFile catches hard links (and any other + // platform-specific names for the same file). + // + // What this closes and what it does NOT: the comparison is made BEFORE the + // tool opens the path, so it catches every alias that exists at check time + // and stays put — a symlink or hard link created after the token path was + // selected. It is not an open-time binding, so a writer that repoints a + // symlink between this check and the tool's own open still wins that race. + // Closing that window means passing an open handle down to the comparison, + // which the tool layer does not do today: read_file and friends resolve a + // path argument and open it themselves. ReadExclusions.FileExcluded is the + // handle-bound form for the callers that DO own the open (MCP resources/read + // reads through it), and it is where the rest should migrate. + // + // The OS layer is pathname-based and + // stays that way: seatbelt and Bubblewrap rules name paths, so a sandboxed + // shell on macOS can still `ln alias && cat alias` — a hard link is a + // second name for the same inode, and no path-based rule covers a name that + // did not exist when the profile was built. That is the same model a + // user-configured DenyRead has always had, deliberately: an aliasing defense + // at the OS layer would have to resolve every path at open time, which is not + // something either backend's policy language expresses. Shell access to a + // host running a remote bridge is therefore access to the token, and the + // protection here is the in-process boundary plus the pathname deny rules, + // not an inode-tight OS guarantee. + abs := path + if !filepath.IsAbs(abs) { + if workspaceRoot == "" { + return false + } + abs = filepath.Join(workspaceRoot, abs) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return false + } + requestInfo, err := os.Stat(resolved) + if err != nil { + return false + } + // A protected credential is a regular file, so a directory, device, or + // socket reached by a walk can never be an alias of one. Deciding that from + // the stat already taken keeps the SameFile comparisons off the majority of + // the entries in a tree walk. + if !requestInfo.Mode().IsRegular() { + return false + } + if identity, ok := remotetoken.IdentityOf(requestInfo); ok && identity == cache.identity && identity != "" { + return true + } + for _, protectedInfo := range cache.infos { + if protectedInfo == nil { + continue + } + if os.SameFile(requestInfo, protectedInfo) { + return true + } + } + return false +} + +// protectedInfoDenied compares an ALREADY-OPEN object against the protected +// credential set. The caller holds the handle its os.FileInfo came from, so the +// answer describes the object that will actually be read rather than whatever +// the pathname resolves to on a second look — there is no window between this +// check and the use. Callers that own the open should prefer it; a caller that +// only has a pathname is stuck with the pre-open comparison above. +func protectedInfoDenied(protected []string, file *os.File, info os.FileInfo) bool { + if info == nil { + return false + } + identity, ok := remotetoken.IdentityOfFile(file) + if !ok { + identity, ok = remotetoken.IdentityOf(info) + } + if startupIdentity := os.Getenv(daemonRemoteTokenFileIdentityEnv); startupIdentity != "" { + // An actual consumer must not fall back to current pathname metadata + // when the startup identity cannot be established for its open handle. + if file != nil && !ok || ok && identity == startupIdentity { + return true + } + } + for _, entry := range protected { + protectedInfo, err := os.Stat(entry) + if err == nil && os.SameFile(info, protectedInfo) { + return true + } + } + return false +} + +type pathCaseSemantics uint8 + +const ( + pathCaseUnknown pathCaseSemantics = iota + pathCaseSensitive + pathCaseInsensitive +) + +// protectedPathFoldsCase derives name equivalence from the filesystem that owns +// the protected path, not from GOOS. For an absent token it probes the nearest +// existing ancestor, which preserves the reservation through rotation. An +// indeterminate result fails closed by folding. +func protectedPathFoldsCase(path string) bool { + return detectPathCaseSemantics(path, os.Stat) != pathCaseSensitive +} + +func detectPathCaseSemantics(path string, stat func(string) (os.FileInfo, error)) pathCaseSemantics { + current, err := filepath.Abs(path) + if err != nil { + return pathCaseUnknown + } + current = filepath.Clean(current) + for { + info, statErr := stat(current) + if statErr == nil { + name := filepath.Base(current) + if variantName, ok := caseVariant(name); ok { + variant := filepath.Join(filepath.Dir(current), variantName) + variantInfo, variantErr := stat(variant) + switch { + case variantErr == nil && os.SameFile(info, variantInfo): + return pathCaseInsensitive + case variantErr == nil: + return pathCaseSensitive + case os.IsNotExist(variantErr): + return pathCaseSensitive + default: + return pathCaseUnknown + } + } + } else if !os.IsNotExist(statErr) { + return pathCaseUnknown + } + parent := filepath.Dir(current) + if parent == current { + return pathCaseUnknown + } + current = parent + } +} + +func caseVariant(name string) (string, bool) { + for index := range len(name) { + switch { + case name[index] >= 'a' && name[index] <= 'z': + return name[:index] + string(name[index]-('a'-'A')) + name[index+1:], true + case name[index] >= 'A' && name[index] <= 'Z': + return name[:index] + string(name[index]+('a'-'A')) + name[index+1:], true + } + } + return "", false +} + +// pathUnderProtectedRootFolding is pathUnderPolicyRoot for the automatic +// credential exclusions: identical anchoring and symlink normalization, plus the +// owning filesystem's case semantics — which the caller supplies, because +// deriving it probes ancestors and depends only on the root. protectedPathCache +// derives it once per root and reuses it for every path a walk visits. +func pathUnderProtectedRootFolding(requestedPath, root, workspaceRoot string, folds bool) bool { + normalized, ok := normalizePathForPolicyRoot(requestedPath, root, workspaceRoot) + if !ok { + return false + } + if pathWithinRootExact(root, normalized) { + return true + } + if !folds { + return false + } + return pathWithinRootExact(strings.ToLower(root), strings.ToLower(normalized)) +} + +func pathWithinRootExact(root, candidate string) bool { + root = filepath.Clean(root) + candidate = filepath.Clean(candidate) + if candidate == root { + return true + } + if filepath.Dir(root) == root { + return strings.HasPrefix(candidate, root) + } + return strings.HasPrefix(candidate, root+string(filepath.Separator)) +} + // resolvePolicyPaths resolves and de-duplicates a list of policy path entries, // dropping blanks and non-existent entries. Files and directories are both kept // (a DenyRead/DenyWrite entry may target a single sensitive file). @@ -92,18 +422,29 @@ func resolveWriteRootPaths(entries []string) []string { // symlink prefix cannot evade the match. root must be an already-resolved // absolute path. func pathUnderPolicyRoot(requestedPath, root, workspaceRoot string) bool { - if root == "" { + normalized, ok := normalizePathForPolicyRoot(requestedPath, root, workspaceRoot) + if !ok { return false } + return pathWithinRoot(root, normalized) +} + +// normalizePathForPolicyRoot anchors requestedPath (a relative one against +// workspaceRoot) and symlink-normalizes the portion outside root, yielding the +// path pathUnderPolicyRoot compares. ok is false when there is nothing to +// compare against: a blank root, or a relative path with no workspace root. +func normalizePathForPolicyRoot(requestedPath, root, workspaceRoot string) (string, bool) { + if root == "" { + return "", false + } abs := requestedPath if !filepath.IsAbs(abs) { if workspaceRoot == "" { - return false + return "", false } abs = filepath.Join(workspaceRoot, abs) } - normalized := NormalizePrefixForRoot(abs, root) - return pathWithinRoot(root, normalized) + return NormalizePrefixForRoot(abs, root), true } // readDenied reports whether path is excluded by the DenyRead list with no @@ -143,6 +484,36 @@ func readDeniedResolved(workspaceRoot string, denyRoots, allowRoots []string, pa return false } +// ProtectedCredentialExclusions returns exclusions covering ONLY the automatic, +// non-overrideable credential paths — no policy involved. +// +// Every other exclusion set is built from an Engine, which is the right shape +// for a tool invoked through the agent. But the protected-credential set comes +// from this process's own environment rather than from any policy, so a tool +// reached WITHOUT an engine (MCP, legacy registry.Run) has no reason to be less +// protected than the same tool reached with one. Callers on that path use this +// to keep the bridge bearer token out of their output. +// +// Active() only when a credential is actually selected, so the no-token case +// stays a no-op and the walk behaves exactly as it did before. +func ProtectedCredentialExclusions(workspaceRoot string) ReadExclusions { + return newReadExclusions(workspaceRoot, nil, nil, protectedCredentialPaths()) +} + +// newReadExclusions builds the matcher with the per-run constants of the +// protected set resolved once. Every construction site goes through it so the +// cache is never left nil on a set that has entries — a ReadExclusions built as +// a bare literal would re-stat each protected entry per visited path. +func newReadExclusions(workspaceRoot string, denyRoots, allowRoots, protectedRoots []string) ReadExclusions { + return ReadExclusions{ + workspaceRoot: workspaceRoot, + denyRoots: denyRoots, + allowRoots: allowRoots, + protectedRoots: protectedRoots, + protected: newProtectedPathCache(protectedRoots), + } +} + // ReadExclusions holds the resolved DenyRead/AllowRead roots for a policy so a // search walk resolves each policy entry ONCE (Abs/EvalSymlinks) and reuses the // result across every visited path, rather than re-resolving per path. Build it @@ -151,23 +522,71 @@ type ReadExclusions struct { workspaceRoot string denyRoots []string allowRoots []string + // protectedRoots are the automatic credential exclusions + // (protectedCredentialPaths); AllowRead never re-includes them. + protectedRoots []string + // protected caches what the protected-set check can resolve ahead of the + // walk. Nil when nothing is protected. + protected *protectedPathCache } -// Active reports whether any DenyRead root is configured. When false the -// exclusions are a no-op and the search behaves exactly as before. +// Active reports whether anything is excluded: a configured DenyRead root or an +// automatic protected credential path. When false the exclusions are a no-op and +// the search behaves exactly as before. func (rx *ReadExclusions) Active() bool { - return rx != nil && len(rx.denyRoots) > 0 + return rx != nil && (len(rx.denyRoots) > 0 || len(rx.protectedRoots) > 0) } // PathExcluded reports whether reading path is excluded by DenyRead, honoring a -// more-specific AllowRead re-inclusion. It is the per-file predicate for a walk. +// more-specific AllowRead re-inclusion, or by an automatic credential exclusion, +// which no allow entry re-includes. It is the per-file predicate for a walk. func (rx *ReadExclusions) PathExcluded(path string) bool { if !rx.Active() { return false } + if rx.protectedDenied(path) { + return true + } return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) } +// FileExcluded is PathExcluded for a caller that has already OPENED the object, +// with info taken from that handle (os.File.Stat). Binding the credential +// comparison to the opened object removes the check-to-use window a pathname +// check leaves open: a rename or a repointed symlink between the check and the +// read cannot change what info describes, because it describes the handle the +// caller will read from rather than whatever the name resolves to next. +// +// The pathname rules still apply on top, so a configured token pathname stays +// excluded even when the file behind it does not exist. +func (rx *ReadExclusions) FileExcluded(path string, info os.FileInfo) bool { + return rx.FileHandleExcluded(path, nil, info) +} + +// FileHandleExcluded performs the stable-identity check against the consumed +// handle. This is required on Windows, where FileInfo omits the file index. +func (rx *ReadExclusions) FileHandleExcluded(path string, file *os.File, info os.FileInfo) bool { + if !rx.Active() { + return false + } + if rx.protectedDenied(path) { + return true + } + if protectedInfoDenied(rx.protectedRoots, file, info) { + return true + } + return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) +} + +func (rx *ReadExclusions) protectedDenied(path string) bool { + if rx.protected != nil { + return rx.protected.denied(rx.workspaceRoot, path) + } + // A ReadExclusions built as a bare literal (tests, older call sites) still + // gets the full check, just without the per-run cache. + return protectedPathDenied(rx.protectedRoots, rx.workspaceRoot, path) +} + // DirExcluded reports whether a directory subtree can be skipped wholesale during // a walk: it is read-denied AND contains no nested AllowRead root (descending is // required to reach a re-included subtree). When it returns false on a denied dir @@ -176,6 +595,11 @@ func (rx *ReadExclusions) DirExcluded(path string) bool { if !rx.Active() { return false } + // A protected credential entry is a file, so it only prunes a directory when + // the directory IS that entry; PathExcluded still filters it during the walk. + if rx.protectedDenied(path) { + return true + } if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) { return false } @@ -212,6 +636,16 @@ func allowWriteScope(policy Policy) *Scope { // enforceWorkspace; the workspace boundary itself applies only when // enforceWorkspace. It never bypasses the symlink/out-of-workspace guards. func validateWritePath(scope *Scope, policy Policy, enforceWorkspace bool, workspaceRoot, path string) *pathBlock { + // The protected credential files outrank every allow: overwriting or + // truncating the bridge token denies service, and replacing it hands the next + // bridge start an attacker-chosen secret. + if protectedPathDenied(protectedCredentialPaths(), workspaceRoot, path) { + return &pathBlock{ + Code: BlockDenied, + Path: path, + Reason: path + " holds the remote bridge token and is never writable", + } + } // DenyWrite wins regardless of workspace enforcement. for _, deny := range resolvePolicyPaths(policy.DenyWrite) { if pathUnderPolicyRoot(path, deny, workspaceRoot) { @@ -252,7 +686,9 @@ func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect, // when there is anything to enforce; otherwise it is a no-op (unchanged from the // pre-path-list behavior, where an empty workspace root skipped validation). if workspaceRoot == "" && !filepath.IsAbs(path) { - if enforceWorkspace || policyHasPathLists(policy) { + // A configured bridge token counts as something to enforce: the relative + // path cannot be anchored, so it cannot be proven to miss the token file. + if enforceWorkspace || policyHasPathLists(policy) || len(protectedCredentialPaths()) > 0 { return &pathBlock{ Code: BlockOutsideWorkspace, Path: path, @@ -263,6 +699,13 @@ func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect, } switch sideEffect { case SideEffectRead: + if protectedPathDenied(protectedCredentialPaths(), workspaceRoot, path) { + return &pathBlock{ + Code: BlockDenied, + Path: path, + Reason: path + " holds the remote bridge token and is never readable", + } + } if readDenied(policy, workspaceRoot, path) { return &pathBlock{ Code: BlockDenied, @@ -325,7 +768,9 @@ func workspaceRelGlob(workspaceRoot, target string) (string, bool) { // consumer. Empty when DenyRead is unset (the default), so search behavior is // unchanged. func ReadExclusionGlobs(policy Policy, scope *Scope) []string { - denyRoots := resolvePolicyPaths(policy.DenyRead) + // The automatic credential exclusions ride along so an rg-based consumer never + // walks the bridge token when it happens to live inside the workspace. + denyRoots := dedupeStrings(append(resolvePolicyPaths(policy.DenyRead), protectedCredentialPaths()...)) if len(denyRoots) == 0 || scope == nil { return nil } diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 5cd97a9bb..109e357c0 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -28,8 +28,13 @@ type FileSystemPolicy struct { DenyRead []string `json:"denyRead,omitempty"` // DenyReadIfExists contains best-effort baseline paths. Backends with // path-based policies can protect future paths; mount-based Linux only - // masks entries that exist when the namespace is assembled. + // masks optional entries that exist when the namespace is assembled. DenyReadIfExists []string `json:"denyReadIfExists,omitempty"` + // MandatoryDenyReadPaths is the exact subset of DenyReadIfExists that names + // the selected remote-daemon bearer token. Unlike optional credential-store + // candidates, these paths must not disappear from a mount-based plan merely + // because rotation has temporarily removed the file. + MandatoryDenyReadPaths []string `json:"mandatoryDenyReadPaths,omitempty"` // DenyReadCarveouts are subtrees that stay readable INSIDE a denied root. // They exist so a directory-level credential deny can also cover the files // a store publishes (arbitrary temporary names, files created later in the @@ -164,6 +169,7 @@ func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco WriteRoots: writeRoots, DenyRead: userDenyRead, DenyReadIfExists: credentials.Paths, + MandatoryDenyReadPaths: credentials.MandatoryPaths, DenyReadCarveouts: credentials.Carveouts, EnsureDenyReadDirs: credentials.EnsureDirs, ProcessTrustedDenyReadFiles: credentials.ProcessTrustedFinalFiles, @@ -220,6 +226,7 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop // Zero-owned directories a mount-based backend may create so its mask exists. type credentialDenyPaths struct { Paths []string + MandatoryPaths []string Carveouts []string EnsureDirs []string Dirs []string @@ -234,8 +241,8 @@ type credentialDenyPaths struct { // credentialDenyReadPaths returns default deny-read entries for well-known // credential stores, including tool configuration files discoverable through -// the preserved caller environment and Zero's own config/token stores. Four -// deliberate limits: +// the preserved caller environment, Zero's own config/token stores, and the +// selected daemon remote token file. Four deliberate limits: // // - Windows is skipped: a non-empty profile DenyRead is unsupported on both // restricted-token runner levels under the narrow SID set (PR #640). The @@ -243,7 +250,11 @@ type credentialDenyPaths struct { // Users/AuthUsers, and adding those groups reopens write grants outside // WriteRoots. Revisit once access-time confinement exists. // - A candidate nested under a user-configured AllowRead entry is dropped, -// so `allowRead: ["~/.aws"]` remains an explicit opt-out. +// so `allowRead: ["~/.aws"]` remains an explicit opt-out. The bridge bearer +// token is the one exception: the in-process tool boundary +// (protectedCredentialPaths) treats it as non-overrideable, and the +// guarantee must not depend on whether a wrapped shell command or a built-in +// tool does the reading. // - Candidates are emitted whether or not they currently exist on disk. // Pathname-policy backends such as Seatbelt can enforce future paths; // mount-based Linux masks a path only if it exists when the namespace is @@ -274,6 +285,11 @@ func credentialDenyReadPaths(policy Policy, commandDir string, commandEnv []stri processOptions, policy.AllowRead, ) + // The daemon-token path is selected by this Zero process, not by a command's + // supplied environment. Keep it as an exact mandatory subset so a command + // cannot nominate an arbitrary host path for fail-closed materialization. + trusted.MandatoryPaths = protectedCredentialPaths() + trusted.Paths = dedupeStrings(append(trusted.Paths, trusted.MandatoryPaths...)) processFinalFiles := credentialFinalTokenFiles(processOptions) allowRoots := normalizeProfilePaths(policy.AllowRead) for _, file := range processFinalFiles { @@ -618,7 +634,7 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string out = append(out, path) } return credentialDenyPaths{ - Paths: out, + Paths: dedupeStrings(out), Carveouts: credentialCarveoutPaths(out, carveouts), EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)), Dirs: credentialRetainedDirs(out, normalizeProfilePaths(dirs)), @@ -786,13 +802,28 @@ func credentialRetainedDirs(denied []string, dirs []string) []string { // Children inside a retained carveout stay explicit denies, because the // carveout re-allows that subtree. func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead []string) credentialDenyPaths { - credentials.Paths = pathsOutsideRoots(credentials.Paths, userDenyRead) + // A broader user read denial may subsume optional credential stores, but the + // selected daemon token remains exact and mandatory: native backends also use + // this entry to deny writes, which a parent read denial does not imply. + credentials.MandatoryPaths = dedupeStrings(credentials.MandatoryPaths) + credentials.Paths = dedupeStrings(append(pathsOutsideRoots(credentials.Paths, userDenyRead), credentials.MandatoryPaths...)) credentials.Carveouts = pathsOutsideOverlappingRoots(credentials.Carveouts, userDenyRead) credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs) credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts) + mandatory := make(map[string]struct{}, len(credentials.MandatoryPaths)) + for _, path := range credentials.MandatoryPaths { + mandatory[path] = struct{}{} + } var paths []string for _, path := range credentials.Paths { + // An optional automatic directory is not durable coverage for a mandatory + // child: Linux may skip that directory when it is absent. Keep the exact + // daemon-token pathname so the backend can enforce or reject it separately. + if _, ok := mandatory[path]; ok { + paths = append(paths, path) + continue + } covered := false for _, dir := range credentials.Dirs { if path != dir && pathWithinRoot(dir, path) && !credentialPathReincluded(credentials.Carveouts, path) { @@ -805,6 +836,17 @@ func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead [ } } credentials.Paths = dedupeStrings(paths) + retainedPaths := make(map[string]struct{}, len(credentials.Paths)) + for _, path := range credentials.Paths { + retainedPaths[path] = struct{}{} + } + var retainedMandatory []string + for _, path := range credentials.MandatoryPaths { + if _, ok := retainedPaths[path]; ok { + retainedMandatory = append(retainedMandatory, path) + } + } + credentials.MandatoryPaths = dedupeStrings(retainedMandatory) credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs) credentials.CommandDirs = credentialRetainedDirs(credentials.Paths, credentials.CommandDirs) credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts) diff --git a/internal/sandbox/profile_worktree_test.go b/internal/sandbox/profile_worktree_test.go index e89d23953..c86dc1bce 100644 --- a/internal/sandbox/profile_worktree_test.go +++ b/internal/sandbox/profile_worktree_test.go @@ -50,7 +50,7 @@ func TestLinuxBwrapPlanDoesNotMountBelowWorktreePointer(t *testing.T) { ReadOnlySubpaths: gitMetadataWriteCarveouts(root), }}, }} - args := linuxBwrapFilesystemArgs(profile) + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args assertArgsContainSequence(t, args, "--ro-bind", gitPath, gitPath) for _, child := range []string{"hooks", "config"} { bogus := filepath.Join(gitPath, child) diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go new file mode 100644 index 000000000..1c65fb678 --- /dev/null +++ b/internal/sandbox/protected_credentials_test.go @@ -0,0 +1,1053 @@ +package sandbox + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" +) + +// protectedTokenFixture writes a bridge token inside the workspace and points +// ZERO_DAEMON_REMOTE_TOKEN_FILE at it. +func protectedTokenFixture(t *testing.T) (string, string) { + t.Helper() + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(ws, "bridge-token") + if err := os.WriteFile(token, []byte("secret\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + return ws, token +} + +func TestProtectedCredentialPathsResolveLikeTheDaemonReader(t *testing.T) { + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(base, "token") + if err := os.WriteFile(token, []byte("secret\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + t.Run("absent variable protects nothing", func(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "") + if got := protectedCredentialPaths(); len(got) != 0 { + t.Fatalf("protected paths = %#v, want none", got) + } + }) + + t.Run("inline token leaves the unused file pointer unprotected", func(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "from-env") + t.Setenv(daemonRemoteTokenFileEnv, token) + if got := protectedCredentialPaths(); len(got) != 0 { + t.Fatalf("protected paths = %#v, want none when the inline token takes precedence", got) + } + }) + + t.Run("relative value resolves against the working directory", func(t *testing.T) { + // os.ReadFile — what the daemon uses — resolves a relative value against the + // working directory, so the protected path must do the same. + t.Chdir(base) + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "token") + if got := protectedCredentialPaths(); !stringSliceContains(got, token) { + t.Fatalf("protected paths = %#v, want %q", got, token) + } + }) + + t.Run("a literal tilde is not home-expanded", func(t *testing.T) { + // os.ReadFile treats "~" as an ordinary directory name; expanding it here + // would protect a path the daemon never reads. + t.Chdir(base) + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, filepath.Join("~", "token")) + want := filepath.Join(base, "~", "token") + got := protectedCredentialPaths() + if !stringSliceContains(got, want) { + t.Fatalf("protected paths = %#v, want literal %q", got, want) + } + home, err := os.UserHomeDir() + if err == nil && stringSliceContains(got, filepath.Join(home, "token")) { + t.Fatalf("protected paths = %#v, must not home-expand the value", got) + } + }) + + t.Run("filename whitespace is part of the pathname", func(t *testing.T) { + // The daemon reads exactly the bytes the variable names, so trimming the + // pointer here would protect "/token" while the bearer file is + // "/token " — leaving the real credential readable and replaceable. + if runtime.GOOS == "windows" { + t.Skip("Windows filenames cannot end in a space") + } + spaced := filepath.Join(base, "token ") + if err := os.WriteFile(spaced, []byte("secret\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, spaced) + got := protectedCredentialPaths() + if !stringSliceContains(got, spaced) { + t.Fatalf("protected paths = %#v, want %q", got, spaced) + } + if stringSliceContains(got, token) { + t.Fatalf("protected paths = %#v, must not protect the trimmed name %q", got, token) + } + }) + + t.Run("a symlinked pathname protects the link and its target", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + link := filepath.Join(base, "token-link") + if err := os.Symlink(token, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, link) + got := protectedCredentialPaths() + for _, want := range []string{link, token} { + if !stringSliceContains(got, want) { + t.Fatalf("protected paths = %#v, want %q", got, want) + } + } + }) + + t.Run("startup source survives symlink retarget until restart", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + oldTarget := filepath.Join(base, "old-token") + newTarget := filepath.Join(base, "new-token") + for _, path := range []string{oldTarget, newTarget} { + if err := os.WriteFile(path, []byte("secret\n"), 0o600); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(base, "rotating-token") + if err := os.Symlink(oldTarget, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, link) + t.Setenv(daemonRemoteTokenFileResolvedEnv, oldTarget) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(newTarget, link); err != nil { + t.Fatal(err) + } + + got := protectedCredentialPaths() + for _, want := range []string{link, oldTarget} { + if !stringSliceContains(got, want) { + t.Fatalf("protected paths after retarget = %#v, want startup identity %q", got, want) + } + } + if stringSliceContains(got, newTarget) { + t.Fatalf("protected paths after retarget = %#v, current run must not silently re-authorize new target %q", got, newTarget) + } + + t.Setenv(daemonRemoteTokenFileResolvedEnv, newTarget) + if got := protectedCredentialPaths(); !stringSliceContains(got, newTarget) { + t.Fatalf("protected paths after simulated restart = %#v, want new startup target %q", got, newTarget) + } + }) +} + +// TestProtectedCredentialsSurviveAllowRead locks in the non-opt-out guarantee: +// the bridge token grants control of the daemon, so neither AllowRead, an +// AllowWrite root, nor a granted permission may re-include it. +func TestProtectedCredentialsSurviveAllowRead(t *testing.T) { + ws, token := protectedTokenFixture(t) + policy := Policy{ + Mode: ModeEnforce, + EnforceWorkspace: true, + AllowRead: []string{ws, token}, + AllowWrite: []string{ws}, + } + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + + for _, sideEffect := range []SideEffect{SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace} { + block := validatePathWithPolicy(scope, policy, sideEffect, true, ws, token) + if block == nil || !strings.Contains(block.Reason, "remote bridge token") { + t.Fatalf("%s on the bridge token: block = %#v, want a bridge-token deny", sideEffect, block) + } + } + + // The search-walk matcher enforces the same exclusion without consulting + // AllowRead, and it is active even though DenyRead is empty. + engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: policy, Scope: scope}) + rx := engine.ReadExclusions() + if !rx.Active() { + t.Fatal("read exclusions must be active for the automatic credential deny") + } + if !rx.PathExcluded(token) { + t.Fatalf("read exclusions must exclude the bridge token %q", token) + } + if rx.PathExcluded(filepath.Join(ws, "main.go")) { + t.Fatal("read exclusions must not exclude ordinary workspace files") + } + if globs := ReadExclusionGlobs(policy, scope); !stringSliceContains(globs, "!bridge-token") { + t.Fatalf("read exclusion globs = %#v, want the bridge token excluded", globs) + } +} + +func TestProtectedCredentialDirExcluded(t *testing.T) { + ws, token := protectedTokenFixture(t) + engine := NewEngine(EngineOptions{ + WorkspaceRoot: ws, + Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowRead: []string{token}}, + }) + + if !engine.ReadExclusions().DirExcluded(token) { + t.Fatalf("DirExcluded must enforce the protected credential path %q", token) + } +} + +func TestProtectedCredentialPreventsUnsandboxedExecution(t *testing.T) { + ws, _ := protectedTokenFixture(t) + engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: DefaultPolicy()}) + + if engine.UnsandboxedExecutionAllowed() { + t.Fatal("UnsandboxedExecutionAllowed must stay false while a credential path is protected") + } +} + +// TestProtectedCredentialsRejectSessionPermissionProfile covers the other +// re-inclusion route: a session/turn permission profile that asks for the token +// path must not be auto-applicable. +func TestProtectedCredentialsRejectSessionPermissionProfile(t *testing.T) { + ws, token := protectedTokenFixture(t) + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + engine := NewEngine(EngineOptions{ + WorkspaceRoot: ws, + Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true}, + Scope: scope, + }) + if engine.CoversRequestPermissions(RequestPermissionProfile{ + FileSystem: &FileSystemPermissions{Read: []string{token}}, + }) { + t.Fatal("a permission request covering the bridge token must not read as already-granted") + } + if !engine.CoversRequestPermissions(RequestPermissionProfile{ + FileSystem: &FileSystemPermissions{Read: []string{filepath.Join(ws, "main.go")}}, + }) { + t.Fatal("an ordinary workspace read request must stay covered by policy") + } +} + +// TestProtectedCredentialsDenyReadAndWriteInSeatbeltProfile covers the macOS +// backend: a token under a writable root was read-denied but still truncatable +// through the broad write allow. A user-configured DenyRead entry keeps the write +// direction (see TestSeatbeltProfileProtectsMetadataAndDenyOrdering). +func TestProtectedCredentialsDenyReadAndWriteInSeatbeltProfile(t *testing.T) { + ws, token := protectedTokenFixture(t) + userDenied := filepath.Join(ws, "generated") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: ws}}, + DenyRead: []string{token, userDenied}, + AllowTemp: true, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + sbpl := seatbeltProfileFromPermissionProfile(profile, Policy{Mode: ModeEnforce, DenyRead: []string{userDenied}}, "") + escaped := sandboxProfileString(normalizeProfilePath(token)) + denyRead := `(deny file-read* (literal "` + escaped + `"))` + denyWrite := `(deny file-write* (literal "` + escaped + `"))` + for _, want := range []string{denyRead, denyWrite} { + if !strings.Contains(sbpl, want) { + t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl) + } + } + if strings.Contains(sbpl, `(deny file-write* (literal "`+sandboxProfileString(normalizeProfilePath(userDenied))+`"))`) { + t.Fatalf("a user-configured DenyRead path must stay writable:\n%s", sbpl) + } + // Seatbelt is last-match-wins, so the denial must follow the broad allow. + if allow := strings.Index(sbpl, "(allow file-write*"); allow < 0 || strings.Index(sbpl, denyWrite) < allow { + t.Fatalf("the write denial must follow the broad write allow:\n%s", sbpl) + } +} + +func TestMandatoryTokenKeepsExactSeatbeltDenialsUnderParentDenyRead(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Seatbelt is only available on macOS") + } + workspace, token := protectedTokenFixture(t) + policy := DefaultPolicy() + policy.DenyRead = []string{workspace} + + profile := PermissionProfileFromPolicy(workspace, policy, nil) + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, token) { + t.Fatalf("DenyReadIfExists = %#v, want mandatory token %q despite parent DenyRead", profile.FileSystem.DenyReadIfExists, token) + } + if !stringSliceContains(profile.FileSystem.MandatoryDenyReadPaths, token) { + t.Fatalf("MandatoryDenyReadPaths = %#v, want token %q", profile.FileSystem.MandatoryDenyReadPaths, token) + } + + sbpl := seatbeltProfileFromPermissionProfile(profile, policy, "") + escaped := sandboxProfileString(token) + for _, want := range []string{ + `(deny file-read* (literal "` + escaped + `"))`, + `(deny file-write* (literal "` + escaped + `"))`, + } { + if !strings.Contains(sbpl, want) { + t.Fatalf("Seatbelt profile missing mandatory token rule %q:\n%s", want, sbpl) + } + } +} + +func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filenames cannot end in a space") + } + workspace, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(workspace, "bridge-token ") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local")) + for _, key := range []string{"CLOUDSDK_CONFIG", "NPM_CONFIG_USERCONFIG", "npm_config_userconfig", "GH_CONFIG_DIR", "NETRC", "DOCKER_CONFIG", "KUBECONFIG", "GOOGLE_APPLICATION_CREDENTIALS", "ZERO_OAUTH_TOKENS_PATH", "ZERO_OAUTH_STORAGE", "ZERO_MCP_OAUTH_TOKENS_PATH"} { + t.Setenv(key, "") + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, "") + t.Setenv(daemonRemoteTokenFileIdentityEnv, "") + + profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil) + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, token) { + t.Fatalf("DenyReadIfExists = %#v, want exact token pathname %q", profile.FileSystem.DenyReadIfExists, token) + } + + sbpl := seatbeltProfileFromPermissionProfile(profile, DefaultPolicy(), "") + escaped := sandboxProfileString(token) + for _, want := range []string{ + `(deny file-read* (literal "` + escaped + `"))`, + `(deny file-write* (literal "` + escaped + `"))`, + } { + if !strings.Contains(sbpl, want) { + t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl) + } + } + + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) + assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", token) + +} + +// A mandatory token named by a symlink fails the plan outright: bubblewrap +// cannot bind-mask a mutable symlink destination, so masking the link's current +// target would be detached by a retarget mid-session. +func TestMandatoryLinuxTokenSymlinkFailsPlan(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "token") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "token-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, link) + profile := PermissionProfileFromPolicy(dir, DefaultPolicy(), nil) + + _, err := buildLinuxBwrapFilesystemPlan(profile) + if err == nil { + t.Fatal("buildLinuxBwrapFilesystemPlan succeeded for a symlinked mandatory token, want a fail-closed error") + } + if !strings.Contains(err.Error(), "mandatory credential symlink") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want a mandatory credential symlink refusal", err) + } +} + +// The helper-level refusal above must also hold at the command-plan level, so a +// future change cannot restore the unsafe accepted-symlink behavior while only a +// helper test stays green. +func TestSandboxManagerRefusesLinuxShellForSymlinkedToken(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + workspace := t.TempDir() + dir := t.TempDir() + target := filepath.Join(dir, "token") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "token-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, link) + + policy := DefaultPolicy() + backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"} + _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil { + t.Fatal("BuildCommandPlan succeeded with a symlinked mandatory token, want the shell refused") + } + if !strings.Contains(err.Error(), "mandatory credential symlink") && !strings.Contains(err.Error(), "hard-link aliases") { + t.Fatalf("BuildCommandPlan error = %v, want a fail-closed credential refusal", err) + } +} + +// A hard-link alias is a second directory entry for the token's inode, so the +// /dev/null bind over the configured pathname does not hide it. Plan +// construction must fail closed instead of running behind that mask. +func TestSandboxManagerRejectsLinuxTokenHardLinkAlias(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("hard-link inode probing is exercised on Linux") + } + workspace, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + tokenDir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + token := filepath.Join(tokenDir, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(tokenDir, "token-alias") + if err := os.Link(token, alias); err != nil { + t.Skipf("fixture paths are not hard-linkable: %v", err) + } + + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + + if _, err := pathHardLinkCount(token); err != nil { + t.Fatalf("pathHardLinkCount could not inspect the token fixture: %v", err) + } + if credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot( + PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil), + protectedCredentialPaths(), + ); !linkable || credential != token { + t.Fatalf("linkable = %t credential = %q, want the aliased token %q reported", linkable, credential, token) + } + + policy := DefaultPolicy() + backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"} + _, err = NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "cat " + alias}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil { + t.Fatal("BuildCommandPlan succeeded with a hard-linked token alias, want plan construction to fail closed") + } + if !strings.Contains(err.Error(), "hard-link aliases") { + t.Fatalf("BuildCommandPlan error = %v, want a hard-link alias refusal", err) + } +} + +// A read-only "/" root exposes the namespace, not an alias for the masked token +// inode. A token on a distinct filesystem is therefore the supported Linux +// placement; a writable root on the token filesystem remains unsafe. +func TestSandboxManagerAllowsLinuxTokenOnSeparateFilesystem(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux mount identity is exercised on Linux") + } + workspace := t.TempDir() + tokenDir, err := os.MkdirTemp("/dev/shm", "zero-token-") + if err != nil { + t.Skipf("distinct tmpfs unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tokenDir) }) + if shared, known := pathsShareFilesystem(workspace, tokenDir); shared || !known { + t.Skip("/dev/shm is not a verifiably distinct filesystem on this host") + } + token := filepath.Join(tokenDir, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + + policy := DefaultPolicy() + profile := PermissionProfileFromPolicy(workspace, policy, nil) + if credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentialPaths()); linkable { + t.Fatalf("separate-filesystem token %q reported linkable through %q", token, credential) + } + backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"} + if _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }); err != nil { + t.Fatalf("BuildCommandPlan with separate-filesystem token: %v", err) + } + + sameFilesystemToken := filepath.Join(t.TempDir(), "bridge-token") + if err := os.WriteFile(sameFilesystemToken, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenFileEnv, sameFilesystemToken) + if credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentialPaths()); !linkable || credential != sameFilesystemToken { + t.Fatalf("same-filesystem writable-root linkable = %t credential = %q, want %q", linkable, credential, sameFilesystemToken) + } + if _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }); err == nil || !strings.Contains(err.Error(), "hard-link aliases") { + t.Fatalf("BuildCommandPlan same-filesystem error = %v, want hard-link alias refusal", err) + } +} + +// This admission test deliberately has no writable roots, so linkability does +// not reject the request before the startup-identity check is exercised. +func TestLinuxShellAdmissionRejectsRotatedStartupToken(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux shell admission requires native filesystem identity") + } + workspace := t.TempDir() + dir := t.TempDir() + token := filepath.Join(dir, "bridge-token") + if err := os.WriteFile(token, []byte("startup-secret"), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Open(token) + if err != nil { + t.Fatal(err) + } + identity, ok := remotetoken.IdentityOfFile(file) + file.Close() + if !ok { + t.Fatal("stable file identity unavailable") + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, token) + t.Setenv(daemonRemoteTokenFileIdentityEnv, identity) + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, ReadRoots: []string{string(filepath.Separator)}}, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + manager := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: Backend{Name: BackendLinuxBwrap, Available: true, Platform: "linux", NativeIsolation: true, CommandWrapping: true}}) + request := SandboxManagerRequest{WorkspaceRoot: workspace, Policy: DefaultPolicy(), Profile: profile, Preference: SandboxPreferenceAuto, ValidateExecution: true} + if _, err := manager.BuildExecutionRequest(request); err != nil { + t.Fatalf("unrotated startup token rejected: %v", err) + } + if err := os.Rename(token, token+".old"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(token, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.BuildExecutionRequest(request); err == nil || !strings.Contains(err.Error(), "no longer names the token object") { + t.Fatalf("rotated startup token error = %v, want identity-drift refusal", err) + } + if err := os.Remove(token); err != nil { + t.Fatal(err) + } + if _, err := manager.BuildExecutionRequest(request); err == nil || !strings.Contains(err.Error(), "no longer names the token object") { + t.Fatalf("missing startup token error = %v, want identity-drift refusal", err) + } + if err := os.Rename(token+".old", token); err != nil { + t.Fatal(err) + } + if _, err := manager.BuildExecutionRequest(request); err != nil { + t.Fatalf("restored startup token rejected: %v", err) + } +} + +// macOS intentionally has no file-token placement exception: Seatbelt cannot +// deny every inode alias across token rotation and daemon restart. +func TestSandboxManagerRejectsMacOSFileTokenRegardlessOfLinkLayout(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS file-token shell contract is exercised on darwin") + } + workspace := t.TempDir() + tokenDir := t.TempDir() + token := filepath.Join(tokenDir, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + policy := DefaultPolicy() + backend := Backend{Name: BackendMacOSSeatbelt, Available: true, Executable: "/usr/bin/sandbox-exec", Platform: "darwin", CommandWrapping: true, NativeIsolation: true} + _, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "file-backed remote token") { + t.Fatalf("BuildCommandPlan error = %v, want unconditional macOS file-token shell refusal", err) + } +} + +// Windows has no read-deny rule at all: credentialDenyReadPaths returns nothing +// there, so a wrapped shell would run with the bridge token readable under every +// pathname. The refusal is therefore unconditional, exactly like the macOS one +// above, and does not depend on the host actually being Windows — a Windows plan +// is buildable from any host. +func TestSandboxManagerRejectsWindowsFileBackedTokenShell(t *testing.T) { + workspace := t.TempDir() + tokenDir := t.TempDir() + token := filepath.Join(tokenDir, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, "") + + policy := DefaultPolicy() + backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Platform: "windows", CommandWrapping: true, NativeIsolation: true} + manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}) + _, err := manager.BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "type bridge-token"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "file-backed remote token") { + t.Fatalf("BuildExecutionRequest error = %v, want a Windows file-token shell refusal", err) + } + + // Without a file-backed token the same request must still build: the refusal + // is scoped to the credential, not to Windows. + t.Setenv(daemonRemoteTokenFileEnv, "") + if _, err := manager.BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "echo hi"}, Dir: workspace}, + Policy: policy, + Profile: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }); err != nil { + t.Fatalf("BuildExecutionRequest without a token file = %v, want success", err) + } +} + +// A write root the sandbox has not created yet still resolves to a filesystem: +// it lands on whichever one its parent is on. A root under the workspace is +// therefore a place a hard link to a same-filesystem token could be created, and +// the planner must refuse — while a root whose whole ancestor chain is missing +// (the macOS-only spellings the profile carries on every platform) must NOT be +// read as an unknown that refuses everything. +func TestLinuxTokenPlannerReadsUncreatedWriteRootFromItsParent(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux mount identity is exercised on Linux") + } + workspace, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + tokenDir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + token := filepath.Join(tokenDir, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, "") + + profileWith := func(root string) PermissionProfile { + return PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: root}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + } + + // Both temp dirs are on the same filesystem, so a root that will be created + // under the workspace is a hard-link site for the token. + missing := filepath.Join(workspace, "not-created-yet") + if shared, known := pathsShareFilesystem(missing, token); !known || !shared { + t.Skipf("workspace %q and token %q are not on one filesystem (shared=%t known=%t)", workspace, token, shared, known) + } + if credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot(profileWith(missing), protectedCredentialPaths()); !linkable || credential != token { + t.Fatalf("linkable = %t credential = %q, want %q refused behind a not-yet-created same-filesystem write root", linkable, credential, token) + } + + // /private/... is a macOS spelling with no Linux counterpart, and the profile + // carries it on every platform. Answering it from `/` keeps it a real answer + // instead of an unknown that would refuse every file-backed token on Linux — + // the regression that broke TestSandboxManagerAllowsLinuxTokenOnSeparateFilesystem. + if _, err := os.Stat("/private"); err == nil { + t.Skip("/private exists on this host, so it is not the platform-foreign case") + } + if _, known := pathsShareFilesystem("/private/tmp", token); !known { + t.Fatal(`pathsShareFilesystem("/private/tmp", token) reported unknown, want the answer resolved from an existing ancestor`) + } +} + +// The optional (non-mandatory) credential candidates keep the older behavior: +// never bind over the link itself, and mask the resolved destination when it is +// the path that actually exists. +func TestOptionalLinuxCredentialSymlinkMasksResolvedDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevation on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "token") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "token-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + resolvedTarget, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatalf("EvalSymlinks target: %v", err) + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{dir}, + DenyReadIfExists: []string{link, resolvedTarget}, + }, + } + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) + if stringSliceContains(plan.Args, link) { + t.Fatalf("bubblewrap plan attempted to mount over symlink destination %q: %#v", link, plan.Args) + } + assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", resolvedTarget) +} + +// TestProtectedCredentialsSurviveDisabledPolicy covers the one route that skips +// validatePathWithPolicy entirely: ModeDisabled drops every user-configured +// restriction, but the bridge token authenticates the caller driving these tools. +func TestProtectedCredentialsSurviveDisabledPolicy(t *testing.T) { + ws, token := protectedTokenFixture(t) + engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: Policy{Mode: ModeDisabled}}) + + for _, sideEffect := range []SideEffect{SideEffectRead, SideEffectWrite} { + decision := engine.Evaluate(context.Background(), Request{ + ToolName: "read_file", + WorkspaceRoot: ws, + SideEffect: sideEffect, + Args: map[string]any{"path": token}, + }) + if decision.Action != ActionDeny || !strings.Contains(decision.Reason, "remote bridge token") { + t.Fatalf("%s under a disabled policy: action = %q reason = %q, want a bridge-token deny", sideEffect, decision.Action, decision.Reason) + } + } + + // Everything else stays allowed: a disabled sandbox is still disabled. + decision := engine.Evaluate(context.Background(), Request{ + ToolName: "read_file", + WorkspaceRoot: ws, + SideEffect: SideEffectRead, + Args: map[string]any{"path": filepath.Join(ws, "main.go")}, + }) + if decision.Action != ActionAllow { + t.Fatalf("ordinary read under a disabled policy: action = %q reason = %q, want allow", decision.Action, decision.Reason) + } + + rx := engine.ReadExclusions() + if !rx.Active() || !rx.PathExcluded(token) { + t.Fatalf("read exclusions under a disabled policy must still exclude %q", token) + } + if rx.PathExcluded(filepath.Join(ws, "main.go")) { + t.Fatal("read exclusions under a disabled policy must not exclude ordinary files") + } +} + +// TestDisabledPolicyLeavesShellOutsideTheTokenBoundary pins the boundary jatmn +// asked to see stated for #685: under ModeDisabled the bridge-token exclusion +// covers Zero's in-process file tools and nothing else. No OS wrapper is built +// at all in that mode, so a shell command is confined by nothing and an +// escalation has nothing to bypass. This test exists so that stops being an +// implicit property — changing any of it should mean changing this test on +// purpose, not discovering the behavior later. +func TestDisabledPolicyLeavesShellOutsideTheTokenBoundary(t *testing.T) { + ws, token := protectedTokenFixture(t) + engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: Policy{Mode: ModeDisabled}}) + + shell := engine.Evaluate(context.Background(), Request{ + ToolName: "bash", + WorkspaceRoot: ws, + SideEffect: SideEffectShell, + Args: map[string]any{"command": "cat " + token}, + }) + if shell.Action != ActionAllow { + t.Fatalf("shell under a disabled policy = %q (%s); the token boundary is documented as in-process only", shell.Action, shell.Reason) + } + + // The same command's payload IS blocked when it arrives as a path-carrying + // request, which is the whole of the guarantee. + read := engine.Evaluate(context.Background(), Request{ + ToolName: "read_file", + WorkspaceRoot: ws, + SideEffect: SideEffectRead, + Args: map[string]any{"path": token}, + }) + if read.Action != ActionDeny { + t.Fatalf("in-process read under a disabled policy = %q (%s), want deny", read.Action, read.Reason) + } + + if !engine.UnsandboxedExecutionAllowed() { + t.Fatal("escalation under a disabled policy must stay allowed: there is no wrapper for it to bypass") + } + + // With the sandbox on, the same configured token flips both: the profile is + // built, so escalating out of it would drop a real deny rule. + enforcing := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: DefaultPolicy()}) + if enforcing.UnsandboxedExecutionAllowed() { + t.Fatal("escalation must be refused while a bridge token is protected by an active profile") + } +} + +func TestProtectedCredentialsFollowFilesystemCaseSemantics(t *testing.T) { + policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true} + + for _, existing := range []bool{false, true} { + name := "absent token pathname" + if existing { + name = "existing token file" + } + t.Run(name, func(t *testing.T) { + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + wantDenied := filesystemFoldsCaseForTest(t, ws) + configured := filepath.Join(ws, "bridge-token") + if existing { + if err := os.WriteFile(configured, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, configured) + variant := filepath.Join(ws, strings.ToUpper(filepath.Base(configured))) + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + + for _, sideEffect := range []SideEffect{SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace} { + block := validatePathWithPolicy(scope, policy, sideEffect, true, ws, variant) + denied := block != nil && strings.Contains(block.Reason, "remote bridge token") + if denied != wantDenied { + t.Fatalf("%s on case variant %q: denied = %t, want %t (block = %#v)", sideEffect, variant, denied, wantDenied, block) + } + } + engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: policy, Scope: scope}) + if excluded := engine.ReadExclusions().PathExcluded(variant); excluded != wantDenied { + t.Fatalf("read exclusions on case variant %q: excluded = %t, want %t", variant, excluded, wantDenied) + } + if block := validatePathWithPolicy(scope, policy, SideEffectWrite, true, ws, configured); block == nil || !strings.Contains(block.Reason, "remote bridge token") { + t.Fatalf("the configured token %q must stay unwritable", configured) + } + }) + } +} + +func filesystemFoldsCaseForTest(t *testing.T, dir string) bool { + t.Helper() + probe := filepath.Join(dir, "zero-case-probe") + variant := filepath.Join(dir, "ZERO-CASE-PROBE") + if err := os.WriteFile(probe, []byte("probe"), 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(probe) }) + probeInfo, err := os.Stat(probe) + if err != nil { + t.Fatal(err) + } + variantInfo, err := os.Stat(variant) + if os.IsNotExist(err) { + return false + } + if err != nil { + t.Skipf("cannot determine filesystem case semantics: %v", err) + } + return os.SameFile(probeInfo, variantInfo) +} + +func TestDetectPathCaseSemanticsUsesNearestExistingAncestor(t *testing.T) { + backing := filepath.Join(t.TempDir(), "backing") + if err := os.WriteFile(backing, []byte("probe"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(backing) + if err != nil { + t.Fatal(err) + } + parent := filepath.Join(t.TempDir(), "ProbeDir") + configured := filepath.Join(parent, "missing", "bridge-token") + variantName, ok := caseVariant(filepath.Base(parent)) + if !ok { + t.Fatal("test parent has no case variant") + } + variantParent := filepath.Join(filepath.Dir(parent), variantName) + + statWithVariant := func(variantResult error) func(string) (os.FileInfo, error) { + return func(path string) (os.FileInfo, error) { + switch path { + case parent: + return info, nil + case variantParent: + if variantResult != nil { + return nil, variantResult + } + return info, nil + default: + return nil, os.ErrNotExist + } + } + } + if got := detectPathCaseSemantics(configured, statWithVariant(nil)); got != pathCaseInsensitive { + t.Fatalf("case-insensitive ancestor detection = %v, want %v", got, pathCaseInsensitive) + } + if got := detectPathCaseSemantics(configured, statWithVariant(os.ErrNotExist)); got != pathCaseSensitive { + t.Fatalf("case-sensitive ancestor detection = %v, want %v", got, pathCaseSensitive) + } + if got := detectPathCaseSemantics(configured, statWithVariant(os.ErrPermission)); got != pathCaseUnknown { + t.Fatalf("indeterminate ancestor detection = %v, want %v", got, pathCaseUnknown) + } +} + +// TestProtectedCredentialsDoNotBlockUnrelatedRequests keeps the exclusion inert +// for everyone who does not run the remote bridge. +func TestProtectedCredentialsDoNotBlockUnrelatedRequests(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "") + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + engine := NewEngine(EngineOptions{ + WorkspaceRoot: ws, + Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true}, + }) + if rx := engine.ReadExclusions(); rx.Active() { + t.Fatal("read exclusions must stay inactive without a configured token file") + } + decision := engine.Evaluate(context.Background(), Request{ + ToolName: "read_file", + WorkspaceRoot: ws, + SideEffect: SideEffectRead, + Args: map[string]any{"path": filepath.Join(ws, "main.go")}, + }) + if decision.Action == ActionDeny { + t.Fatalf("ordinary workspace read was denied: %q", decision.Reason) + } +} + +func TestProtectedCredentialIdentitySurvivesConfiguredFileReplacement(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "token") + alias := filepath.Join(dir, "old-token-alias") + if err := os.WriteFile(token, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + startup, err := os.Open(token) + if err != nil { + t.Fatal(err) + } + identity, ok := remotetoken.IdentityOfFile(startup) + startup.Close() + if !ok { + t.Fatal("stable file identity unavailable") + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, token) + t.Setenv(daemonRemoteTokenFileIdentityEnv, identity) + replacement := filepath.Join(dir, "replacement") + if err := os.WriteFile(replacement, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, token); err != nil { + t.Fatal(err) + } + + rx := ProtectedCredentialExclusions(dir) + aliasFile, err := os.Open(alias) + if err != nil { + t.Fatal(err) + } + aliasInfo, err := aliasFile.Stat() + if err != nil { + aliasFile.Close() + t.Fatal(err) + } + if !rx.FileHandleExcluded(alias, aliasFile, aliasInfo) { + aliasFile.Close() + t.Fatal("hard-link alias of startup token became readable after configured path replacement") + } + aliasFile.Close() + if !rx.PathExcluded(token) { + t.Fatal("configured token name was not reserved") + } +} diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index faa4e09bc..03395ecb5 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -236,32 +236,61 @@ func firstArgString(args map[string]any, keys ...string) string { return "" } +func firstExactStringArg(args map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := args[key].(string); ok && value != "" { + return value + } + } + return "" +} + +// pathArgKeys is the alias list the sandbox inspects for path-carrying tool +// arguments. Keep it aligned with the alias lists the tools themselves accept +// (see aliasedStringArg in write_file/edit_file/read_file/grep/glob/list): the +// sandbox gates by arg-key name, so any alias a tool resolves but the sandbox +// does not inspect would let a model route a write/read around the +// workspace+symlink boundary. +var pathArgKeys = []string{"path", "file", "file_path", "filepath", "filename", "cwd", "workdir", "dir", "directory"} + func requestPaths(request Request) []string { paths := []string{} - // Keep this aligned with the path-arg alias lists the tools accept (see - // aliasedStringArg in write_file/edit_file/read_file/grep/glob/list). The - // sandbox gates by arg-key name, so any alias a tool resolves but the sandbox - // does not inspect would let a model route a write/read around the - // workspace+symlink boundary. - for _, key := range []string{"path", "file", "file_path", "filepath", "filename", "cwd", "workdir", "dir", "directory"} { - if value := argString(request.Args, key); value != "" { - paths = append(paths, value) + for _, key := range pathArgKeys { + // Gate on the EXACT bytes, because that is what the tool will open: + // aliasedStringArg does not trim, so a trimming gate inspects a + // different pathname than the one that gets read. A credential file + // whose name carries meaningful whitespace ("bridge-token " on Unix) + // was protected under its real spelling while the gate checked the + // trimmed name and allowed the read. + // + // The trimmed spelling is still emitted when it differs, so the gate + // never inspects LESS than it did before this became exact — a + // whitespace-padded argument is now checked both ways rather than only + // the way the tool does not use. + value, ok := request.Args[key].(string) + if !ok || value == "" { + continue + } + paths = append(paths, value) + if trimmed := strings.TrimSpace(value); trimmed != "" && trimmed != value { + paths = append(paths, trimmed) } } if request.ToolName == "apply_patch" { - paths = append(paths, applyPatchRequestPaths(request.Args)...) + paths = append(paths, applyPatchRequestPaths(request)...) } return paths } -func applyPatchRequestPaths(args map[string]any) []string { - patch := firstArgString(args, "patch", "diff") - if patch == "" { +func applyPatchRequestPaths(request Request) []string { + if request.PatchPaths == nil { return nil } - cwd := firstArgString(args, "cwd") + // apply_patch consumes cwd as exact pathname data. Trimming here would make + // the gate derive targets under a different directory than the tool applies them. + cwd := firstExactStringArg(request.Args, "cwd") var paths []string - for _, path := range applyPatchPaths(patch) { + for _, path := range request.PatchPaths { if path == "" || path == "/dev/null" { continue } @@ -277,15 +306,21 @@ func applyPatchPathBlock(request Request) *pathBlock { if request.ToolName != "apply_patch" { return nil } - patch := firstArgString(request.Args, "patch", "diff") + patch := firstExactStringArg(request.Args, "patch", "diff") if patch == "" { return nil } + if request.PatchPaths == nil { + return &pathBlock{ + Code: BlockDenied, + Reason: "patch paths were not supplied by the apply_patch executor", + } + } // Only relative traversal is rejected up front. Absolute paths flow through // the regular workspace-scope validation below (requestPaths), which accepts // one inside the workspace and denies one outside — a model that echoes the // absolute path read_file showed it must not be blocked for that alone. - for _, path := range applyPatchPaths(patch) { + for _, path := range request.PatchPaths { if path == "" || path == "/dev/null" { continue } @@ -327,105 +362,213 @@ func IsStructuredPatch(patch string) bool { return StructuredPatchMarker(first) == "begin" } -func applyPatchPaths(patch string) []string { - if IsStructuredPatch(patch) { - return structuredPatchHeaderPaths(patch) +func parseDiffGitPaths(line string) (string, string, bool) { + if line == "" { + return "", "", false + } + if strings.HasPrefix(line, "\"") { + source, rest, ok := consumeGitPath(line) + if !ok || len(rest) < 2 || rest[0] != ' ' { + return "", "", false + } + destination, ok := parseWholeGitPath(rest[1:]) + return source, destination, ok && validDiffGitPaths(source, destination) } - return patchHeaderPaths(patch) -} -// structuredPatchHeaderPaths is intentionally a small, conservative scanner for -// the sandbox boundary. The executor validates the complete hunk grammar before -// writing; the sandbox only needs every possible target path so it can reject an -// unsafe request before the tool runs. -func structuredPatchHeaderPaths(patch string) []string { - var paths []string - for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") { - trimmed := strings.TrimSpace(line) - for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} { - if path, ok := strings.CutPrefix(trimmed, prefix); ok { - path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/") - if path != "" { - paths = append(paths, path) - } - break + // A quoted destination uniquely separates the operands even when the source + // contains ordinary spaces. + for separator := strings.Index(line, " \""); separator >= 0; { + if destination, ok := parseWholeGitPath(line[separator+1:]); ok { + source := line[:separator] + if validDiffGitPaths(source, destination) { + return source, destination, true } } + next := strings.Index(line[separator+1:], " \"") + if next < 0 { + break + } + separator += next + 1 } - return paths -} -func patchHeaderPaths(patch string) []string { - var paths []string - oldRemaining, newRemaining := 0, 0 - inHunk := false - for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") { - if inHunk && (oldRemaining > 0 || newRemaining > 0) { - switch { - case strings.HasPrefix(line, "-"): - oldRemaining-- - case strings.HasPrefix(line, "+"): - newRemaining-- - case strings.HasPrefix(line, "\\"): - default: - oldRemaining-- - newRemaining-- - } + type candidate struct{ source, destination string } + var candidates []candidate + for separator := range len(line) { + if line[separator] != ' ' { continue } - inHunk = false - switch { - case strings.HasPrefix(line, "diff --git "): - fields := strings.Fields(line) - if len(fields) >= 4 { - paths = append(paths, stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3])) - } - case strings.HasPrefix(line, "@@"): - oldRemaining, newRemaining = parsePatchHunkCounts(line) - inHunk = oldRemaining > 0 || newRemaining > 0 - case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "): - fields := strings.Fields(line) - if len(fields) >= 2 { - paths = append(paths, stripPatchPrefix(fields[1])) - } + source, destination := line[:separator], line[separator+1:] + if validDiffGitPaths(source, destination) { + candidates = append(candidates, candidate{source: source, destination: destination}) } } - return paths + var prefixed []candidate + for _, candidate := range candidates { + if hasDefaultGitPrefixes(candidate.source, candidate.destination) { + prefixed = append(prefixed, candidate) + } + } + if len(prefixed) == 1 { + return prefixed[0].source, prefixed[0].destination, true + } + var same []candidate + for _, candidate := range candidates { + if candidate.source == candidate.destination { + same = append(same, candidate) + } + } + if len(same) == 1 { + return same[0].source, same[0].destination, true + } + if len(candidates) == 1 { + return candidates[0].source, candidates[0].destination, true + } + return "", "", false } -func parsePatchHunkCounts(line string) (int, int) { - _, rest, ok := strings.Cut(line, "@@") - if !ok { - return 0, 0 +func parseWholeGitPath(input string) (string, bool) { + if input == "" { + return "", false + } + if input[0] != '"' { + return input, true } - rangeSection := rest - if before, _, ok := strings.Cut(rest, "@@"); ok { - rangeSection = before + path, rest, ok := consumeGitPath(input) + return path, ok && rest == "" +} + +func validDiffGitPaths(source, destination string) bool { + return source != "" && destination != "" +} + +func hasDefaultGitPrefixes(source, destination string) bool { + return len(source) > 2 && len(destination) > 2 && + strings.HasPrefix(source, "a/") && strings.HasPrefix(destination, "b/") +} + +func normalizeDiffGitPaths(source, destination string) (string, string) { + if hasDefaultGitPrefixes(source, destination) { + return source[2:], destination[2:] + } + return source, destination +} + +// Extended copy/rename headers consume the whole unquoted remainder as the +// pathname, including leading spaces. For a C-quoted name Git consumes the +// first complete quoted string, so trailing header text is not pathname data. +func parseExtendedGitPath(input string) (string, bool) { + if input == "" { + return "", false + } + if input[0] != '"' { + return input, true + } + path, rest, ok := consumeGitPath(input) + return path, ok && rest == "" +} + +// consumeGitPath reads one diff --git path operand. Git uses C-style quoted +// strings for paths containing characters that need escaping; strconv.Unquote +// handles its escaped quotes, backslashes, control characters, and octal bytes. +func consumeGitPath(input string) (string, string, bool) { + if input == "" { + return "", "", false + } + if input[0] != '"' { + end := strings.IndexAny(input, " \t") + if end < 0 { + return input, "", true + } + return input[:end], input[end:], true } - old, next := 0, 0 - for _, field := range strings.Fields(rangeSection) { + + escaped := false + for i := 1; i < len(input); i++ { switch { - case strings.HasPrefix(field, "-"): - old = patchHunkCount(field[1:]) - case strings.HasPrefix(field, "+"): - next = patchHunkCount(field[1:]) + case escaped: + escaped = false + case input[i] == '\\': + escaped = true + case input[i] == '"': + path, err := strconv.Unquote(input[:i+1]) + if err != nil { + return "", "", false + } + return path, input[i+1:], true } } - return old, next + return "", "", false } -func patchHunkCount(spec string) int { - if _, count, ok := strings.Cut(spec, ","); ok { - if n, err := strconv.Atoi(count); err == nil { - return n +// patchFileHeaderPath mirrors the apply_patch parser's handling of unified-diff +// file headers: paths may be C-quoted and may contain spaces, while an optional +// timestamp is separated by a tab. +// +// Only the tab is header formatting. Git's own parser takes every remaining byte +// of an unquoted operand as pathname data, so a name with a leading or trailing +// space is written verbatim; trimming here would make the token gate evaluate +// `bridge-token` while git patches the live `bridge-token ` beside it. +func patchFileHeaderPath(line string) (string, bool) { + if len(line) < len("--- ") { + return "", false + } + rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes + if tab := strings.IndexByte(rest, '\t'); tab >= 0 { + rest = rest[:tab] + } + if strings.HasPrefix(rest, `"`) { + path, remainder, ok := consumeGitPath(rest) + if !ok || remainder != "" { + return "", false } - return 0 + return path, true } - return 1 + return rest, rest != "" +} + +// The apply_patch executor uses the exported parsers below for every pathname +// operand. They carry one contract: +// every byte of a header's pathname operand is pathname data. Only structural +// formatting is removed — the fixed header prefix, a tab-separated timestamp, a +// C-quoted operand's quoting, and matching a/ b/ prefixes. Nothing is trimmed, +// case-folded, or shell-split. A consumer that needs a different spelling must +// derive it from these bytes rather than re-reading the header. + +// PatchFileHeaderPath returns the pathname named by a unified-diff "--- " or +// "+++ " file header line, reporting false when the header is not a form this +// parser can interpret exactly. An empty pathname is returned as ("", true): +// the header carries no target, which is not the same as an unreadable one. +func PatchFileHeaderPath(line string) (string, bool) { + return patchFileHeaderPath(line) +} + +// ExtendedGitHeaderPath returns the pathname named by a git extended header — +// "rename from ", "rename to ", "copy from ", "copy to " — given the operand +// that follows the header's fixed prefix. +func ExtendedGitHeaderPath(operand string) (string, bool) { + return parseExtendedGitPath(operand) +} + +// DiffGitPaths returns the source and destination named by the operand section +// of a "diff --git " line (the text after the prefix), with a matching a/ and +// b/ prefix pair removed. It reports false for operands whose split between the +// two pathnames is ambiguous. +func DiffGitPaths(operands string) (string, string, bool) { + source, destination, ok := parseDiffGitPaths(operands) + if !ok { + return "", "", false + } + source, destination = normalizeDiffGitPaths(source, destination) + return source, destination, true +} + +// StripPatchPrefix removes a single leading a/ or b/ from a unified-diff path +// and normalizes separators to "/", preserving every other byte. +func StripPatchPrefix(path string) string { + return stripPatchPrefix(path) } func stripPatchPrefix(path string) string { - path = strings.TrimSpace(path) if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { path = path[2:] } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..4eb17e1f1 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "sync/atomic" @@ -678,6 +679,7 @@ func seatbeltProfileFromPermissionProfile(profile PermissionProfile, policy Poli // Seatbelt's last-match-wins evaluation without hiding ordinary extension // files. rules = append(rules, denyReadRulesInsideCarveouts(profile.FileSystem)...) + rules = append(rules, credentialDenyWriteRules(profile.FileSystem, policy)...) rules = append(rules, writeRootCarveoutDenyRules(profile.FileSystem)...) rules = append(rules, denyWriteRulesFromPaths(profile.FileSystem.DenyWrite)...) rules = append(rules, networkRule) @@ -804,6 +806,13 @@ func seatbeltProtectedMetadataRegex(root string, name string) string { return "^" + escapedRoot + "/" + escapedName + "(/.*)?$" } +// denyReadRules emits seatbelt deny rules for the profile's read-denied paths. +// The rules name paths — `(literal …)` for files, `(subpath …)` for directories +// — because that is the whole of seatbelt's vocabulary here. A user-configured +// DenyRead can therefore still be bypassed through a hard-link name created +// after profile construction. Automatic remote-token protection fails closed +// before launch whenever the token can be linked into a shell-writable root; +// in-process tools close inode aliases separately (see protectedPathDenied). func denyReadRules(fs FileSystemPolicy) []string { denied := dedupeStrings(append(append([]string{}, fs.DenyRead...), fs.DenyReadIfExists...)) rules := denySeatbeltPathRules("file-read*", denied) @@ -899,8 +908,44 @@ func denyWriteRulesFromPaths(paths []string) []string { return denySeatbeltPathRules("file-write*", paths) } +// credentialDenyWriteRules write-denies the AUTOMATIC credential entries of +// DenyRead — the cloud credential stores and the remote bridge token file. +// Denying reads does not imply denying writes, and the broad +// (allow file-write* ...) above covers every workspace root plus the default +// temp roots, so a credential file under one of those stayed truncatable and +// replaceable: enough to deny service, or to swap the secret the next process +// reads. denySeatbeltPathRules keeps emitting read + unlink denials for the +// whole DenyRead list, so a user-configured read-denied path that their build +// legitimately writes (a cache or generated directory) stays writable — only +// Zero's own credential entries lose the write direction. +func credentialDenyWriteRules(fs FileSystemPolicy, _ Policy) []string { + automatic := dedupeStrings(append(append([]string{}, fs.DenyReadIfExists...), protectedCredentialPaths()...)) + if len(automatic) == 0 { + return nil + } + denied := dedupeStrings(append(append([]string{}, fs.DenyRead...), fs.DenyReadIfExists...)) + paths := make([]string, 0, len(automatic)) + for _, path := range automatic { + // Only paths the profile actually read-denies: credentialDenyReadPaths + // already drops AllowRead opt-outs, and this keeps the two lists in step. + if slices.Contains(denied, path) { + paths = append(paths, path) + } + } + return denyWriteRulesFromPaths(dedupeStrings(paths)) +} + func denySeatbeltPathRules(action string, paths []string) []string { - return denySeatbeltNormalizedPathRules(action, normalizeProfilePaths(paths)) + protected := protectedCredentialPaths() + resolved := make([]string, 0, len(paths)) + for _, path := range paths { + if slices.Contains(protected, path) { + resolved = append(resolved, path) + } else if normalized := normalizeProfilePath(path); normalized != "" { + resolved = append(resolved, normalized) + } + } + return denySeatbeltNormalizedPathRules(action, resolved) } func denySeatbeltNormalizedPathRules(action string, paths []string) []string { @@ -1045,10 +1090,10 @@ func seatbeltPlatformRuntimeRules() string { ` (literal "/dev/zero"))`, `(allow file-read-data file-test-existence file-write-data (subpath "/dev/fd"))`, `(allow file-read* file-test-existence file-write-data file-ioctl (literal "/dev/dtracehelper"))`, - `(allow file-read* file-test-existence file-write* (subpath "/tmp"))`, - `(allow file-read* file-write* (subpath "/private/tmp"))`, - `(allow file-read* file-write* (subpath "/var/tmp"))`, - `(allow file-read* file-write* (subpath "/private/var/tmp"))`, + `(allow file-read* file-test-existence (subpath "/tmp"))`, + `(allow file-read* (subpath "/private/tmp"))`, + `(allow file-read* (subpath "/var/tmp"))`, + `(allow file-read* (subpath "/private/var/tmp"))`, `(allow file-read* file-test-existence`, ` (literal "/System/Library/CoreServices")`, ` (literal "/System/Library/CoreServices/.SystemVersionPlatform.plist")`, @@ -1106,14 +1151,12 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string { "GITLAB_TOKEN", "GH_TOKEN", "ZERO_WEBSEARCH_API_KEY", - "ZERO_DAEMON_REMOTE_TOKEN", - // The file form of the same bridge token. TokenFromEnv accepts either, - // so scrubbing only the inline variable left the pointer readable, and - // the default sandbox posture is read-all. There is no fallback - // location to guess at, the path comes from this variable alone, so - // removing it closes the leak rather than half of it. Same reasoning as - // GOOGLE_APPLICATION_CREDENTIALS below. - "ZERO_DAEMON_REMOTE_TOKEN_FILE", + daemonRemoteTokenEnv, + // Both identities of the file-backed bridge token are internal + // authority pointers. Neither belongs in an agent-controlled child. + daemonRemoteTokenFileEnv, + daemonRemoteTokenFileResolvedEnv, + daemonRemoteTokenFileIdentityEnv, } for _, descriptor := range providercatalog.All() { for _, key := range descriptor.AuthEnvVars { @@ -1153,6 +1196,14 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string { return out } +// ScrubSensitiveEnv returns a copy of env with the same credential variables +// removed as an agent-controlled sandbox command. It is for the few external +// processes (such as format-on-write) which are launched outside Engine but +// must share its environment boundary. +func ScrubSensitiveEnv(env []string, additionalKeys ...string) []string { + return scrubSensitiveEnv(env, additionalKeys...) +} + func normalizeSensitiveEnvKeys(keys []string) []string { out := make([]string, 0, len(keys)) seen := make(map[string]struct{}, len(keys)) diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index e707e2397..118317547 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -499,6 +499,14 @@ func TestSeatbeltProfileProtectsMetadataAndDenyOrdering(t *testing.T) { t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl) } } + // A user-configured read-denied path keeps the write direction: a cache or + // generated directory the build legitimately writes must not become read-only + // just because it is excluded from reads. Only Zero's own automatic credential + // entries are write-denied (see TestProtectedCredentialsDenyReadAndWrite...). + if strings.Contains(sbpl, `(deny file-write* (subpath "`+normalizedSecretRead+`"))`) || + strings.Contains(sbpl, `(deny file-write* (literal "`+normalizedSecretRead+`"))`) { + t.Fatalf("a user-configured DenyRead path must stay writable:\n%s", sbpl) + } allowIdx := strings.Index(sbpl, "(allow file-write*") denyReadIdx := strings.Index(sbpl, denySecretReadRule) metadataIdx := strings.Index(sbpl, `(deny file-write* (regex #"^/repo/\.git(/.*)?$"))`) @@ -630,6 +638,21 @@ func TestSeatbeltProfileDoesNotRenderSymlinkCarveout(t *testing.T) { } } +func TestSeatbeltTemporaryWritesFollowPermissionProfile(t *testing.T) { + runtimeRules := seatbeltPlatformRuntimeRules() + if strings.Contains(runtimeRules, `file-write* (subpath "/tmp")`) { + t.Fatal("platform runtime rules must not bypass FileSystemPolicy.AllowTemp") + } + withoutTemp := seatbeltWriteRule(FileSystemPolicy{Kind: FileSystemRestricted}) + if strings.Contains(withoutTemp, `(subpath "/tmp")`) { + t.Fatal("restricted profile with AllowTemp=false unexpectedly permits /tmp writes") + } + withTemp := seatbeltWriteRule(FileSystemPolicy{Kind: FileSystemRestricted, AllowTemp: true}) + if !strings.Contains(withTemp, `(subpath "/tmp")`) { + t.Fatal("restricted profile with AllowTemp=true must permit /tmp writes") + } +} + // TestSeatbeltProfileAllowsGitWritesExceptHooksAndConfig locks in the fix for // git subprocesses (fetch, commit, add, ...) failing under the sandbox: the // default profile must stop write-denying the whole .git tree and only carve @@ -849,10 +872,13 @@ func TestScrubSensitiveEnv(t *testing.T) { // path to read it from (#677). "ZERO_DAEMON_REMOTE_TOKEN=bridge-token-inline", "ZERO_DAEMON_REMOTE_TOKEN_FILE=/home/user/.zero/remote-token", + "ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED=/home/user/.zero/resolved-remote-token", + "ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY=unix:1:2", "COMPANY_LLM_SECRET=custom-secret", "ZERO_OAUTH_MY_SVC_CLIENT_SECRET=oauth-secret", "zero_oauth_second_client_secret=case-insensitive-secret", "ZERO_OAUTH_CLIENT_SECRET=not-a-provider-secret", + "ZERO_DAEMON_REMOTE_TOKEN_FILE=/home/user/daemon-token", "AWS_PROFILE=staging", "SAFE_VAR=hello", } diff --git a/internal/sandbox/types.go b/internal/sandbox/types.go index 897254546..c6c584cbd 100644 --- a/internal/sandbox/types.go +++ b/internal/sandbox/types.go @@ -202,7 +202,13 @@ type Request struct { PermissionGranted bool `json:"permissionGranted,omitempty"` PermissionMode PermissionMode `json:"permissionMode"` Args map[string]any `json:"args,omitempty"` - Reason string `json:"reason,omitempty"` + // PatchPaths is the authoritative set of paths produced by the built-in + // apply_patch executor's parser. A non-nil slice means the patch was parsed, + // including when it contains no operations. Keeping parsed targets on the + // request prevents the sandbox from interpreting model-controlled headers a + // second, potentially different way. + PatchPaths []string `json:"patchPaths,omitempty"` + Reason string `json:"reason,omitempty"` } type Decision struct { diff --git a/internal/sessions/checkpoint.go b/internal/sessions/checkpoint.go index 1b36b83a4..6e50e50fe 100644 --- a/internal/sessions/checkpoint.go +++ b/internal/sessions/checkpoint.go @@ -5,10 +5,13 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" "strconv" + + "github.com/Gitlawb/zero/internal/sandbox" ) // CheckpointsDir is the per-session subdirectory holding content-addressed blobs. @@ -107,7 +110,13 @@ func (store *Store) SnapshotForCheckpoint(sessionID, workspaceRoot, tool string, if !CheckpointsEnabled() || len(paths) == 0 { return CheckpointPayload{}, false } + root, err := os.OpenRoot(workspaceRoot) + if err != nil { + return CheckpointPayload{}, false + } + defer root.Close() capBytes := int64(maxCheckpointBytes()) + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) files := make([]CheckpointFile, 0, len(paths)) for _, rel := range paths { entry := CheckpointFile{Path: rel} @@ -121,9 +130,9 @@ func (store *Store) SnapshotForCheckpoint(sessionID, workspaceRoot, tool string, files = append(files, entry) continue } - info, statErr := os.Stat(abs) - if statErr != nil { - if os.IsNotExist(statErr) { + file, openErr := root.Open(rel) + if openErr != nil { + if os.IsNotExist(openErr) { // Genuinely new file — restore deletes it. entry.Absent = true } else { @@ -134,12 +143,21 @@ func (store *Store) SnapshotForCheckpoint(sessionID, workspaceRoot, tool string, files = append(files, entry) continue } + info, statErr := file.Stat() + if statErr != nil || exclusions.FileHandleExcluded(abs, file, info) { + file.Close() + entry.Skipped = true + files = append(files, entry) + continue + } if info.IsDir() { + file.Close() continue } // Compare sizes as int64 so a file larger than the cap is never silently // truncated past the cap via an int overflow on a 32-bit platform. if info.Size() > capBytes { + file.Close() entry.Skipped = true if info.Size() <= int64(^uint(0)>>1) { entry.Bytes = int(info.Size()) @@ -150,8 +168,9 @@ func (store *Store) SnapshotForCheckpoint(sessionID, workspaceRoot, tool string, // Record the prior permission bits so a restore can put them back (an // executable script must not return as 0o644). entry.Mode = uint32(info.Mode().Perm()) - content, readErr := os.ReadFile(abs) - if readErr != nil { + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { entry.Skipped = true files = append(files, entry) continue diff --git a/internal/sessions/checkpoint_test.go b/internal/sessions/checkpoint_test.go index 78a1674df..eacf0aa40 100644 --- a/internal/sessions/checkpoint_test.go +++ b/internal/sessions/checkpoint_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" ) func newCkStore(t *testing.T) (*Store, string) { @@ -46,6 +48,59 @@ func TestCaptureToolCheckpointWritesBlobAndEvent(t *testing.T) { } } +func TestCheckpointCallbacksDoNotStoreProtectedCredentialAliases(t *testing.T) { + store, ws := newCkStore(t) + token := filepath.Join(ws, "bridge-token") + alias := filepath.Join(ws, "ordinary.txt") + if err := os.WriteFile(token, []byte("checkpoint-secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + file, err := os.Open(token) + if err != nil { + t.Fatal(err) + } + identity, ok := remotetoken.IdentityOfFile(file) + file.Close() + if !ok { + t.Fatal("stable file identity unavailable") + } + t.Setenv(remotetoken.EnvToken, "") + t.Setenv(remotetoken.EnvTokenFile, token) + t.Setenv(remotetoken.EnvTokenFileResolved, token) + t.Setenv(remotetoken.EnvTokenFileIdentity, identity) + // Rotate after target discovery. The alias still names the live startup + // credential, but no longer shares an inode with the configured pathname. + if err := os.Rename(token, token+".old"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(token, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + + event, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"ordinary.txt"}) + if err != nil { + t.Fatal(err) + } + captured := decodeCk(t, event) + if len(captured.Files) != 1 || !captured.Files[0].Skipped || captured.Files[0].Blob != "" { + t.Fatalf("exec checkpoint captured protected alias: %+v", captured.Files) + } + snapshot, ok := store.SnapshotForCheckpoint("s", ws, "edit_file", []string{"ordinary.txt"}) + if !ok || len(snapshot.Files) != 1 || !snapshot.Files[0].Skipped || snapshot.Files[0].Blob != "" { + t.Fatalf("TUI checkpoint captured protected alias: ok=%v files=%+v", ok, snapshot.Files) + } + entries, err := os.ReadDir(store.blobsDir("s")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("protected credential became visible in checkpoint blobs: %v", entries) + } +} + func TestCaptureDedupsIdenticalContent(t *testing.T) { store, ws := newCkStore(t) mustWriteFile(t, filepath.Join(ws, "a.txt"), "same") diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index 293de27dc..60f0828e9 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "path/filepath" - "strconv" "strings" "github.com/Gitlawb/zero/internal/sandbox" @@ -16,6 +15,51 @@ type applyPatchTool struct { scope PathScope } +type applyPatchPreparation struct { + patch string + operations []structuredPatchOperation + paths []string +} + +func prepareApplyPatchArguments(args map[string]any) (*applyPatchPreparation, error) { + patch, err := aliasedStringArg(args, []string{"patch", "diff"}, "", true, false) + if err != nil { + return nil, fmt.Errorf("invalid arguments for apply_patch: %w", err) + } + var operations []structuredPatchOperation + if isStructuredPatch(patch) { + operations, err = parseStructuredPatch(patch) + } else { + operations, err = parseUnifiedPatch(patch) + } + if err != nil { + return nil, err + } + return &applyPatchPreparation{ + patch: patch, + operations: operations, + paths: structuredPatchOperationPaths(operations), + }, nil +} + +func preparedPatchPaths(prepared *applyPatchPreparation) []string { + if prepared == nil { + return nil + } + return prepared.paths +} + +// ApplyPatchPaths derives preflight paths with the executor's parser. Invalid +// input returns nil so sandbox preflight fails closed; execution reports the +// detailed preparation error. Paths remain relative to the patch's cwd. +func ApplyPatchPaths(args map[string]any) []string { + prepared, err := prepareApplyPatchArguments(args) + if err != nil { + return nil + } + return preparedPatchPaths(prepared) +} + func (applyPatchTool) isBuiltInApplyPatch() {} // PrepareFreeformApplyPatchArguments converts native structured-patch input @@ -151,45 +195,17 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a if err != nil { return errorResult("Error applying patch: " + err.Error()) } - if isStructuredPatch(patch) { - return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options) + prepared := options.preparedApplyPatch + if prepared == nil || prepared.patch != patch { + prepared, err = prepareApplyPatchArguments(args) + if err != nil { + return errorResult("Error applying patch: " + err.Error()) + } } - // Unified diffs are translated into the same operations and applied by - // the same os.Root engine, so neither format opens a target by pathname - // after validation (no check-to-use window) and git is not needed. - operations, err := parseUnifiedPatch(patch) - if err != nil { + if err := validatePatchPaths(applyRoot, prepared.paths); err != nil { return errorResult("Error applying patch: " + err.Error()) } - return applyPatchOperations(applyRoot, relativeRoot, operations, options) -} - -// changedFilesFromPatch extracts the unique, WORKSPACE-relative paths a patch -// touches, reusing the same per-line parser used for validation. Patch paths are -// relative to the apply cwd, so relativeRoot (the workspace-relative cwd, e.g. -// "sub/dir", or "." for the workspace root) is prefixed so callers get true -// workspace-relative paths regardless of cwd. When the apply cwd resolves to an -// extra write root, resolveScopedPath returns the absolute path as relativeRoot; -// in that case the entries in the returned slice are absolute paths, since -// workspace-relative would be ambiguous there. -func changedFilesFromPatch(relativeRoot string, patch string) []string { - seen := map[string]bool{} - var paths []string - for _, path := range patchHeaderPaths(patch) { - if path == "" || path == "/dev/null" { - continue - } - workspacePath := path - if relativeRoot != "" && relativeRoot != "." { - workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path)) - } - if seen[workspacePath] { - continue - } - seen[workspacePath] = true - paths = append(paths, workspacePath) - } - return paths + return applyPatchOperations(applyRoot, relativeRoot, prepared.operations, options) } // normalizePatchPathForRoot resolves platform-level symlinks (macOS /var -> @@ -210,8 +226,8 @@ func normalizePatchPathForRoot(root string, path string) string { return sandbox.NormalizePrefixForRoot(path, resolvedRoot) } -func validatePatchPaths(root string, patch string) error { - for _, path := range patchHeaderPaths(patch) { +func validatePatchPaths(root string, patchPaths []string) error { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -220,128 +236,32 @@ func validatePatchPaths(root string, patch string) error { if path == ".." || strings.HasPrefix(path, "../") { return fmt.Errorf("patch path %q must stay inside the workspace", path) } - if _, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)); err != nil { + absolute, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)) + if err != nil { return err } - } - return nil -} - -// patchHeaderPaths returns the file paths declared in a unified diff's headers -// (`diff --git` and `---`/`+++` lines). It tracks hunk state by counting body -// lines from each `@@ -a,b +c,d @@` header, so a removed/added content line that -// merely begins with "--- "/"+++ " (e.g. the removal of a markdown line "-- x") -// is NOT mistaken for a file header. This mirrors how `git apply` parses hunks, -// so a line this skips is content git won't write to either — no security gap. -func patchHeaderPaths(patch string) []string { - var paths []string - oldRemaining, newRemaining := 0, 0 - inHunk := false - for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") { - if inHunk && (oldRemaining > 0 || newRemaining > 0) { - switch { - case strings.HasPrefix(line, "-"): - oldRemaining-- - case strings.HasPrefix(line, "+"): - newRemaining-- - case strings.HasPrefix(line, "\\"): - // "\ No newline at end of file" — not a content line. - default: // context line (" ...") or a blank context line - oldRemaining-- - newRemaining-- - } - continue - } - inHunk = false - switch { - case strings.HasPrefix(line, "diff --git "): - fields := strings.Fields(line) - if len(fields) >= 4 { - paths = append(paths, stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3])) - } - case strings.HasPrefix(line, "@@"): - oldRemaining, newRemaining = parseHunkCounts(line) - inHunk = oldRemaining > 0 || newRemaining > 0 - case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "): - if p := patchFileHeaderPath(line); p != "" && p != "/dev/null" { - paths = append(paths, stripPatchPrefix(p)) - } - } - } - return paths -} - -func patchFileHeaderPath(line string) string { - if len(line) < len("--- ") { - return "" - } - rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes - if tab := strings.IndexByte(rest, '\t'); tab >= 0 { - rest = rest[:tab] - } - return strings.TrimSpace(unquoteGitPath(rest)) -} - -// parseHunkCounts reads the old/new line counts from a "@@ -a,b +c,d @@" header. -// A missing count (e.g. "@@ -a +c @@") means 1 per unified-diff convention. -// -// Only the range section BETWEEN the opening and closing "@@" is parsed. A hunk -// header may carry a free-form section heading after the closing "@@" (e.g. -// "@@ -1,1 +1,1 @@ func foo()"), and that text can itself contain "+"/"-" -// tokens. Scanning the whole line would let a crafted heading like -// "@@ -1,1 +1,1 @@ +1,999999" overwrite the real count, keep the parser stuck in -// hunk mode, and swallow later "--- "/"+++ " file headers so they escape -// validatePatchPaths — a workspace-confinement bypass. -func parseHunkCounts(line string) (int, int) { - _, rest, ok := strings.Cut(line, "@@") - if !ok { - return 0, 0 - } - rangeSection := rest - if before, _, ok := strings.Cut(rest, "@@"); ok { - rangeSection = before // drop the section heading after the closing "@@" - } - old, next := 0, 0 - for _, field := range strings.Fields(rangeSection) { - switch { - case strings.HasPrefix(field, "-"): - old = hunkCount(field[1:]) - case strings.HasPrefix(field, "+"): - next = hunkCount(field[1:]) - } - } - return old, next -} - -func hunkCount(spec string) int { - if _, count, ok := strings.Cut(spec, ","); ok { - if n, err := strconv.Atoi(count); err == nil { - return n + // The apply path refuses a protected credential per target inside + // planStructuredPatch; repeat the lexical refusal here so a permission + // preview never advertises the token as a writable target either. + if err := protectedMutationDenied(absolute, root); err != nil { + return err } - return 0 } - return 1 + return nil } -// unquoteGitPath undoes git's C-style quoting of a diff path. Git wraps a path in -// double quotes and backslash-escapes special bytes (spaces, tabs, high bytes as -// octal) when it contains anything unusual; an unquoted path is returned as-is. -func unquoteGitPath(s string) string { - s = strings.TrimSpace(s) - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - if unquoted, err := strconv.Unquote(s); err == nil { - return unquoted - } - } - return s +// patchFileHeaderPath reads a "--- "/"+++ " header through the same parser that +// produced patchPaths above. The executor must not re-interpret these bytes: a +// second reading that trims or unquotes differently would let the gate authorize +// `bridge-token ` while the executor opens the protected `bridge-token` beside +// it. Only the parser's own refusals are surfaced here. +func patchFileHeaderPath(line string) (string, bool) { + return sandbox.PatchFileHeaderPath(line) } +// stripPatchPrefix removes a single a/ or b/ prefix so a real directory named +// "a" or "b" is preserved. It preserves every other byte, including surrounding +// spaces, which are pathname data in an unquoted header operand. func stripPatchPrefix(path string) string { - path = strings.TrimSpace(path) - // A unified-diff path carries exactly one of the a/ or b/ prefixes; strip a - // single one so a real directory literally named "a" or "b" is preserved. - if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { - path = path[2:] - } - return filepath.ToSlash(path) + return sandbox.StripPatchPrefix(path) } diff --git a/internal/tools/apply_patch_cwd_token_test.go b/internal/tools/apply_patch_cwd_token_test.go new file mode 100644 index 000000000..aa13c6319 --- /dev/null +++ b/internal/tools/apply_patch_cwd_token_test.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" + "github.com/Gitlawb/zero/internal/sandbox" +) + +func TestApplyPatchDeniesDaemonTokenUnderExactWhitespaceCwd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Win32 normalizes terminal directory whitespace") + } + for _, cwd := range []string{" token-dir", "token-dir "} { + t.Run(strings.ReplaceAll(cwd, " ", "_"), func(t *testing.T) { + workspace := t.TempDir() + dir := filepath.Join(workspace, cwd) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + token := filepath.Join(dir, "token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(workspace, nil)) + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: workspace, + Policy: sandbox.DefaultPolicy(), + }) + result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{ + "cwd": cwd, + "patch": "--- a/token\n" + + "+++ b/token\n" + + "@@ -1 +1 @@\n" + + "-bridge-secret\n" + + "+attacker-controlled\n", + }, RunOptions{Sandbox: engine, PermissionGranted: true}) + if result.Status == StatusOK || !strings.Contains(result.Output, "remote bridge token") { + t.Fatalf("apply_patch with cwd %q: status=%s output=%q, want token denial", cwd, result.Status, result.Output) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after denied patch: contents=%q err=%v", contents, err) + } + }) + } +} diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index 2d255a358..cfd25f7a8 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -1,11 +1,24 @@ package tools import ( + "os" + "os/exec" + "path/filepath" "slices" + "strings" "testing" ) -func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { +func mustApplyPatchPaths(t *testing.T, patch string) []string { + t.Helper() + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) + if err != nil { + t.Fatalf("prepareApplyPatchArguments: %v", err) + } + return prepared.paths +} + +func TestApplyPatchPathsHandleQuotedAndSpacedNames(t *testing.T) { // git C-quotes a path that contains a space; the old strings.Fields parse kept // only the first whitespace-delimited token ("a/dir/file" and the literal `name` // pieces would split), losing the real name. The whole post-prefix value, with a @@ -13,15 +26,15 @@ func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { patch := "--- \"a/dir/file name.go\"\t2024-01-01 00:00:00\n" + "+++ \"b/dir/file name.go\"\n" + "@@ -1 +1 @@\n-old\n+new\n" - got := patchHeaderPaths(patch) + got := mustApplyPatchPaths(t, patch) if !slices.Contains(got, "dir/file name.go") { t.Fatalf("quoted spaced path not extracted: %v", got) } } -func TestPatchHeaderPathsUnspacedStillWorks(t *testing.T) { +func TestApplyPatchPathsUnspacedStillWork(t *testing.T) { patch := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n" - got := patchHeaderPaths(patch) + got := mustApplyPatchPaths(t, patch) if !slices.Contains(got, "x.go") { t.Fatalf("plain path not extracted: %v", got) } @@ -30,3 +43,186 @@ func TestPatchHeaderPathsUnspacedStillWorks(t *testing.T) { t.Fatalf("/dev/null should not be a header path: %v", got) } } + +// git only C-quotes names containing characters it must escape, so a filename +// whose last byte is an ordinary space reaches the header raw. git apply reads +// it to the tab or newline and patches that exact file, so trimming the operand +// here would leave the token gate comparing against a neighbouring pathname. +func TestApplyPatchPathsPreserveSurroundingSpacesInNames(t *testing.T) { + patch := "--- a/bridge-token \t2024-01-01 00:00:00\n" + + "+++ b/bridge-token \n" + + "@@ -1 +1 @@\n-old\n+new\n" + got := mustApplyPatchPaths(t, patch) + if !slices.Contains(got, "bridge-token ") { + t.Fatalf("trailing-space path not preserved: %q", got) + } + if slices.Contains(got, "bridge-token") { + t.Fatalf("trailing space trimmed off the header path: %q", got) + } +} + +func TestApplyPatchPathsRejectAmbiguousDiffOperands(t *testing.T) { + patch := "diff --git a/source b/part b/destination\nnew file mode 100644\n" + if prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}); err == nil { + t.Fatalf("prepareApplyPatchArguments = %#v, want ambiguous-path error", prepared) + } +} + +func TestApplyPatchPathsParseGitDefaultAndNoPrefixOutput(t *testing.T) { + for _, operation := range []string{"rename", "copy", "modify"} { + for _, noPrefix := range []bool{false, true} { + name := operation + "/default-prefix" + if noPrefix { + name = operation + "/no-prefix" + } + t.Run(name, func(t *testing.T) { + patch, source, destination := gitGeneratedPatch(t, operation, noPrefix) + got := mustApplyPatchPaths(t, patch) + for _, want := range []string{source, destination} { + if !slices.Contains(got, want) { + t.Fatalf("Git-generated %s paths = %q, want %q\npatch:\n%s", operation, got, want, patch) + } + } + }) + } + } +} + +func TestApplyPatchPathsAcceptNoPrefixSpacedRenameAndCopyWithHunks(t *testing.T) { + for _, operation := range []string{"rename", "copy"} { + t.Run(operation, func(t *testing.T) { + patch := "diff --git old name.txt new name.txt\n" + + "similarity index 75%\n" + + operation + " from old name.txt\n" + + operation + " to new name.txt\n" + + "--- old name.txt\n" + + "+++ new name.txt\n" + + "@@ -1 +1 @@\n" + + "-old\n" + + "+new\n" + paths := mustApplyPatchPaths(t, patch) + for _, want := range []string{"old name.txt", "new name.txt"} { + if !slices.Contains(paths, want) { + t.Fatalf("%s paths = %v, want %q", operation, paths, want) + } + } + }) + } +} + +func TestApplyPatchPathsRejectContradictoryRenameMetadata(t *testing.T) { + patch := "diff --git source.txt destination.txt\n" + + "similarity index 100%\n" + + "rename from other.txt\n" + + "rename to destination.txt\n" + if prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}); err == nil { + t.Fatalf("prepareApplyPatchArguments = %#v, want disagreement error", prepared) + } +} + +func TestApplyPatchPathsRejectContradictoryGitHeaders(t *testing.T) { + tests := map[string]string{ + "rename destination": "diff --git old.txt new.txt\nrename from old.txt\nrename to other.txt\n", + "copy source": "diff --git old.txt new.txt\ncopy from other.txt\ncopy to new.txt\n", + "update": "diff --git old.txt new.txt\n--- old.txt\n+++ other.txt\n@@ -1 +1 @@\n-old\n+new\n", + "create": "diff --git new.txt new.txt\n--- /dev/null\n+++ other.txt\n@@ -0,0 +1 @@\n+new\n", + "delete": "diff --git old.txt old.txt\n--- other.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n", + } + for name, patch := range tests { + t.Run(name, func(t *testing.T) { + if prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}); err == nil { + t.Fatalf("prepareApplyPatchArguments = %#v, want disagreement error", prepared) + } + }) + } +} + +func TestApplyPatchPathsAcceptMatchingGitHeaders(t *testing.T) { + tests := map[string]string{ + "rename": "diff --git old.txt new.txt\nrename from old.txt\nrename to new.txt\n", + "copy": "diff --git old.txt new.txt\ncopy from old.txt\ncopy to new.txt\n", + "update": "diff --git old.txt new.txt\n--- old.txt\n+++ new.txt\n@@ -1 +1 @@\n-old\n+new\n", + "create": "diff --git new.txt new.txt\n--- /dev/null\n+++ new.txt\n@@ -0,0 +1 @@\n+new\n", + "delete": "diff --git old.txt old.txt\n--- old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n", + } + for name, patch := range tests { + t.Run(name, func(t *testing.T) { + mustApplyPatchPaths(t, patch) + }) + } +} + +func gitGeneratedPatch(t *testing.T, operation string, noPrefix bool) (string, string, string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("git unavailable: %v", err) + } + dir := t.TempDir() + runGit := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "GIT_CONFIG_") { + cmd.Env = append(cmd.Env, entry) + } + } + cmd.Env = append(cmd.Env, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL="+filepath.Join(dir, "empty-global-gitconfig"), + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } + return string(output) + } + runGit("init", "-q") + runGit("config", "user.name", "Zero Tests") + runGit("config", "user.email", "zero-tests@example.invalid") + + source := "source name.txt" + destination := "destination name.txt" + if operation == "modify" { + source = "quoted-\u00e9.txt" + destination = source + } + if err := os.WriteFile(filepath.Join(dir, source), []byte("before\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit("add", "--", source) + runGit("commit", "-qm", "base") + + switch operation { + case "rename": + if err := os.Rename(filepath.Join(dir, source), filepath.Join(dir, destination)); err != nil { + t.Fatal(err) + } + case "copy": + if err := os.WriteFile(filepath.Join(dir, destination), []byte("before\n"), 0o600); err != nil { + t.Fatal(err) + } + case "modify": + if err := os.WriteFile(filepath.Join(dir, source), []byte("after\n"), 0o600); err != nil { + t.Fatal(err) + } + default: + t.Fatalf("unknown operation %q", operation) + } + runGit("add", "-A") + args := []string{"diff", "--cached"} + if noPrefix { + args = append(args, "--no-prefix") + } + switch operation { + case "rename": + args = append(args, "-M100%") + case "copy": + args = append(args, "--find-copies-harder", "-C100%") + } + patch := runGit(args...) + if operation != "modify" && !strings.Contains(patch, operation+" from ") { + t.Fatalf("git did not produce %s metadata:\n%s", operation, patch) + } + return patch, source, destination +} diff --git a/internal/tools/bash_auto_allow_test.go b/internal/tools/bash_auto_allow_test.go index ef335229d..3543ca5f2 100644 --- a/internal/tools/bash_auto_allow_test.go +++ b/internal/tools/bash_auto_allow_test.go @@ -130,6 +130,13 @@ func TestBashRequireEscalatedKeepsSandboxWhenDeniedReadsActive(t *testing.T) { } } +func TestBashRequireEscalatedKeepsSandboxWhenDaemonTokenProtected(t *testing.T) { + engine := defaultPolicyDaemonTokenEngine(t) + if commandEngineForSandboxPermissions(engine, SandboxPermissionsRequireEscalated) == nil { + t.Fatal("bash require_escalated must preserve the sandbox when the default remote-daemon policy protects a token file") + } +} + func TestBashStillPromptsWithoutActiveSandbox(t *testing.T) { root := t.TempDir() registry := NewRegistry() diff --git a/internal/tools/bash_tool_test.go b/internal/tools/bash_tool_test.go index 297f9816a..a1b484801 100644 --- a/internal/tools/bash_tool_test.go +++ b/internal/tools/bash_tool_test.go @@ -61,7 +61,10 @@ func runBashToolHelper(command string) { case "long-sleep": time.Sleep(5 * time.Second) fmt.Println("long sleep finished") - case "http-server": + case "http-server", "delayed-http-server": + if command == "delayed-http-server" { + time.Sleep(time.Second) + } listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/tools/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go new file mode 100644 index 000000000..db80be46e --- /dev/null +++ b/internal/tools/daemon_token_exclusion_test.go @@ -0,0 +1,623 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" + "github.com/Gitlawb/zero/internal/sandbox" +) + +// daemonTokenFixture builds a workspace holding the remote bridge's token file +// alongside an ordinary file, with ZERO_DAEMON_REMOTE_TOKEN_FILE pointing at it — +// the shape a remote daemon session takes when its token lives in the session +// workspace. AllowRead deliberately covers the whole workspace so the tests prove +// the exclusion is not re-includable. +func daemonTokenFixture(t *testing.T) (string, string, *sandbox.Engine) { + t.Helper() + return daemonTokenFixtureNamed(t, "bridge-token") +} + +// daemonTokenFixtureNamed is daemonTokenFixture with the token's filename under +// the caller's control, so a test can pin a spelling the gate and the tool must +// agree on byte for byte (a trailing space, for instance). It skips rather than +// fails when the host filesystem refuses the name — Windows rejects trailing +// spaces and dots — so the matrix still runs everywhere it can. +func daemonTokenFixtureNamed(t *testing.T, tokenName string) (string, string, *sandbox.Engine) { + t.Helper() + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if err := os.WriteFile(filepath.Join(ws, "main.go"), []byte("package main // bridge-secret\n"), 0o600); err != nil { + t.Fatalf("write main.go: %v", err) + } + token := filepath.Join(ws, tokenName) + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Skipf("filesystem rejects token name %q: %v", tokenName, err) + } + // Windows silently strips trailing spaces/dots rather than erroring, and it + // strips them from the lookup too, so an Lstat of the requested name would + // happily find the normalized file and the test would assert against a + // spelling that never existed. Compare against the real directory entry. + entries, err := os.ReadDir(ws) + if err != nil { + t.Fatalf("read workspace: %v", err) + } + stored := false + for _, entry := range entries { + if entry.Name() == tokenName { + stored = true + break + } + } + if !stored { + t.Skipf("filesystem did not preserve token name %q", tokenName) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + + scope, err := sandbox.NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: ws, + Policy: sandbox.Policy{ + Mode: sandbox.ModeEnforce, + EnforceWorkspace: true, + AllowRead: []string{ws}, + }, + Scope: scope, + }) + return ws, token, engine +} + +// defaultPolicyDaemonTokenEngine is the remote-daemon shape relevant to shell +// escalation: a selected token file with the default policy and no user +// DenyRead entries. +func defaultPolicyDaemonTokenEngine(t *testing.T) *sandbox.Engine { + t.Helper() + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(ws, "bridge-token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + policy := sandbox.DefaultPolicy() + if len(policy.DenyRead) != 0 { + t.Fatalf("default policy DenyRead = %#v, want none", policy.DenyRead) + } + return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: ws, Policy: policy}) +} + +func TestGrepSkipsDaemonTokenFile(t *testing.T) { + ws, _, engine := daemonTokenFixture(t) + tool, ok := NewScopedGrepTool(ws, nil).(sandboxAwareTool) + if !ok { + t.Fatal("grep tool must be sandbox-aware") + } + args := map[string]any{"pattern": "bridge-secret", "output_mode": "files_with_matches"} + + sandboxed := tool.RunWithSandbox(context.Background(), args, engine) + if sandboxed.Status != StatusOK { + t.Fatalf("grep failed: %s", sandboxed.Output) + } + if !strings.Contains(sandboxed.Output, "main.go") { + t.Fatalf("grep must still match ordinary workspace files, got:\n%s", sandboxed.Output) + } + if strings.Contains(sandboxed.Output, "bridge-token") { + t.Fatalf("grep must NOT surface the remote bridge token file, got:\n%s", sandboxed.Output) + } +} + +func TestGlobSkipsDaemonTokenFile(t *testing.T) { + ws, _, engine := daemonTokenFixture(t) + tool, ok := NewScopedGlobTool(ws, nil).(sandboxAwareTool) + if !ok { + t.Fatal("glob tool must be sandbox-aware") + } + + sandboxed := tool.RunWithSandbox(context.Background(), map[string]any{"pattern": "**/*"}, engine) + if sandboxed.Status != StatusOK { + t.Fatalf("glob failed: %s", sandboxed.Output) + } + if !strings.Contains(sandboxed.Output, "main.go") { + t.Fatalf("glob must still match ordinary workspace files, got:\n%s", sandboxed.Output) + } + if strings.Contains(sandboxed.Output, "bridge-token") { + t.Fatalf("glob must NOT surface the remote bridge token file, got:\n%s", sandboxed.Output) + } +} + +func TestListDirectorySkipsDaemonTokenFile(t *testing.T) { + ws, _, engine := daemonTokenFixture(t) + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + + result := registry.RunWithOptions(context.Background(), "list_directory", map[string]any{ + "path": ".", + }, RunOptions{Sandbox: engine}) + if result.Status != StatusOK { + t.Fatalf("list_directory failed: %s", result.Output) + } + if !strings.Contains(result.Output, "main.go") { + t.Fatalf("list_directory must still show ordinary workspace files, got:\n%s", result.Output) + } + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("list_directory must NOT surface the remote bridge token file, got:\n%s", result.Output) + } +} + +// TestEngineDeniesDaemonTokenFileTools covers the request gate the direct file +// tools go through (read_file, write_file, edit_file, apply_patch): the bridge +// token must be neither readable nor writable, even though AllowRead covers the +// whole workspace and the file is inside it. +func TestEngineDeniesDaemonTokenFileTools(t *testing.T) { + ws, token, engine := daemonTokenFixture(t) + for _, tc := range []struct { + name string + toolName string + sideEffect sandbox.SideEffect + }{ + {name: "read_file", toolName: "read_file", sideEffect: sandbox.SideEffectRead}, + {name: "write_file", toolName: "write_file", sideEffect: sandbox.SideEffectWrite}, + {name: "edit_file", toolName: "edit_file", sideEffect: sandbox.SideEffectWrite}, + {name: "apply_patch", toolName: "apply_patch", sideEffect: sandbox.SideEffectWrite}, + } { + t.Run(tc.name, func(t *testing.T) { + decision := engine.Evaluate(context.Background(), sandbox.Request{ + ToolName: tc.toolName, + WorkspaceRoot: ws, + SideEffect: tc.sideEffect, + Args: map[string]any{"path": token}, + // A granted permission must not override the exclusion either. + Permission: sandbox.PermissionAllow, + }) + if decision.Action != sandbox.ActionDeny || !strings.Contains(decision.Reason, "remote bridge token") { + t.Fatalf("%s on the bridge token: action = %q reason = %q, want a bridge-token deny", tc.toolName, decision.Action, decision.Reason) + } + }) + } + + // An ordinary workspace file in the same directory stays usable. + decision := engine.Evaluate(context.Background(), sandbox.Request{ + ToolName: "read_file", + WorkspaceRoot: ws, + SideEffect: sandbox.SideEffectRead, + Args: map[string]any{"path": filepath.Join(ws, "main.go")}, + }) + if decision.Action == sandbox.ActionDeny { + t.Fatalf("ordinary workspace read was denied: %q", decision.Reason) + } +} + +func TestApplyPatchDeniesQuotedDaemonTokenPathWithSpaces(t *testing.T) { + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(ws, "bridge token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: ws, Policy: sandbox.DefaultPolicy()}) + + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + patch := "diff --git \"a/bridge token\" \"b/bridge token\"\n" + + "--- \"a/bridge token\"\n" + + "+++ \"b/bridge token\"\n" + + "@@ -1 +1 @@\n" + + "-bridge-secret\n" + + "+attacker-controlled\n" + result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{ + "patch": patch, + }, RunOptions{Sandbox: engine, PermissionGranted: true}) + if result.Status == StatusOK || !strings.Contains(result.Output, "remote bridge token") { + t.Fatalf("apply_patch on quoted protected path: status=%s output=%q, want bridge-token denial", result.Status, result.Output) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after denied patch: contents=%q err=%v", contents, err) + } +} + +func TestApplyPatchDeniesHeaderOnlyAndBinaryDaemonTokenPatches(t *testing.T) { + original := []byte("bridge-secret\x00original\n") + for _, tc := range []struct { + name string + tokenName string + patch string + destination string + wantControlSource []byte + wantControlTarget []byte + // controlUnsupported marks a patch form the in-process unified-diff + // engine does not implement, so the control cannot demonstrate an + // applied effect: apply_patch no longer shells out to git, and these + // forms now reach no file at all. The control then proves the weaker + // but still non-vacuous property that the refusal is a format refusal + // which creates nothing, while the protected case below must still be + // refused by the credential gate specifically. + controlUnsupported string + }{ + { + name: "header-only copy", + tokenName: "bridge token", + patch: "diff --git a/bridge token b/exposed-token\n" + + "similarity index 100%\n" + + "copy from bridge token\n" + + "copy to exposed-token\n", + destination: "exposed-token", + wantControlSource: original, + wantControlTarget: original, + }, + { + name: "header-only rename", + tokenName: "bridge token", + patch: "diff --git a/bridge token b/renamed-token\n" + + "similarity index 100%\n" + + "rename from bridge token\n" + + "rename to renamed-token\n", + destination: "renamed-token", + wantControlTarget: original, + }, + { + name: "header-only rename aliases", + tokenName: "bridge token", + patch: "diff --git a/bridge token b/alias-renamed-token\n" + + "similarity index 100%\n" + + "rename old bridge token\n" + + "rename new alias-renamed-token\n", + destination: "alias-renamed-token", + wantControlTarget: original, + // Legacy "rename old"/"rename new" spellings; the in-process engine + // implements only git's "rename from"/"rename to". + controlUnsupported: `unexpected "rename old bridge token"`, + }, + { + name: "header-only copy preserves leading space", + tokenName: " bridge-token", + patch: "diff --git a/ bridge-token b/leading-space-copy\n" + + "similarity index 100%\n" + + "copy from bridge-token\n" + + "copy to leading-space-copy\n", + destination: "leading-space-copy", + wantControlSource: original, + // The rename/copy executor now reads its headers through the same + // byte-preserving parser as the credential gate, so a leading space + // is copied as pathname data instead of being trimmed away into a + // different file's name. + wantControlTarget: original, + }, + { + name: "binary modification", + tokenName: "bridge token", + patch: "diff --git a/bridge token b/bridge token\n" + + "index 6e4018bb778ca15e70706fe1f7a4c22e762f37b6..d3eae57ec6ad245ec7ab173e28141ac2f87cca68 100644\n" + + "GIT binary patch\n" + + "literal 23\n" + + "ecmYc+DM?JuPA$?+Ni0cZ$jwj5Ov_A7;Q|0@R|sMN\n\n" + + "literal 23\n" + + "ecmYc)%1lX5)h$j alias && cat alias` +// — the same pathname model a user-configured DenyRead has always had. See +// protectedPathDenied and denyReadRules in internal/sandbox for why that is the +// boundary rather than an omission. +func TestDaemonTokenAliasesDeniedEndToEnd(t *testing.T) { + for _, aliasKind := range []string{"symlink", "hardlink"} { + t.Run(aliasKind, func(t *testing.T) { + ws, token, engine := daemonTokenFixture(t) + alias := filepath.Join(ws, "token-alias") + var err error + switch aliasKind { + case "symlink": + err = os.Symlink(token, alias) + case "hardlink": + err = os.Link(token, alias) + } + if err != nil { + t.Skipf("%s unsupported: %v", aliasKind, err) + } + + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + registry.Register(NewScopedWriteFileTool(ws, nil)) + + read := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": alias}, RunOptions{Sandbox: engine}) + if read.Status == StatusOK || strings.Contains(read.Output, "bridge-secret") { + t.Fatalf("read_file followed protected %s: status=%s output=%q", aliasKind, read.Status, read.Output) + } + + write := registry.RunWithOptions(context.Background(), "write_file", map[string]any{"path": alias, "content": "attacker-controlled\n"}, RunOptions{Sandbox: engine}) + if write.Status == StatusOK { + t.Fatalf("write_file followed protected %s: output=%q", aliasKind, write.Output) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after denied write through %s: contents=%q err=%v", aliasKind, contents, err) + } + + grep, ok := NewScopedGrepTool(ws, nil).(sandboxAwareTool) + if !ok { + t.Fatal("grep tool must be sandbox-aware") + } + result := grep.RunWithSandbox(context.Background(), map[string]any{ + "pattern": "bridge-secret", + "output_mode": "files_with_matches", + }, engine) + if result.Status != StatusOK { + t.Fatalf("grep failed: %s", result.Output) + } + if strings.Contains(result.Output, "token-alias") || strings.Contains(result.Output, "bridge-token") { + t.Fatalf("grep surfaced protected %s target:\n%s", aliasKind, result.Output) + } + if !strings.Contains(result.Output, "main.go") { + t.Fatalf("grep omitted ordinary file while filtering %s:\n%s", aliasKind, result.Output) + } + }) + } +} + +// TestGrepDoesNotScanTokenAliasSwappedInAfterExclusion closes the check/use +// window between grep's walk-time pathname exclusion and the os.Open in +// scanGrepFile. A workspace writer can replace an ordinary candidate with a +// hard link to the token in that window; path confinement cannot catch it, +// because the alias is a real file inside the root under an ordinary name. +// Only a decision bound to the opened handle can, so the seam below performs +// the swap from inside the pathname check itself — deterministically, with no +// scheduling assumptions. +func TestGrepDoesNotScanTokenAliasSwappedInAfterExclusion(t *testing.T) { + ws, token, _ := daemonTokenFixture(t) + candidate := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(candidate, []byte("ordinary notes\n"), 0o600); err != nil { + t.Fatalf("write candidate: %v", err) + } + + base := sandboxReadExcluderWithin(nil, ws) + if !base.fileExcluded(token) { + t.Fatal("fixture must protect the token by pathname") + } + swapped := false + exclude := base + exclude.file = func(path string) bool { + if !swapped && path == candidate { + swapped = true + if err := os.Remove(candidate); err != nil { + t.Fatalf("remove candidate: %v", err) + } + if err := os.Link(token, candidate); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + // Cleared as an ordinary file, exactly as it was when visited. + return false + } + return base.fileExcluded(path) + } + + var matches []grepMatch + err := scanGrepMatches(context.Background(), ws, ws, nil, exclude, false, + presenceGrepLineMatcher(regexp.MustCompile("bridge-secret")), + func(match grepMatch) bool { + matches = append(matches, match) + return true + }) + if err != nil { + t.Fatalf("grep walk: %v", err) + } + if !swapped { + t.Fatal("the swap seam never ran, so the window was never exercised") + } + for _, match := range matches { + if strings.Contains(match.file, "notes.txt") || strings.Contains(match.file, "bridge-token") { + t.Fatalf("grep scanned the token alias swapped in after the exclusion: %+v", match) + } + } + // The ordinary workspace file must still be searched; the handle check may + // only ever remove the protected object from the results. + found := false + for _, match := range matches { + if strings.Contains(match.file, "main.go") { + found = true + } + } + if !found { + t.Fatalf("handle check dropped ordinary matches: %+v", matches) + } +} diff --git a/internal/tools/daemon_token_matrix_test.go b/internal/tools/daemon_token_matrix_test.go new file mode 100644 index 000000000..75eb59d37 --- /dev/null +++ b/internal/tools/daemon_token_matrix_test.go @@ -0,0 +1,289 @@ +package tools + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" +) + +func TestDaemonTokenNavigationAndImageMatrix(t *testing.T) { + for _, kind := range []string{"exact", "symlink", "hardlink", "ordinary"} { + t.Run(kind, func(t *testing.T) { + ws, token, engine := daemonTokenFixture(t) + t.Setenv(remote.EnvTokenFileResolved, "") + t.Setenv(remote.EnvTokenFileIdentity, "") + // Valid image bytes ensure refusal cannot be an image-type error. + if err := os.WriteFile(token, onePixelPNG, 0o600); err != nil { + t.Fatal(err) + } + path := token + if kind != "exact" { + path = filepath.Join(ws, "candidate.unknownext") + var err error + switch kind { + case "symlink": + err = os.Symlink(token, path) + case "hardlink": + err = os.Link(token, path) + case "ordinary": + err = os.WriteFile(path, onePixelPNG, 0o600) + } + if err != nil { + t.Skipf("%s unavailable: %v", kind, err) + } + } + registry := NewRegistry() + registry.Register(NewScopedLSPNavigateTool(ws, nil)) + registry.Register(NewViewImageTool(ws)) + for _, withEngine := range []bool{false, true} { + options := RunOptions{} + if withEngine { + options.Sandbox = engine + } + for _, op := range []string{"definition", "workspace_symbol", "image"} { + name := "lsp_navigate" + args := map[string]any{"path": path, "op": op, "line": 1, "query": "x"} + if op == "image" { + name, args = ViewImageToolName, map[string]any{"path": path} + } + result := registry.RunWithOptions(context.Background(), name, args, options) + if kind == "ordinary" { + if result.Status != StatusOK { + t.Fatalf("%s engine=%v ordinary control: %s", op, withEngine, result.Output) + } + if op == "image" && (len(result.Images) != 1 || !bytes.Equal(result.Images[0].Data, onePixelPNG)) { + t.Fatal("ordinary image bytes changed") + } + } else if result.Status != StatusError || len(result.Images) != 0 || !strings.Contains(result.Output, "holds the remote bridge token") { + t.Errorf("%s engine=%v must refuse credential before consumption: %+v", op, withEngine, result) + } + } + } + }) + } +} + +// The gate and the tool must resolve a path argument to the SAME bytes. +// aliasedStringArg does not trim, so while requestPaths ran path args through +// argString (strings.TrimSpace), a token file whose name carries meaningful +// whitespace was protected under its real spelling while the gate inspected the +// trimmed one — read_file opened "bridge-token " after the gate cleared +// "bridge-token". This runs the production path (registry.RunWithOptions with +// the sandbox engine), not a profile builder, because that divergence is +// invisible to a test that calls the gate directly. +func TestEngineDeniesReadFileWithExactSpacedTokenPath(t *testing.T) { + for _, tokenName := range []string{"bridge-token ", " bridge-token", "bridge token "} { + t.Run(strings.ReplaceAll(tokenName, " ", "_"), func(t *testing.T) { + ws, _, engine := daemonTokenFixtureNamed(t, tokenName) + + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + + // Send the RELATIVE spelling. The whitespace has to sit at the + // boundary of the argument string for TrimSpace to reach it: in an + // absolute path the space is mid-string (after the separator) and + // the old gate happened to behave. That is exactly why this needs + // to be a named regression rather than a variant of the existing + // absolute-path coverage. + result := registry.RunWithOptions(context.Background(), "read_file", + map[string]any{"path": tokenName}, RunOptions{Sandbox: engine}) + if result.Status == StatusOK { + t.Fatalf("read_file served the protected token under its exact spelling: output=%q", result.Output) + } + if strings.Contains(result.Output, "bridge-secret") { + t.Fatalf("denied read still leaked the bearer token: output=%q", result.Output) + } + + // The trimmed spelling must stay denied too: it is the same + // credential identity, and the gate is now a superset of what it + // inspected before this became exact. + trimmed := strings.TrimSpace(tokenName) + trimmedResult := registry.RunWithOptions(context.Background(), "read_file", + map[string]any{"path": trimmed}, RunOptions{Sandbox: engine}) + if strings.Contains(trimmedResult.Output, "bridge-secret") { + t.Fatalf("trimmed spelling leaked the bearer token: output=%q", trimmedResult.Output) + } + }) + } +} + +// The Step C matrix: every tool that can name a path, crossed with the +// spellings an attacker controls. Earlier rounds on this branch each closed one +// cell — shell, then read_file, then apply_patch headers, then whitespace — so +// the point here is to run them all through the production entrypoint at once +// and make a future gap fail as a missing row rather than as a new report. +func TestDaemonTokenProtectionMatrix(t *testing.T) { + // Each column is a spelling of the SAME protected credential. + spellings := []struct { + name string + token string // token filename on disk + arg func(ws, token string) string + }{ + { + name: "exact", + token: "bridge-token", + arg: func(_, token string) string { return token }, + }, + { + name: "trailing space", + token: "bridge-token ", + arg: func(_, token string) string { return token }, + }, + { + name: "relative", + token: "bridge-token", + arg: func(_, token string) string { return filepath.Base(token) }, + }, + { + name: "dot segment", + token: "bridge-token", + arg: func(ws, token string) string { + return filepath.Join(ws, ".", filepath.Base(token)) + }, + }, + { + name: "parent traversal", + token: "bridge-token", + arg: func(ws, token string) string { + return filepath.Join(ws, "sub", "..", filepath.Base(token)) + }, + }, + } + + for _, spelling := range spellings { + t.Run(spelling.name, func(t *testing.T) { + t.Run("read_file", func(t *testing.T) { + ws, token, engine := daemonTokenFixtureNamed(t, spelling.token) + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + result := registry.RunWithOptions(context.Background(), "read_file", + map[string]any{"path": spelling.arg(ws, token)}, RunOptions{Sandbox: engine}) + assertTokenNotLeaked(t, "read_file", result) + }) + + t.Run("write_file", func(t *testing.T) { + ws, token, engine := daemonTokenFixtureNamed(t, spelling.token) + registry := NewRegistry() + registry.Register(NewScopedWriteFileTool(ws, nil)) + result := registry.RunWithOptions(context.Background(), "write_file", + map[string]any{"path": spelling.arg(ws, token), "content": "attacker\n"}, + RunOptions{Sandbox: engine, PermissionGranted: true}) + if result.Status == StatusOK { + t.Fatalf("write_file overwrote the protected token: output=%q", result.Output) + } + if !strings.Contains(result.Output, "holds the remote bridge token") { + t.Fatalf("write_file refusal did not come from the credential gate: output=%q", result.Output) + } + // A refused write must leave the bearer intact, not truncate it. + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied write: contents=%q err=%v", contents, err) + } + }) + + t.Run("list_directory", func(t *testing.T) { + ws, _, engine := daemonTokenFixtureNamed(t, spelling.token) + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + result := registry.RunWithOptions(context.Background(), "list_directory", + map[string]any{"path": ws}, RunOptions{Sandbox: engine}) + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("list_directory surfaced the protected token filename:\n%s", result.Output) + } + if !strings.Contains(result.Output, "main.go") { + t.Fatalf("list_directory dropped ordinary entries while filtering:\n%s", result.Output) + } + }) + + t.Run("grep", func(t *testing.T) { + ws, _, engine := daemonTokenFixtureNamed(t, spelling.token) + grep, ok := NewScopedGrepTool(ws, nil).(sandboxAwareTool) + if !ok { + t.Fatal("grep tool must be sandbox-aware") + } + result := grep.RunWithSandbox(context.Background(), map[string]any{ + "pattern": "bridge-secret", + "output_mode": "files_with_matches", + }, engine) + if result.Status != StatusOK { + t.Fatalf("grep failed: %s", result.Output) + } + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("grep surfaced the protected token:\n%s", result.Output) + } + if !strings.Contains(result.Output, "main.go") { + t.Fatalf("grep dropped ordinary matches while filtering:\n%s", result.Output) + } + }) + + t.Run("apply_patch", func(t *testing.T) { + ws, token, engine := daemonTokenFixtureNamed(t, spelling.token) + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + target := spelling.arg(ws, token) + oldPath := strconv.Quote("a/" + filepath.ToSlash(target)) + newPath := strconv.Quote("b/" + filepath.ToSlash(target)) + patch := "--- " + oldPath + "\n+++ " + newPath + + "\n@@ -1 +1 @@\n-bridge-secret\n+attacker\n" + result := registry.RunWithOptions(context.Background(), "apply_patch", + map[string]any{"patch": patch}, + RunOptions{Sandbox: engine, PermissionGranted: true}) + if result.Status == StatusOK { + t.Fatalf("apply_patch rewrote the protected token: output=%q", result.Output) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied patch: contents=%q err=%v", contents, err) + } + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) + if err != nil { + t.Fatalf("prepareApplyPatchArguments: %v", err) + } + if len(prepared.paths) != 1 || prepared.paths[0] != filepath.ToSlash(target) { + t.Fatalf("executor paths = %q, want exact target spelling", prepared.paths) + } + // Every spelling — absolute included — must be refused by the + // credential gate itself. An absolute path inside the workspace + // is legitimate for an ordinary target, so the blanket + // absolute-path rejection cannot be what protects the token. + if !strings.Contains(result.Output, "holds the remote bridge token") { + t.Fatalf("apply_patch refusal did not come from the credential gate: output=%q", result.Output) + } + }) + }) + } +} + +// registry.Run reaches list_directory without a sandbox engine (MCP, legacy +// callers). The protected-credential set does not come from a policy, so the +// bearer filename must not become visible just because no engine was passed. +func TestListDirectoryWithoutEngineStillHidesProtectedToken(t *testing.T) { + ws, _, _ := daemonTokenFixture(t) + + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + result := registry.Run(context.Background(), "list_directory", map[string]any{"path": ws}) + + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("engine-less list_directory disclosed the protected token filename:\n%s", result.Output) + } + if !strings.Contains(result.Output, "main.go") { + t.Fatalf("engine-less list_directory dropped ordinary entries while filtering:\n%s", result.Output) + } +} + +func assertTokenNotLeaked(t *testing.T, tool string, result Result) { + t.Helper() + if result.Status == StatusOK { + t.Fatalf("%s served the protected token: output=%q", tool, result.Output) + } + if strings.Contains(result.Output, "bridge-secret") { + t.Fatalf("%s leaked the bearer token in a denial: output=%q", tool, result.Output) + } +} diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index 34f55781e..1c0bd554d 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "strings" ) @@ -12,6 +13,7 @@ type editFileTool struct { baseTool workspaceRoot string scope PathScope + formatter writtenFileFormatter } func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool { @@ -35,6 +37,7 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, + formatter: maybeFormatWrittenFile, } } @@ -60,11 +63,24 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any return errorResult("Error: Invalid arguments for edit_file: " + err.Error()) } - absolutePath, relativePath, err := resolveScopedPath(tool.workspaceRoot, tool.scope, requestedPath) + target, err := resolveScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath) if err != nil { return errorResult("Error reading " + requestedPath + ": " + err.Error()) } - contentBytes, err := os.ReadFile(absolutePath) + absolutePath, relativePath := target.absolute, target.display + root, err := os.OpenRoot(target.root) + if err != nil { + return errorResult("Error reading " + relativePath + ": " + err.Error()) + } + defer root.Close() + // Bind both workspace containment and credential identity to the handle the + // content is actually read from. + readFile, readInfo, err := protectedRootRead(root, target.relative, absolutePath, tool.workspaceRoot) + if err != nil { + return errorResult("Error reading " + relativePath + ": " + err.Error()) + } + contentBytes, err := io.ReadAll(readFile) + readFile.Close() if err != nil { return errorResult("Error reading " + relativePath + ": " + err.Error()) } @@ -80,10 +96,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 @@ -154,28 +166,26 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any return okResult("No changes: new_string is identical to old_string.") } editedSpans := replacementByteSpans(content, oldString, newString, replaceAll) - if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { + if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } - if err := commitFileContents(absolutePath, priorInfo, &content, updated); err != nil { + if err := commitRootedFileContents(root, target.relative, readInfo, &content, updated); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } modelKnownContent := updated // Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the // FileTracker re-baseline: recording pre-format content would make the very // next edit look like an external modification and trip the conflict guard. - formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, updated, priorInfo.Mode().Perm()) - updated = formatting.Content - finalContentKnown := formatting.ContentKnown + formatting := tool.formatter(ctx, root, target.relative, absolutePath, tool.workspaceRoot, updated, readInfo.Mode()) + published, newInfo, err := readRootedFile(root, target.relative) + if err != nil { + options.FileTracker.Forget(absolutePath) + return errorResult("Error reading written file " + relativePath + ": " + err.Error()) + } + updated = string(published) // Re-baseline to the content we just wrote so subsequent edits in this session // compare against the current on-disk state, not the pre-edit version. - newInfo := formatting.Info - if newInfo == nil { - newInfo, _ = os.Stat(absolutePath) - } - if !finalContentKnown { - options.FileTracker.Forget(absolutePath) - } else if updated == modelKnownContent { + if updated == modelKnownContent { // OUR edit, so we know precisely which lines moved: RecordEdit carries // across the reads this edit did not disturb instead of dropping them. // @@ -205,24 +215,17 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any } summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix) summary += formatting.notice(relativePath) - if finalContentKnown { - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) - } + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) result := okResult(summary) result.ChangedFiles = []string{relativePath} - if finalContentKnown { - if diff, ok := boundedFileDiff(absolutePath, content, updated, true, true); ok { - result.FileDiffs = []FileDiff{diff} - } else if diffTextRevealsObfuscatedSecret(content) || diffTextRevealsObfuscatedSecret(updated) { - result.Redacted = true - } + if diff, ok := boundedFileDiff(absolutePath, content, updated, true, true); ok { + result.FileDiffs = []FileDiff{diff} + } else if diffTextRevealsObfuscatedSecret(content) || diffTextRevealsObfuscatedSecret(updated) { + result.Redacted = true } // Card-only preview (Display.Preview): the model's Output stays the one-line // summary, so the red/green diff costs zero model tokens. - preview := "" - if finalContentKnown { - preview = boundedUnifiedDiff(relativePath, content, updated) - } + preview := boundedUnifiedDiff(relativePath, content, updated) result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: preview} return result } diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index ab45823f1..4a6c90a50 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -177,6 +177,13 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin } } +func TestExecCommandRequireEscalatedKeepsSandboxWhenDaemonTokenProtected(t *testing.T) { + engine := defaultPolicyDaemonTokenEngine(t) + if commandEngineForSandboxPermissions(engine, SandboxPermissionsRequireEscalated) == nil { + t.Fatal("exec_command require_escalated must preserve the sandbox when the default remote-daemon policy protects a token file") + } +} + // TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors // TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox // guard exists only because MSYS/Cygwin coreutils fail under the @@ -362,8 +369,10 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) { execTool := NewScopedExecCommandTool(root, nil, manager) writeTool := NewWriteStdinTool(manager) + // Force startup to outlast the first yield, rather than relying on CI load + // to exercise the readiness polling below. start := execTool.Run(context.Background(), map[string]any{ - "cmd": helperCommand("http-server"), + "cmd": helperCommand("delayed-http-server"), "yield_time_ms": 500, }) if start.Status != StatusOK { @@ -373,10 +382,6 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) { if err != nil { t.Fatalf("foreground server should return session_id, meta=%#v output=%q", start.Meta, start.Output) } - addr := parseListeningAddress(start.Output) - if addr == "" { - t.Fatalf("server output did not include listening address: %q", start.Output) - } t.Cleanup(func() { writeTool.Run(context.Background(), map[string]any{ "session_id": sessionID, @@ -384,6 +389,27 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) { }) }) + // Yielding a session does not promise that the child has finished starting, + // especially when Windows must launch a shell first. Poll for readiness and + // register cleanup above so a startup failure cannot leak the server. + output := start.Output + addr := parseListeningAddress(output) + deadline := time.Now().Add(10 * time.Second) + for addr == "" && time.Now().Before(deadline) { + poll := writeTool.Run(context.Background(), map[string]any{ + "session_id": sessionID, + "yield_time_ms": 100, + }) + output += "\n" + poll.Output + if poll.Status != StatusOK || poll.Meta["exit_code"] != "" { + t.Fatalf("server exited before readiness: %q", output) + } + addr = parseListeningAddress(output) + } + if addr == "" { + t.Fatalf("server output did not include listening address before deadline: %q", output) + } + response, err := http.Get("http://" + addr) if err != nil { t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, start.Output) diff --git a/internal/tools/file_commit.go b/internal/tools/file_commit.go index 924e056dd..6bac80aae 100644 --- a/internal/tools/file_commit.go +++ b/internal/tools/file_commit.go @@ -5,6 +5,9 @@ import ( "fmt" "io" "os" + "path/filepath" + + "github.com/Gitlawb/zero/internal/sandbox" ) var errFileChangedDuringWrite = errors.New("file changed on disk before the write committed") @@ -19,7 +22,7 @@ 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 +// commitRootedFileContents binds an overwrite to the file identity and bytes that // the caller observed. A create uses exclusive creation. 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 @@ -28,13 +31,20 @@ var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat( // 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, relative string, expectedInfo os.FileInfo, expectedContent *string, content string) error { + path := filepath.Join(root.Name(), relative) if fileWriteBeforeCommit != nil { fileWriteBeforeCommit(path) } if expectedInfo == nil { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err := protectedMutationDenied(path, root.Name()); err != nil { + return err + } + if err := root.MkdirAll(filepath.Dir(relative), 0o755); err != nil { + return err + } + file, err := root.OpenFile(relative, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } @@ -43,14 +53,14 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * _ = file.Close() return err } - return writeAndVerifyFileIdentity(path, file, openedInfo, content, false) + return writeAndVerifyFileIdentity(root, relative, 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(relative, flags, 0) if err != nil { return err } @@ -59,11 +69,16 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * _ = file.Close() return err } + exclusions := sandbox.ProtectedCredentialExclusions(root.Name()) + if exclusions.FileHandleExcluded(path, file, openedInfo) { + _ = file.Close() + return protectedCredentialErr(path, "writable") + } if !os.SameFile(expectedInfo, openedInfo) { _ = file.Close() return errFileChangedDuringWrite } - pathInfo, err := os.Stat(path) + pathInfo, err := root.Stat(relative) if err != nil || !os.SameFile(openedInfo, pathInfo) { _ = file.Close() return errFileChangedDuringWrite @@ -79,10 +94,10 @@ func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent * return errFileChangedDuringWrite } } - return writeAndVerifyFileIdentity(path, file, openedInfo, content, true) + return writeAndVerifyFileIdentity(root, relative, file, openedInfo, content, true) } -func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error { +func writeAndVerifyFileIdentity(root *os.Root, relative 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 +115,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(relative) if err != nil || !os.SameFile(openedInfo, pathInfo) { return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite) } diff --git a/internal/tools/format_on_write.go b/internal/tools/format_on_write.go index a7cdafd50..eacbc2f2e 100644 --- a/internal/tools/format_on_write.go +++ b/internal/tools/format_on_write.go @@ -1,10 +1,9 @@ package tools import ( + "bytes" "context" "errors" - "fmt" - "io" "os" "os/exec" "path/filepath" @@ -15,11 +14,11 @@ import ( ) // Format-on-write for the mutating file tools. When enabled, a successful -// edit_file/write_file runs the language's standard formatter on the file it -// just wrote, so the model's output always lands in project-canonical style -// and never fails a CI format check it cannot see. Off by default (set -// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not -// write, which strict workflows may not want. +// edit_file/write_file sends the written content to the language's standard +// formatter over stdin, using the destination only as a project-config filename +// hint, then publishes stdout through the protected write primitive. Off by +// default (set ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the +// model did not write, which strict workflows may not want. // // Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and // the caller records the POST-format content. Formatting after the baseline @@ -48,33 +47,16 @@ var formatOnWriteTimeout = 10 * time.Second // and finds out from a CI format check it cannot see, which is the thing this // feature exists to prevent. type formatOnWriteResult struct { - Content string - ContentKnown bool - Info os.FileInfo + Content string // Formatter is the binary that was run, named in the notice so the user can // tell a slow gofmt from a slow prettier. Formatter string TimedOut bool - // 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 - // can leave the target truncated or half-rewritten: what a dead - // `prettier --write` leaves behind is not the input and not the output. - // Returning the written bytes while disk holds something else would put the - // tracker baseline, the diff preview and the file itself into three - // different states, so the failure paths write the bytes back. When even - // that fails the user has to hear about it: it is their file. - RestoreFailed bool } // notice is the line appended to the tool summary when formatting was expected // and did not happen, 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 "" } @@ -83,38 +65,43 @@ func (result formatOnWriteResult) notice(relativePath string) string { ". The file holds exactly what was written, so a project format check may still flag it." } -// 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. +type writtenFileFormatter func(context.Context, *os.Root, string, string, string, string, os.FileMode) formatOnWriteResult + +const formatterPathPlaceholder = "{zero_file_path}" + +// formatterCommands maps a file extension to a formatter argv. The placeholder +// is replaced with the destination path solely as the formatter's filename hint. +// All formatters consume stdin and emit formatted content on stdout; a missing +// binary silently skips formatting. 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"}, - ".zig": {"zig", "fmt"}, - ".dart": {"dart", "format"}, - ".tf": {"terraform", "fmt"}, - ".gleam": {"gleam", "format"}, - ".sh": {"shfmt", "-w"}, - ".bash": {"shfmt", "-w"}, - ".c": {"clang-format", "-i"}, - ".h": {"clang-format", "-i"}, - ".cpp": {"clang-format", "-i"}, - ".hpp": {"clang-format", "-i"}, - ".cc": {"clang-format", "-i"}, - ".kt": {"ktlint", "-F"}, - ".swift": {"swiftformat"}, - ".lua": {"stylua"}, + ".go": {"gofmt"}, + ".rs": {"rustfmt", "--emit", "stdout"}, + ".py": {"ruff", "format", "--quiet", "--stdin-filename", formatterPathPlaceholder, "-"}, + ".ts": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".tsx": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".js": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".jsx": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".json": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".css": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".scss": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".html": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".md": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".yaml": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".yml": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, + ".zig": {"zig", "fmt", "--stdin"}, + ".dart": {"dart", "format", "--output=show", "--stdin-name", formatterPathPlaceholder}, + ".tf": {"terraform", "fmt", "-"}, + ".gleam": {"gleam", "format", "--stdin"}, + ".sh": {"shfmt", "--filename", formatterPathPlaceholder}, + ".bash": {"shfmt", "--filename", formatterPathPlaceholder}, + ".c": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, + ".h": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, + ".cpp": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, + ".hpp": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, + ".cc": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, + ".kt": {"ktlint", "--format", "--stdin", "--stdin-path", formatterPathPlaceholder, "--log-level=none"}, + ".swift": {"swiftformat", "--stdinpath", formatterPathPlaceholder}, + ".lua": {"stylua", "--stdin-filepath", formatterPathPlaceholder, "-"}, } // formatOnWriteEnabled reports whether the opt-in env toggle is set. @@ -123,32 +110,12 @@ func formatOnWriteEnabled() bool { return value != "" && value != "0" && !strings.EqualFold(value, "false") } -var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, arguments []string, directory string) error { - formatter := exec.CommandContext(ctx, binaryPath, arguments...) - formatter.Dir = directory - formatter.Stdin = strings.NewReader("") - return formatter.Run() -} - -var readFormattedFile = readRootedFile - -// maybeFormatWrittenFile is the unscoped test-facing wrapper. Production -// callers use maybeFormatWrittenFileScoped so a formatter cannot redirect the -// final read or recovery write outside the configured write roots. -func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) formatOnWriteResult { - restoreMode := os.FileMode(0o644) - if info, err := os.Stat(absolutePath); err == nil { - restoreMode = info.Mode().Perm() - } - return maybeFormatWrittenFileScoped(ctx, filepath.Dir(absolutePath), nil, absolutePath, writtenContent, restoreMode) -} - -// maybeFormatWrittenFileScoped runs the configured formatter and returns only -// content verified through a descriptor-bound root. Formatter failures restore -// writtenContent through that same root; if restoration or the final read -// fails, ContentKnown is false and callers omit exact diff evidence. -func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, scope PathScope, absolutePath string, writtenContent string, restoreMode os.FileMode) formatOnWriteResult { - unformatted := formatOnWriteResult{Content: writtenContent, ContentKnown: true} +// maybeFormatWrittenFile formats writtenContent over stdin and publishes stdout +// through the rooted, credential-checked write path. The destination pathname is +// passed only through each formatter's filename-hint option so project settings +// resolve from the same ancestors as the real file. +func maybeFormatWrittenFile(ctx context.Context, root *os.Root, relativePath, absolutePath, workspaceRoot, writtenContent string, mode os.FileMode) formatOnWriteResult { + unformatted := formatOnWriteResult{Content: writtenContent} if !formatOnWriteEnabled() { return unformatted } @@ -160,90 +127,40 @@ func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, sco if err != nil { return unformatted } - root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath) - if err != nil { - unformatted.ContentKnown = false - return unformatted + arguments := make([]string, len(command)-1) + for index, argument := range command[1:] { + arguments[index] = strings.ReplaceAll(argument, formatterPathPlaceholder, absolutePath) } - defer root.Close() - formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout) defer cancel() - arguments := append(append([]string(nil), command[1:]...), absolutePath) - if err := runFormatOnWriteCommand(formatCtx, binaryPath, arguments, filepath.Dir(absolutePath)); err != nil { + formatter := exec.CommandContext(formatCtx, binaryPath, arguments...) + // Stdout is a pipe, so Run waits for every holder of it, not only the + // process the deadline kills. A formatter launched through a shim (every + // npm-installed one on Windows is a .cmd) leaves the real formatter holding + // that pipe after the shim dies. Kill the whole tree and bound the wait. + hardenProcessLifetime(formatter) + formatter.Dir = filepath.Dir(absolutePath) + formatter.Stdin = strings.NewReader(writtenContent) + var stdout bytes.Buffer + formatter.Stdout = &stdout + formatter.Env = sandbox.ScrubSensitiveEnv(os.Environ()) + if err := formatter.Run(); err != nil { unformatted.Formatter = command[0] - if restoreErr := restoreFormattedFile(root, relativePath, writtenContent, restoreMode); restoreErr != nil { - unformatted.RestoreFailed = true - unformatted.ContentKnown = false - } else if restored, info, readErr := readFormattedFile(root, relativePath); readErr != nil { - unformatted.ContentKnown = false - } else { - unformatted.Content = string(restored) - unformatted.Info = info - } if errors.Is(formatCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil { unformatted.TimedOut = true } return unformatted } - - formatted, info, err := readFormattedFile(root, relativePath) - if err != nil { - unformatted.ContentKnown = false + formatted := stdout.Bytes() + // A formatter that declines a file can answer with silence and exit 0: + // clang-format does for a path matched by .clang-format-ignore. Publishing + // that would empty the file while the write reports success. Nothing went + // wrong, so there is no notice; the written bytes simply stand. + if len(bytes.TrimSpace(formatted)) == 0 && strings.TrimSpace(writtenContent) != "" { return unformatted } - return formatOnWriteResult{Content: string(formatted), ContentKnown: true, Info: info, Formatter: command[0]} -} - -func restoreFormattedFile(root *os.Root, relativePath string, content string, mode os.FileMode) error { - file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode) - if err != nil { - return err - } - if _, err := io.WriteString(file, content); err != nil { - _ = file.Close() - return err - } - 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) - 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 - } - 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 - } - root, err := os.OpenRoot(resolvedRoot) - if err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - return root, relativePath, nil - } - if firstErr != nil { - return nil, "", firstErr + if _, err := writeRootedFile(root, relativePath, absolutePath, workspaceRoot, formatted, mode, false); err != nil { + return unformatted } - return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath) + return formatOnWriteResult{Content: string(formatted), Formatter: command[0]} } diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index 2750b037a..d24fb0d6a 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -2,10 +2,12 @@ package tools import ( "context" + "io" "os" "os/exec" "path/filepath" "runtime" + "slices" "strings" "testing" ) @@ -19,6 +21,74 @@ func requireGofmt(t *testing.T) { } } +// Preserve upstream's verified FileDiff/tracker contract across the PR's +// stdin/stdout formatter boundary. A formatter's reported Content is not proof +// of the on-disk bytes; both mutation tools must inspect the rooted final file. +func TestPostWriteEvidenceUsesVerifiedFinalFile(t *testing.T) { + for _, name := range []string{"write", "edit"} { + for _, state := range []string{"replacement", "missing", "escaping symlink"} { + t.Run(name+"/"+state, func(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "a.go") + if err := os.WriteFile(target, []byte("before\n"), 0o600); err != nil { + t.Fatal(err) + } + tracker := NewFileTracker() + read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{"path": "a.go"}, RunOptions{FileTracker: tracker}) + if read.Status != StatusOK { + t.Fatal(read.Output) + } + formatter := func(context.Context, *os.Root, string, string, string, string, os.FileMode) formatOnWriteResult { + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + switch state { + case "replacement": + if err := os.WriteFile(target, []byte("formatted\n"), 0o600); err != nil { + t.Fatal(err) + } + case "escaping symlink": + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("external\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, target); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + return formatOnWriteResult{Content: "unverified formatter claim"} + } + diagnosticsCalled := false + options := RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string { + diagnosticsCalled = true + return "" + }} + var result Result + if name == "write" { + tool := NewScopedWriteFileTool(root, nil).(writeFileTool) + tool.formatter = formatter + result = tool.RunWithOptions(context.Background(), map[string]any{"path": "a.go", "content": "requested\n", "overwrite": true}, options) + } else { + tool := NewScopedEditFileTool(root, nil).(editFileTool) + tool.formatter = formatter + result = tool.RunWithOptions(context.Background(), map[string]any{"path": "a.go", "old_string": "before", "new_string": "requested"}, options) + } + version, tracked := tracker.Version(target) + if state == "replacement" { + if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].OldText != "before\n" || result.FileDiffs[0].NewText != "formatted\n" || !tracked || version.Hash != HashContent([]byte("formatted\n")) || !diagnosticsCalled { + t.Fatalf("verified final file not used: %+v, tracked=%v", result, tracked) + } + } else if result.Status != StatusError || len(result.FileDiffs) != 0 || result.Display.Preview != "" || tracked || diagnosticsCalled { + t.Fatalf("unverified final file reached consumers: %+v, tracked=%v diagnostics=%v", result, tracked, diagnosticsCalled) + } + }) + } + } +} + func TestFormatOnWriteDisabledByDefault(t *testing.T) { requireGofmt(t) t.Setenv("ZERO_FORMAT_ON_WRITE", "") @@ -96,10 +166,43 @@ func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) { } } +func TestProtectedCredentialDoesNotDisableFormatOnWriteForOtherFiles(t *testing.T) { + requireGofmt(t) + dir := t.TempDir() + token := filepath.Join(dir, "bridge-token") + if err := os.WriteFile(token, []byte("secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + + result := NewScopedWriteFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "ordinary.go", "content": "package ordinary\n\nfunc F( ) { }\n", + }, RunOptions{}) + if result.Status != StatusOK { + t.Fatalf("write failed: %q", result.Output) + } + content, err := os.ReadFile(filepath.Join(dir, "ordinary.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "func F() {") { + t.Fatalf("formatting was suppressed by unrelated token: %q", content) + } +} + func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) { t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - formatting := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text") - if formatting.Content != "raw text" || !formatting.ContentKnown { + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + formatting := maybeFormatWrittenFile(context.Background(), root, "notes.xyz", filepath.Join(dir, "notes.xyz"), dir, "raw text", 0o644) + if formatting.Content != "raw text" { t.Fatalf("unknown extension must pass through: %q", formatting.Content) } if notice := formatting.notice("notes.xyz"); notice != "" { @@ -115,8 +218,13 @@ func TestFormatOnWriteFormatterLookupFailure(t *testing.T) { if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil { t.Fatal(err) } - formatting := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent) - if formatting.Content != uglyContent || !formatting.ContentKnown { + root, err := os.OpenRoot(filepath.Dir(targetPath)) + if err != nil { + t.Fatal(err) + } + defer root.Close() + formatting := maybeFormatWrittenFile(context.Background(), root, filepath.Base(targetPath), targetPath, filepath.Dir(targetPath), uglyContent, 0o644) + if formatting.Content != uglyContent { t.Fatalf("missing formatter must return written content, got %q", formatting.Content) } if notice := formatting.notice("a.go"); notice != "" { @@ -124,410 +232,357 @@ func TestFormatOnWriteFormatterLookupFailure(t *testing.T) { } } -func TestFormatOnWriteReadsMutatedFileAfterFormatterFailure(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - targetPath := filepath.Join(t.TempDir(), "a.go") - if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil { +func TestFormatOnWriteUsesStdinAndScrubsSensitiveEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX formatter fixture") + } + dir := t.TempDir() + formatter := filepath.Join(dir, "zero-test-formatter") + script := `#!/bin/sh +[ "$#" -eq 0 ] || exit 20 +if [ -n "$ZERO_DAEMON_REMOTE_TOKEN" ] || [ -n "$ZERO_DAEMON_REMOTE_TOKEN_FILE" ] || [ -n "$ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED" ] || [ -n "$ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY" ]; then exit 21; fi +[ "$FORMATTER_POSITIVE_CONTROL" = visible ] || exit 22 +[ "$(cat)" = raw ] || exit 23 +printf 'formatted\n' +` + if err := os.WriteFile(formatter, []byte(script), 0o755); err != nil { t.Fatal(err) } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error { - if err := os.WriteFile(targetPath, []byte("formatter-mutated"), 0o644); err != nil { - t.Fatal(err) + old, existed := formatterCommands[".mock"] + formatterCommands[".mock"] = []string{formatter} + defer func() { + if existed { + formatterCommands[".mock"] = old + } else { + delete(formatterCommands, ".mock") } - return exec.ErrNotFound - } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - - formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested") - if !formatting.ContentKnown || formatting.Content != "requested" { - t.Fatalf("formatter failure content = %q, known=%t", formatting.Content, formatting.ContentKnown) - } -} - -func TestFormatOnWriteReportsWhenFormatterFailureCannotBeRestored(t *testing.T) { - requireGofmt(t) + }() t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - targetPath := filepath.Join(t.TempDir(), "a.go") - if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil { + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "inline-secret") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", "/secret/token") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "/secret/resolved") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY", "startup-identity") + t.Setenv("FORMATTER_POSITIVE_CONTROL", "visible") + target := filepath.Join(dir, "target.mock") + t.Setenv("FORMATTER_ORIGINAL_TARGET", target) + if err := os.WriteFile(target, []byte("raw\n"), 0o644); err != nil { t.Fatal(err) } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error { - if err := os.Remove(targetPath); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(targetPath, 0o755); err != nil { - t.Fatal(err) - } - return exec.ErrNotFound + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - - formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested") - if !formatting.RestoreFailed { - t.Fatal("failed recovery was not reported") + defer root.Close() + formatting := maybeFormatWrittenFile(context.Background(), root, "target.mock", target, dir, "raw\n", 0o644) + if formatting.Content != "formatted\n" { + t.Fatalf("stdin formatter result = %q, want formatted content", formatting.Content) } - if formatting.ContentKnown { - t.Fatalf("failed recovery reported known content %q", formatting.Content) + onDisk, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) } - if notice := formatting.notice("a.go"); !strings.Contains(notice, "WARNING: a.go may not hold what was written") { - t.Fatalf("failed recovery notice = %q", notice) + if string(onDisk) != formatting.Content { + t.Fatalf("published content = %q, want %q", onDisk, formatting.Content) } } -func TestWriteFileRecreatesFormatterDeletedTargetWithOriginalMode(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - for _, tc := range []struct { - name string - existing bool - wantMode os.FileMode - overwrite bool - }{ - {name: "create", wantMode: 0o644}, - {name: "overwrite", existing: true, wantMode: 0o751, overwrite: true}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - if tc.existing { - if err := os.WriteFile(targetPath, []byte("before\n"), tc.wantMode); err != nil { - t.Fatal(err) - } - } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error { - if err := os.Remove(targetPath); err != nil { - t.Fatal(err) - } - return exec.ErrNotFound +// Keep the real argv and replace only the executable with a deterministic CLI +// contract fixture. Replacing the entire command would hide broken production +// flags (lint-only Kotlin can succeed with empty stdout; Dart rejects a dash). +func TestFormatOnWriteStdinCommandContracts(t *testing.T) { + for _, extension := range []string{".kt", ".dart"} { + t.Run(extension, func(t *testing.T) { + // Tools pass the physical destination, including when macOS's temp + // directory (or the caller's TMPDIR) contains a symlink prefix. + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - - result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{ - "path": "a.go", "content": "requested\n", "overwrite": tc.overwrite, + target := filepath.Join(dir, "source file"+extension) + command := formatterCommands[extension] + fixture := []string{os.Args[0], "-test.run=^TestFormatOnWriteStdinContractHelper$", "--", command[0]} + formatterCommands[extension] = append(fixture, command[1:]...) + t.Cleanup(func() { formatterCommands[extension] = command }) + t.Setenv("ZERO_FORMATTER_CONTRACT_TARGET", target) + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + result := NewScopedWriteFileTool(dir, nil).Run(context.Background(), map[string]any{ + "path": filepath.Base(target), "content": "unformatted source\n", }) if result.Status != StatusOK { - t.Fatalf("write status = %s: %s", result.Status, result.Output) + t.Fatalf("write failed: %q", result.Output) } - content, err := os.ReadFile(targetPath) - if err != nil || string(content) != "requested\n" { - t.Fatalf("recreated content = %q, err=%v", content, err) + content, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) } - if runtime.GOOS != "windows" { - info, err := os.Stat(targetPath) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != tc.wantMode { - t.Fatalf("recreated mode = %o, want %o", got, tc.wantMode) - } + if string(content) != "formatted source\n" { + t.Fatalf("stdin contract did not publish non-empty formatted source: got %q", content) } }) } } -func TestFormatOnWriteMarksUnreadableFinalStateUnknown(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - targetPath := filepath.Join(t.TempDir(), "a.go") - if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil { - t.Fatal(err) - } - priorRunner := runFormatOnWriteCommand - priorReader := readFormattedFile - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil } - readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission } - t.Cleanup(func() { - runFormatOnWriteCommand = priorRunner - readFormattedFile = priorReader - }) - - formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested") - if formatting.ContentKnown || formatting.Content != "requested" { - t.Fatalf("unreadable formatter result = %q, known=%t", formatting.Content, formatting.ContentKnown) - } -} - -func TestWriteFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error { - if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil { - t.Fatal(err) +func TestFormatOnWriteStdinContractHelper(t *testing.T) { + target := os.Getenv("ZERO_FORMATTER_CONTRACT_TARGET") + if target == "" { + return + } + separator := slices.Index(os.Args, "--") + if separator < 0 || separator+1 >= len(os.Args) { + os.Exit(2) + } + arguments := os.Args[separator+1:] + var want []string + switch arguments[0] { + case "ktlint": + // Lint-only mode succeeds without emitting source for valid Kotlin. + if slices.Equal(arguments, []string{"ktlint", "--stdin"}) { + os.Exit(0) } - return exec.ErrNotFound + want = []string{"ktlint", "--format", "--stdin", "--stdin-path", target, "--log-level=none"} + case "dart": + // Dart uses stdin only when there are no positional paths. + want = []string{"dart", "format", "--output=show", "--stdin-name", target} + default: + os.Exit(3) } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - - result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{ - "path": "a.go", "content": "requested\n", - }) - if result.Status != StatusOK { - t.Fatalf("write status = %s: %s", result.Status, result.Output) + if !slices.Equal(arguments, want) { + os.Exit(4) } - if got := result.FileDiffs; len(got) != 1 || got[0].NewText != "requested\n" { - t.Fatalf("formatter-failure FileDiff = %#v", got) - } -} - -func TestEditFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil { - t.Fatal(err) - } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error { - if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil { - t.Fatal(err) - } - return exec.ErrNotFound - } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - - result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{ - "path": "a.go", "old_string": "before", "new_string": "requested", - }) - if result.Status != StatusOK { - t.Fatalf("edit status = %s: %s", result.Status, result.Output) + content, err := io.ReadAll(os.Stdin) + if err != nil || string(content) != "unformatted source\n" { + os.Exit(5) } - if got := result.FileDiffs; len(got) != 1 || got[0].OldText != "before\n" || got[0].NewText != "requested\n" { - t.Fatalf("formatter-failure edit FileDiff = %#v", got) + if _, err := io.WriteString(os.Stdout, "formatted source\n"); err != nil { + os.Exit(6) } + os.Exit(0) } -func TestWriteFileOmitsRichDiffWhenFormatterFinalReadFails(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - priorRunner := runFormatOnWriteCommand - priorReader := readFormattedFile - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil } - readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission } - t.Cleanup(func() { - runFormatOnWriteCommand = priorRunner - readFormattedFile = priorReader - }) - - result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{ - "path": "a.go", "content": "requested\n", - }) - if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 { - t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs) - } - if result.Display.Preview != "" { - t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview) +func TestFormatOnWriteUsesDestinationForProjectConfiguration(t *testing.T) { + dir := t.TempDir() + sourceDir := filepath.Join(dir, "src") + if err := os.Mkdir(sourceDir, 0o755); err != nil { + t.Fatal(err) } -} - -func TestEditFileOmitsPreviewWhenFormatterFinalReadFails(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, ".clang-format"), []byte("IndentWidth: 8\n"), 0o644); err != nil { t.Fatal(err) } - priorRunner := runFormatOnWriteCommand - priorReader := readFormattedFile - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil } - readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission } - t.Cleanup(func() { - runFormatOnWriteCommand = priorRunner - readFormattedFile = priorReader - }) + previous := formatterCommands[".c"] + formatterCommands[".c"] = []string{os.Args[0], "-test.run=^TestFormatOnWriteProjectConfigHelper$", "--", "--assume-filename=" + formatterPathPlaceholder} + t.Cleanup(func() { formatterCommands[".c"] = previous }) + t.Setenv("ZERO_FORMATTER_CONFIG_HELPER", "1") + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{ - "path": "a.go", "old_string": "before", "new_string": "requested", - }) - if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 { - t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs) - } - if result.Display.Preview != "" { - t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview) + result := NewScopedWriteFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "src/main.c", "content": "int main() {\n return 0;\n}\n", + }, RunOptions{}) + if result.Status != StatusOK { + t.Fatalf("write failed: %q", result.Output) } -} - -func TestWriteFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - resolvedRoot, err := filepath.EvalSymlinks(root) + content, err := os.ReadFile(filepath.Join(sourceDir, "main.c")) if err != nil { t.Fatal(err) } - trackedPath := filepath.Join(resolvedRoot, "a.go") - outsidePath := filepath.Join(t.TempDir(), "outside.go") - outsideContent := "package external\n\nconst Secret = \"outside\"\n" - if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil { - t.Fatal(err) - } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { - if err := os.Remove(targetPath); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outsidePath, targetPath); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - return nil - } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - tracker := NewFileTracker() - - diagnosticsCalled := false - result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", "content": "package requested\n", - }, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string { - diagnosticsCalled = true - return "must not run" - }}) - if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" { - t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output) - } - if diagnosticsCalled { - t.Fatal("diagnostics must not inspect an unverified formatter target") - } - if _, tracked := tracker.Version(trackedPath); tracked { - t.Fatal("out-of-root formatter target must not be recorded in the tracker") + if !strings.Contains(string(content), "\n return 0;") { + t.Fatalf("formatter did not discover project config from destination hint: %q", content) } } -func TestEditFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - resolvedRoot, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) +func TestFormatOnWriteProjectConfigHelper(t *testing.T) { + if os.Getenv("ZERO_FORMATTER_CONFIG_HELPER") != "1" { + return } - trackedPath := filepath.Join(resolvedRoot, "a.go") - if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil { - t.Fatal(err) + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } } - outsidePath := filepath.Join(t.TempDir(), "outside.go") - outsideContent := "package external\n\nconst Secret = \"outside\"\n" - if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil { - t.Fatal(err) + if separator < 0 || separator+1 >= len(os.Args) { + os.Exit(2) } - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { - if err := os.Remove(targetPath); err != nil { - t.Fatal(err) + filename := strings.TrimPrefix(os.Args[separator+1], "--assume-filename=") + configured := false + for directory := filepath.Dir(filename); ; directory = filepath.Dir(directory) { + if data, err := os.ReadFile(filepath.Join(directory, ".clang-format")); err == nil && strings.Contains(string(data), "IndentWidth: 8") { + configured = true + break } - if err := os.Symlink(outsidePath, targetPath); err != nil { - t.Skipf("symlinks unavailable: %v", err) + parent := filepath.Dir(directory) + if parent == directory { + break } - return nil } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - tracker := NewFileTracker() - read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", - }, RunOptions{FileTracker: tracker}) - if read.Status != StatusOK { - t.Fatalf("read before edit failed: %s", read.Output) - } - - diagnosticsCalled := false - result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", "old_string": "before", "new_string": "requested", - }, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string { - diagnosticsCalled = true - return "must not run" - }}) - if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" { - t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output) - } - if diagnosticsCalled { - t.Fatal("diagnostics must not inspect an unverified formatter target") - } - if _, tracked := tracker.Version(trackedPath); tracked { - t.Fatal("out-of-root formatter target must not be recorded in the tracker") + if !configured { + os.Exit(3) } -} - -func TestWriteFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - formatted := "package formatted\n" - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { - tempPath := filepath.Join(root, "formatter.tmp") - if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil { - t.Fatal(err) - } - return os.Rename(tempPath, targetPath) - } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - tracker := NewFileTracker() - - result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", "content": "package requested\n", - }, RunOptions{FileTracker: tracker}) - if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted { - t.Fatalf("atomic formatter write = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output) - } - resolvedRoot, err := filepath.EvalSymlinks(root) + content, err := io.ReadAll(os.Stdin) if err != nil { - t.Fatal(err) + os.Exit(4) } - version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go")) - if !tracked || version.Hash != HashContent([]byte(formatted)) { - t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked) + formatted := strings.ReplaceAll(string(content), "\n return", "\n return") + if _, err := io.WriteString(os.Stdout, formatted); err != nil { + os.Exit(5) } + os.Exit(0) } -func TestEditFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) { - requireGofmt(t) - t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - root := t.TempDir() - targetPath := filepath.Join(root, "a.go") - if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil { - t.Fatal(err) - } - formatted := "package formatted\n" - priorRunner := runFormatOnWriteCommand - runFormatOnWriteCommand = func(context.Context, string, []string, string) error { - tempPath := filepath.Join(root, "formatter.tmp") - if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil { - t.Fatal(err) - } - return os.Rename(tempPath, targetPath) +func TestFormatOnWriteRejectsDestinationSwapDuringFormatter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX formatter fixture") } - t.Cleanup(func() { runFormatOnWriteCommand = priorRunner }) - tracker := NewFileTracker() - read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", - }, RunOptions{FileTracker: tracker}) - if read.Status != StatusOK { - t.Fatalf("read before edit failed: %s", read.Output) + for _, kind := range []string{"control", "symlink", "hardlink"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + token, target := filepath.Join(dir, "token"), filepath.Join(dir, "ordinary.mock") + for path, content := range map[string]string{token: "secret", target: "raw"} { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + helper := filepath.Join(dir, "formatter") + script := `#!/bin/sh +set -eu +case "$3" in +symlink) rm "$1"; ln -s "$2" "$1";; +hardlink) rm "$1"; ln "$2" "$1";; +esac +cat >/dev/null +printf formatted +` + if err := os.WriteFile(helper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + old, existed := formatterCommands[".mock"] + formatterCommands[".mock"] = []string{helper, target, token, kind} + t.Cleanup(func() { + if existed { + formatterCommands[".mock"] = old + } else { + delete(formatterCommands, ".mock") + } + }) + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY", "") + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + formatting := maybeFormatWrittenFile(context.Background(), root, "ordinary.mock", target, dir, "raw", 0o600) + want := "raw" + if kind == "control" { + want = "formatted" + } else if targetInfo, err := os.Stat(target); err != nil { + t.Fatal(err) + } else if tokenInfo, err := os.Stat(token); err != nil || !os.SameFile(targetInfo, tokenInfo) { + t.Fatalf("formatter did not exercise alias swap: %v", err) + } + if formatting.Content != want { + t.Fatalf("formatter result = %q, want %q", formatting.Content, want) + } + if data, err := os.ReadFile(token); err != nil || string(data) != "secret" { + t.Fatalf("formatter changed token: %q, %v", data, err) + } + }) } +} - result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ - "path": "a.go", "old_string": "before", "new_string": "requested", - }, RunOptions{FileTracker: tracker}) - if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted { - t.Fatalf("atomic formatter edit = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output) - } - resolvedRoot, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go")) - if !tracked || version.Hash != HashContent([]byte(formatted)) { - t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked) +func TestPostWriteFormatterSwapCannotPublishOrObserveToken(t *testing.T) { + for _, toolName := range []string{"write", "edit"} { + for _, aliasKind := range []string{"symlink", "hardlink"} { + t.Run(toolName+"/"+aliasKind, func(t *testing.T) { + // The tools key tracker entries by the physical path. Match that + // spelling when TempDir is aliased (macOS /var -> /private/var). + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + token := filepath.Join(dir, "bridge-token") + target := filepath.Join(dir, "ordinary.go") + const secret = "formatter-swap-secret" + if err := os.WriteFile(token, []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + if toolName == "edit" { + if err := os.WriteFile(target, []byte("package ordinary\n\nfunc Old() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + } + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + tracker := NewFileTracker() + if toolName == "edit" { + initial := []byte("package ordinary\n\nfunc Old() {}\n") + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + tracker.Record(target, initial, info) + tracker.RecordSeenRange(target, 1, 3, 3) + } + diagnosticsCalled := false + options := RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string { + diagnosticsCalled = true + return secret + }} + var result Result + formatterCalled := false + formatter := func(_ context.Context, _ *os.Root, _, _, _, written string, _ os.FileMode) formatOnWriteResult { + formatterCalled = true + // The injected formatter boundary is entered only after the rooted + // write and returns immediately before publication/post-write read. + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + var err error + if aliasKind == "symlink" { + err = os.Symlink(token, target) + } else { + err = os.Link(token, target) + } + if err != nil { + t.Skipf("%s unavailable: %v", aliasKind, err) + } + return formatOnWriteResult{Content: written} + } + if toolName == "write" { + tool := NewScopedWriteFileTool(dir, nil).(writeFileTool) + tool.formatter = formatter + result = tool.RunWithOptions(context.Background(), map[string]any{ + "path": "ordinary.go", "content": "package ordinary\n\nfunc F( ) { }\n", + }, options) + } else { + tool := NewScopedEditFileTool(dir, nil).(editFileTool) + tool.formatter = formatter + result = tool.RunWithOptions(context.Background(), map[string]any{ + "path": "ordinary.go", "old_string": "Old", "new_string": "F", + }, options) + } + if !formatterCalled { + t.Fatalf("tool refused before exercising the formatter swap: %s", result.Output) + } + if result.Status != StatusError { + t.Fatalf("swapped %s result status = %s, want error: %q", aliasKind, result.Status, result.Output) + } + if diagnosticsCalled || strings.Contains(result.Output, secret) || strings.Contains(result.Display.Preview, secret) { + t.Fatalf("token reached a post-write consumer: diagnostics=%v output=%q preview=%q", diagnosticsCalled, result.Output, result.Display.Preview) + } + if _, tracked := tracker.Version(target); tracked { + t.Fatal("unverified final target must be forgotten by FileTracker") + } + got, err := os.ReadFile(token) + if err != nil || string(got) != secret { + t.Fatalf("token changed: content=%q err=%v", got, err) + } + }) + } } } diff --git a/internal/tools/format_on_write_timeout_test.go b/internal/tools/format_on_write_timeout_test.go index b66fbba70..74d651d01 100644 --- a/internal/tools/format_on_write_timeout_test.go +++ b/internal/tools/format_on_write_timeout_test.go @@ -3,7 +3,6 @@ package tools import ( "context" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -38,7 +37,7 @@ func TestFormatOnWriteReportsATimeout(t *testing.T) { t.Fatal(err) } - formatting := maybeFormatWrittenFile(context.Background(), target, written) + formatting := runTestFormatter(t, context.Background(), target, written) if formatting.Content != written { t.Fatalf("content = %q, want the bytes that were written", formatting.Content) @@ -66,7 +65,7 @@ func TestFormatOnWriteStaysQuietWhenTheFormatterFinishes(t *testing.T) { t.Fatal(err) } - formatting := maybeFormatWrittenFile(context.Background(), target, written) + formatting := runTestFormatter(t, context.Background(), target, written) if formatting.TimedOut { t.Error("a formatter that finished was reported as timed out") } @@ -90,7 +89,7 @@ func TestFormatOnWriteStaysQuietWhenTheCallerCancels(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - formatting := maybeFormatWrittenFile(ctx, target, written) + formatting := runTestFormatter(t, ctx, target, written) if formatting.Content != written { t.Fatalf("content = %q, want the bytes that were written", formatting.Content) @@ -116,7 +115,7 @@ func TestFormatOnWriteStaysQuietWhenTheFormatterFails(t *testing.T) { t.Fatal(err) } - formatting := maybeFormatWrittenFile(context.Background(), target, written) + formatting := runTestFormatter(t, context.Background(), target, written) if formatting.TimedOut { t.Error("a formatter that exited non-zero was reported as a timeout") } @@ -148,6 +147,16 @@ func installFakeFormatter(t *testing.T, extension, name, script string) string { return binaryName } +func runTestFormatter(t *testing.T, ctx context.Context, target, written string) formatOnWriteResult { + t.Helper() + root, err := os.OpenRoot(filepath.Dir(target)) + if err != nil { + t.Fatal(err) + } + defer root.Close() + return maybeFormatWrittenFile(ctx, root, filepath.Base(target), target, filepath.Dir(target), written, 0o644) +} + func formatterScriptExtension() string { if runtime.GOOS == "windows" { return ".bat" @@ -157,9 +166,9 @@ func formatterScriptExtension() string { func succeedingFormatterScript() string { if runtime.GOOS == "windows" { - return "@echo off\r\nexit /b 0\r\n" + return "@echo off\r\nmore\r\nexit /b 0\r\n" } - return "#!/bin/sh\nexit 0\n" + return "#!/bin/sh\ncat\n" } func failingFormatterScript() string { @@ -177,16 +186,9 @@ func shortenFormatOnWriteTimeout(t *testing.T, timeout time.Duration) { t.Cleanup(func() { formatOnWriteTimeout = previous }) } -// A FAILED FORMATTER MUST NOT LEAVE THE FILE HALF-REWRITTEN. -// -// These commands edit in place, so one killed by the deadline, killed by the -// caller, or exiting partway through its own rewrite can leave the target -// truncated: neither the input nor the output. Returning the written bytes on -// top of that would leave the tracker baseline and the diff preview describing -// a file that is not on disk, and the next edit would compare against content -// the file does not have. -func TestFormatOnWriteRestoresTheFileWhenTheFormatterFails(t *testing.T) { - installFakeFormatter(t, ".clobberfmt", "clobberfmt", clobberingFormatterScript()) +// A formatter that fails after emitting partial stdout must not alter the file. +func TestFormatOnWriteDoesNotPublishOutputFromFailedFormatter(t *testing.T) { + installFakeFormatter(t, ".clobberfmt", "clobberfmt", partialOutputFormatterScript()) t.Setenv("ZERO_FORMAT_ON_WRITE", "1") target := filepath.Join(t.TempDir(), "subject.clobberfmt") @@ -194,87 +196,95 @@ func TestFormatOnWriteRestoresTheFileWhenTheFormatterFails(t *testing.T) { if err := os.WriteFile(target, []byte(written), 0o644); err != nil { t.Fatal(err) } - requireFormatterClobbers(t, written) - - formatting := maybeFormatWrittenFile(context.Background(), target, written) - + formatting := runTestFormatter(t, context.Background(), target, written) onDisk, err := os.ReadFile(target) if err != nil { t.Fatal(err) } - if string(onDisk) != written { - t.Errorf("file on disk = %q, want the bytes that were written back", onDisk) - } - if formatting.Content != written { - t.Errorf("content = %q, want the bytes that were written", formatting.Content) + if string(onDisk) != written || formatting.Content != written { + t.Errorf("failed formatter changed content: disk=%q result=%q", onDisk, formatting.Content) } - if formatting.RestoreFailed { - t.Error("restoration was reported as failed on a writable file") +} + +func partialOutputFormatterScript() string { + if runtime.GOOS == "windows" { + return "@echo off\r\necho CLOBBERED\r\nexit /b 3\r\n" } + return "#!/bin/sh\necho CLOBBERED\nexit 3\n" } -// And the same for a run cut off by the deadline, which is the case the notice -// already covers: the disclosure and the file have to agree. -func TestFormatOnWriteRestoresTheFileOnTimeout(t *testing.T) { - installFakeFormatter(t, ".clobberfmt", "clobberfmt", clobberingFormatterScript()) - shortenFormatOnWriteTimeout(t, time.Nanosecond) +// A formatter that declines a file with empty stdout and exit 0 must leave the +// written bytes alone. clang-format answers that way over stdin for a path its +// .clang-format-ignore matches; publishing the silence emptied the file while +// write_file reported success. +func TestFormatOnWriteKeepsContentWhenTheFormatterPrintsNothing(t *testing.T) { + installFakeFormatter(t, ".silentfmt", "silentfmt", silentFormatterScript()) t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - - target := filepath.Join(t.TempDir(), "subject.clobberfmt") - const written = "the bytes the caller wrote\n" - if err := os.WriteFile(target, []byte(written), 0o644); err != nil { + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { t.Fatal(err) } - requireFormatterClobbers(t, written) + const written = "int lib(void) { return 0; }\n" + for _, step := range []struct { + name string + overwrite bool + }{{"create", false}, {"overwrite", true}} { + t.Run(step.name, func(t *testing.T) { + result := NewScopedWriteFileTool(dir, nil).Run(context.Background(), map[string]any{ + "path": "lib.silentfmt", "content": written, "overwrite": step.overwrite, + }) + if result.Status != StatusOK { + t.Fatalf("write failed: %q", result.Output) + } + onDisk, err := os.ReadFile(filepath.Join(dir, "lib.silentfmt")) + if err != nil { + t.Fatal(err) + } + if string(onDisk) != written { + t.Fatalf("silent formatter replaced the file: disk=%q, want %q", onDisk, written) + } + }) + } +} - formatting := maybeFormatWrittenFile(context.Background(), target, written) +// The deadline must bound the call even when the formatter is a shim whose +// child keeps the stdout pipe open after the shim is killed. Every +// npm-installed formatter on Windows is a .cmd shim hosting node.exe. +func TestFormatOnWriteDeadlineBoundsAShimmedFormatter(t *testing.T) { + installFakeFormatter(t, ".shimfmt", "shimfmt", shimmedSlowFormatterScript()) + shortenFormatOnWriteTimeout(t, 500*time.Millisecond) + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") - onDisk, err := os.ReadFile(target) - if err != nil { + target := filepath.Join(t.TempDir(), "subject.shimfmt") + const written = "written\n" + if err := os.WriteFile(target, []byte(written), 0o644); err != nil { t.Fatal(err) } - if string(onDisk) != written { - t.Errorf("file on disk = %q, want the bytes that were written back", onDisk) - } - if !formatting.TimedOut { - t.Error("the deadline path stopped being reported once restoration was added") + started := time.Now() + formatting := runTestFormatter(t, context.Background(), target, written) + elapsed := time.Since(started) + + // The grandchild sleeps nine seconds. The deadline plus bashWaitDelay is + // well under five; waiting on the orphaned pipe is not. + if elapsed > 5*time.Second { + t.Fatalf("formatter call took %s; the deadline did not bound it", elapsed) } - if notice := formatting.notice("subject.clobberfmt"); !strings.Contains(notice, "not formatted") { - t.Errorf("notice = %q, want the timeout note", notice) + if !formatting.TimedOut || formatting.Content != written { + t.Fatalf("result = %+v, want the written content reported as a timeout", formatting) } } -// clobberingFormatterScript truncates the file it is handed and then fails, the -// way an interrupted in-place formatter leaves a partial rewrite. -func clobberingFormatterScript() string { +func silentFormatterScript() string { if runtime.GOOS == "windows" { - return "@echo off\r\necho CLOBBERED> %1\r\nexit /b 3\r\n" + return "@echo off\r\nmore >nul\r\nexit /b 0\r\n" } - return "#!/bin/sh\necho CLOBBERED > \"$1\"\nexit 3\n" + return "#!/bin/sh\ncat >/dev/null\nexit 0\n" } -// requireFormatterClobbers proves the fixture really does damage the file it is -// handed, on a throwaway copy. -// -// It cannot be checked on the real target: restoration is the behaviour under -// test, so when it works the evidence is gone, and asserting on the target -// afterwards would either pass vacuously or report the opposite of what it saw. -func requireFormatterClobbers(t *testing.T, written string) { - t.Helper() - probe := filepath.Join(t.TempDir(), "probe.clobberfmt") - if err := os.WriteFile(probe, []byte(written), 0o644); err != nil { - t.Fatal(err) - } - binary, err := exec.LookPath("clobberfmt" + formatterScriptExtension()) - if err != nil { - t.Fatalf("SETUP INVALID: the fake formatter is not on PATH: %v", err) - } - _ = exec.Command(binary, probe).Run() - after, err := os.ReadFile(probe) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(after), "CLOBBERED") { - t.Fatalf("SETUP INVALID: the fake formatter left %q, so it does not damage the file and restoration is not under test", after) +// The sleeping child inherits stdout, as node.exe does under a .cmd shim. +func shimmedSlowFormatterScript() string { + if runtime.GOOS == "windows" { + return "@echo off\r\nping -n 10 127.0.0.1\r\nexit /b 0\r\n" } + return "#!/bin/sh\nsleep 9\nexit 0\n" } diff --git a/internal/tools/glob.go b/internal/tools/glob.go index a54e2b687..19b5965f3 100644 --- a/internal/tools/glob.go +++ b/internal/tools/glob.go @@ -49,15 +49,14 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool { } func (tool globTool) Run(ctx context.Context, args map[string]any) Result { - return tool.runWith(ctx, args, readExcluder{}, true) + return tool.runWith(ctx, args, sandboxReadExcluderWithin(nil, tool.workspaceRoot), true) } +// RunWithOptions falls back to sandboxReadExcluderWithin, not a bare no-op +// excluder, when options.Sandbox is nil — see the matching comment on grepTool +// for why an engine-less call is a real production path this must still cover. func (tool globTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result { - exclude := readExcluder{} - if options.Sandbox != nil { - exclude = sandboxReadExcluder(options.Sandbox) - } - return tool.runWith(ctx, args, exclude, false) + return tool.runWith(ctx, args, sandboxReadExcluderWithin(options.Sandbox, tool.workspaceRoot), false) } // RunWithSandbox runs glob while skipping subtrees the sandbox policy denies diff --git a/internal/tools/grep.go b/internal/tools/grep.go index 7a1c9e47d..98a2ba5f1 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -63,15 +63,18 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool { } func (tool grepTool) Run(ctx context.Context, args map[string]any) Result { - return tool.runWith(ctx, args, readExcluder{}, true) + return tool.runWith(ctx, args, sandboxReadExcluderWithin(nil, tool.workspaceRoot), true) } +// RunWithOptions falls back to sandboxReadExcluderWithin rather than a bare +// no-op excluder when options.Sandbox is nil. Registry.Run funnels into this +// with an empty RunOptions, which is a real production path (MCP tool +// dispatch that predates a Sandbox engine, and any future caller of the plain +// registry API) — without the fallback, grep could return the bridge token's +// contents from a workspace search whenever no engine happened to be passed, +// even though list_directory's equivalent engine-less path already protects it. func (tool grepTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result { - exclude := readExcluder{} - if options.Sandbox != nil { - exclude = sandboxReadExcluder(options.Sandbox) - } - return tool.runWith(ctx, args, exclude, false) + return tool.runWith(ctx, args, sandboxReadExcluderWithin(options.Sandbox, tool.workspaceRoot), false) } // RunWithSandbox runs the search while skipping subtrees the sandbox policy @@ -350,7 +353,7 @@ func exactGrepLineMatcher(compiled *regexp.Regexp) grepLineMatcher { func scanGrepMatches(ctx context.Context, resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder, absolutePaths bool, matcher grepLineMatcher, emit func(grepMatch) bool) error { err := walkGrepFiles(ctx, resolvedRoot, target, globMatcher, exclude, func(file string) error { - return scanGrepFile(ctx, resolvedRoot, absolutePaths, file, matcher, emit) + return scanGrepFile(ctx, resolvedRoot, absolutePaths, file, exclude, matcher, emit) }) if errors.Is(err, errGrepLimitReached) { return nil @@ -358,7 +361,7 @@ func scanGrepMatches(ctx context.Context, resolvedRoot string, target string, gl return err } -func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool, file string, matcher grepLineMatcher, emit func(grepMatch) bool) error { +func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool, file string, exclude readExcluder, matcher grepLineMatcher, emit func(grepMatch) bool) error { // Re-confine at read time (defense-in-depth) AND to compute the clean // workspace-relative path used in output. relative, resolvedPath, ok := confineGrepFile(resolvedRoot, file) @@ -385,6 +388,17 @@ func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool, } defer handle.Close() + // The walk-time exclusion above inspected a PATHNAME; this open selects the + // object. A workspace writer can replace an ordinary candidate with a + // symlink or hard link to the protected credential in between, so the + // decision that actually authorizes the scan has to come from this handle's + // own metadata — the same binding protectedReadOpen and MCP resources/read + // use. The earlier check stays as a walk pruning optimization. + info, err := handle.Stat() + if err != nil || exclude.openedFileExcluded(resolvedPath, handle, info) { + return nil + } + reader := bufio.NewReader(handle) lineNumber := 1 sawLine := false diff --git a/internal/tools/inline_diagnostics_test.go b/internal/tools/inline_diagnostics_test.go index 062405bfa..e0d56dbbb 100644 --- a/internal/tools/inline_diagnostics_test.go +++ b/internal/tools/inline_diagnostics_test.go @@ -51,3 +51,31 @@ func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) { t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output) } } + +func TestProtectedCredentialDoesNotDisableInlineDiagnosticsForOtherFiles(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "bridge-token") + if err := os.WriteFile(token, []byte("secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + diagnostics := func(context.Context, string) string { return "ordinary-file diagnostic" } + + edit := NewScopedEditFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "a.go", "old_string": "package a", "new_string": "package b", + }, RunOptions{Diagnostics: diagnostics}) + if edit.Status != StatusOK || !strings.Contains(edit.Output, "ordinary-file diagnostic") { + t.Fatalf("edit diagnostics were suppressed by unrelated token: %+v", edit) + } + write := NewScopedWriteFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "b.go", "content": "package b\n", + }, RunOptions{Diagnostics: diagnostics}) + if write.Status != StatusOK || !strings.Contains(write.Output, "ordinary-file diagnostic") { + t.Fatalf("write diagnostics were suppressed by unrelated token: %+v", write) + } +} diff --git a/internal/tools/list_directory.go b/internal/tools/list_directory.go index dd764bd6f..c9354893d 100644 --- a/internal/tools/list_directory.go +++ b/internal/tools/list_directory.go @@ -46,14 +46,18 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool { } func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result { - return tool.run(args, true) + return tool.run(args, sandboxReadExcluderWithin(nil, tool.workspaceRoot), true) } -func (tool listDirectoryTool) RunWithOptions(_ context.Context, args map[string]any, _ RunOptions) Result { - return tool.run(args, false) +// Registry.Run funnels into RunWithOptions with empty options, so a nil engine +// here is the MCP / legacy path rather than a test shape. Listing a directory +// must not disclose the bridge token's filename merely because the caller +// arrived without a sandbox — see sandboxReadExcluderWithin. +func (tool listDirectoryTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result { + return tool.run(args, sandboxReadExcluderWithin(options.Sandbox, tool.workspaceRoot), false) } -func (tool listDirectoryTool) run(args map[string]any, directBudget bool) Result { +func (tool listDirectoryTool) run(args map[string]any, exclude readExcluder, directBudget bool) Result { // Optional with a "." default: treat an explicit empty path (a common // weak-model quirk) the same as the key being absent rather than erroring. requestedPath, err := aliasedStringArg(args, []string{"path", "directory", "dir"}, ".", false, true) @@ -80,7 +84,7 @@ func (tool listDirectoryTool) run(args map[string]any, directBudget bool) Result return errorResult("Error listing directory " + requestedPath + ": " + err.Error()) } - entries, err := listDirectoryEntries(absolutePath, 0, maxDepth) + entries, err := listDirectoryEntries(absolutePath, 0, maxDepth, exclude) if err != nil { return errorResult("Error listing directory " + relativePath + ": " + err.Error()) } @@ -95,7 +99,7 @@ func (tool listDirectoryTool) run(args map[string]any, directBudget bool) Result return result } -func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error) { +func listDirectoryEntries(path string, depth int, maxDepth int, exclude readExcluder) ([]string, error) { dirEntries, err := os.ReadDir(path) if err != nil { return nil, err @@ -112,18 +116,29 @@ func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error if entry.IsDir() && shouldSkipDirectory(entry.Name()) { continue } + entryPath := filepath.Join(path, entry.Name()) + if entry.IsDir() && exclude.dirExcluded(entryPath) { + continue + } indent := strings.Repeat(" ", depth) if entry.IsDir() { - results = append(results, indent+entry.Name()+"/") + // A denied directory with a nested AllowRead cannot be pruned, but the + // denied directory entry itself must still stay out of the listing. + if !exclude.fileExcluded(entryPath) { + results = append(results, indent+entry.Name()+"/") + } if depth < maxDepth { - children, err := listDirectoryEntries(filepath.Join(path, entry.Name()), depth+1, maxDepth) + children, err := listDirectoryEntries(entryPath, depth+1, maxDepth, exclude) if err == nil { results = append(results, children...) } } continue } + if exclude.fileExcluded(entryPath) { + continue + } results = append(results, fmt.Sprintf("%s%s", indent, entry.Name())) } diff --git a/internal/tools/lsp_navigate.go b/internal/tools/lsp_navigate.go index 8392fb35b..71c0567a6 100644 --- a/internal/tools/lsp_navigate.go +++ b/internal/tools/lsp_navigate.go @@ -3,7 +3,7 @@ package tools import ( "context" "fmt" - "os" + "io" "path/filepath" "strings" @@ -84,8 +84,8 @@ func (tool lspNavigateTool) Run(ctx context.Context, args map[string]any) Result return errorResult("Error: lsp_navigate " + scopeErr.Error()) } - text, readErr := readWorkspaceFile(absPath) - if readErr != nil && op != lsp.NavWorkspaceSymbol { + text, readErr := readWorkspaceFile(absPath, tool.workspaceRoot) + if readErr != nil { return errorResult("Error: lsp_navigate could not read " + relPath + ": " + readErr.Error()) } @@ -123,10 +123,15 @@ func (tool lspNavigateTool) Run(ctx context.Context, args map[string]any) Result return okResult(tool.formatResult(op, locations, symbols)) } -// readWorkspaceFile reads an already-confined absolute path (resolved by -// resolveScopedReadPath). Returns "" + error when the file can't be read. -func readWorkspaceFile(absPath string) (string, error) { - data, err := os.ReadFile(absPath) +// readWorkspaceFile binds the confined path and credential check to the handle +// supplying the text, rather than reopening an authorized pathname. +func readWorkspaceFile(absPath, workspaceRoot string) (string, error) { + file, _, err := ProtectedReadOpen(absPath, workspaceRoot) + if err != nil { + return "", err + } + defer file.Close() + data, err := io.ReadAll(file) if err != nil { return "", err } diff --git a/internal/tools/lsp_navigate_test.go b/internal/tools/lsp_navigate_test.go index 3a158e170..cac307c72 100644 --- a/internal/tools/lsp_navigate_test.go +++ b/internal/tools/lsp_navigate_test.go @@ -85,3 +85,44 @@ func TestLSPNavigateIsReadOnly(t *testing.T) { t.Fatalf("name = %q", tool.Name()) } } + +func TestReadWorkspaceFileRejectsPostResolutionSwap(t *testing.T) { + for _, kind := range []string{"hardlink", "escaping symlink", "ordinary"} { + t.Run(kind, func(t *testing.T) { + ws, token, _ := daemonTokenFixture(t) + path := filepath.Join(ws, "candidate") + if err := os.WriteFile(path, []byte("ordinary"), 0o600); err != nil { + t.Fatal(err) + } + resolved, _, err := resolveScopedReadPath(ws, nil, path) + if err != nil { + t.Fatal(err) + } + if kind != "ordinary" { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if kind == "hardlink" { + err = os.Link(token, path) + } else { + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil { + t.Fatal(err) + } + err = os.Symlink(outside, path) + } + if err != nil { + t.Skipf("alias unavailable: %v", err) + } + } + text, err := readWorkspaceFile(resolved, ws) + if kind == "ordinary" { + if err != nil || text != "ordinary" { + t.Fatalf("ordinary read: %q, %v", text, err) + } + } else if err == nil || text != "" { + t.Fatalf("post-resolution swap read: %q, %v", text, err) + } + }) + } +} diff --git a/internal/tools/mutation_targets.go b/internal/tools/mutation_targets.go index 0b352b8ca..bccd431ee 100644 --- a/internal/tools/mutation_targets.go +++ b/internal/tools/mutation_targets.go @@ -1,11 +1,13 @@ package tools -import "path/filepath" +import ( + "path/filepath" +) // MutationTargets returns the workspace-relative paths a tool call will write to, // so the session layer can snapshot their before-state for safe rewind. It is a -// pure helper (no I/O beyond path resolution) and returns nil for read-only tools -// and for bash (whose affected paths are not knowable before execution). +// helper and returns nil for read-only tools, protected credential targets, and +// bash (whose affected paths are not knowable before execution). func MutationTargets(workspaceRoot string, name string, args map[string]any) []string { switch name { case "write_file", "edit_file": @@ -16,8 +18,8 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s if err != nil { return nil } - _, relative, err := resolveWorkspaceTargetPath(workspaceRoot, path) - if err != nil { + absolute, relative, err := resolveWorkspaceTargetPath(workspaceRoot, path) + if err != nil || protectedMutationDenied(absolute, workspaceRoot) != nil { return nil } return []string{relative} @@ -27,6 +29,10 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s if err != nil { return nil } + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) + if err != nil { + return nil + } // Mirror apply_patch's cwd handling so the returned targets are // WORKSPACE-relative (cwd-prefixed) when cwd != ".". Without this, a // patch applied under a subdir would snapshot the wrong rewind path. @@ -38,30 +44,13 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s if err != nil { return nil } - if isStructuredPatch(patch) { - operations, err := parseStructuredPatch(patch) - if err != nil { - return nil - } - paths := structuredPatchOperationPaths(operations) - for _, path := range paths { - if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil { - return nil - } - } - return prefixPatchPaths(relativeRoot, paths) - } // Enforce the same workspace confinement apply_patch applies (against the // resolved apply dir), so a patch with a traversal path (../x) never yields // an out-of-workspace target. - if err := validatePatchPaths(applyRoot, patch); err != nil { - return nil - } - paths := changedFilesFromPatch(relativeRoot, patch) - if len(paths) == 0 { + if err := validatePatchPaths(applyRoot, prepared.paths); err != nil { return nil } - return paths + return prefixPatchPaths(relativeRoot, prepared.paths) default: return nil } diff --git a/internal/tools/mutation_targets_test.go b/internal/tools/mutation_targets_test.go index 771e761df..a2e144926 100644 --- a/internal/tools/mutation_targets_test.go +++ b/internal/tools/mutation_targets_test.go @@ -52,6 +52,23 @@ func TestMutationTargetsResolvesAliasKeys(t *testing.T) { } } +func TestMutationTargetsSkipsProtectedCredentialMutations(t *testing.T) { + root := t.TempDir() + token := filepath.Join(root, "bridge-token") + if err := os.WriteFile(token, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY", "") + for _, tool := range []string{"write_file", "edit_file"} { + if targets := MutationTargets(root, tool, map[string]any{"path": "bridge-token"}); len(targets) != 0 { + t.Fatalf("%s protected checkpoint targets = %v, want none", tool, targets) + } + } +} + func TestMutationTargetsRejectsEscapingPaths(t *testing.T) { root := t.TempDir() if got := MutationTargets(root, "write_file", map[string]any{"path": "../escape.txt", "content": "x"}); len(got) != 0 { @@ -116,3 +133,36 @@ func TestStripPatchPrefixStripsOnlyOne(t *testing.T) { t.Fatalf("expected [b/foo.txt], got %v", got) } } + +func TestApplyPatchRejectsDisagreeingExecutorOperationPaths(t *testing.T) { + for _, tc := range []struct { + name string + patch string + }{ + { + name: "delete header disagrees with diff git", + patch: "diff --git a/decoy.txt b/decoy.txt\n" + + "deleted file mode 100644\n" + + "--- a/secret.txt\n" + + "+++ /dev/null\n" + + "@@ -1 +0,0 @@\n" + + "-secret\n", + }, + { + name: "unmatched a b prefixes", + patch: "diff --git a/secret.txt secret.txt\n" + + "--- a/secret.txt\n" + + "+++ secret.txt\n" + + "@@ -1 +1 @@\n" + + "-secret\n" + + "+changed\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": tc.patch}) + if err == nil { + t.Fatalf("prepare patch = %#v, want disagreement error", prepared) + } + }) + } +} diff --git a/internal/tools/patch_header_bytes_test.go b/internal/tools/patch_header_bytes_test.go new file mode 100644 index 000000000..345f8d64c --- /dev/null +++ b/internal/tools/patch_header_bytes_test.go @@ -0,0 +1,231 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" + "github.com/Gitlawb/zero/internal/sandbox" +) + +// TestApplyPatchExecutesHeaderPathBytesVerbatim closes the parser/consumer gap +// between the layer that authorizes a patch and the layer that applies it. +// +// The executor parser supplies both the paths the sandbox authorizes and the +// operations that run. If either consumer trimmed those paths independently, a +// patch naming the unprotected sibling "bridge-token " could clear the gate and +// then mutate the protected "bridge-token" beside it. +// +// Each case therefore proves both halves at once: the whitespace-bearing name +// is a real, patchable file (the control effect lands on it, byte for byte), +// and the protected token file sitting next to it is untouched. +func TestApplyPatchExecutesHeaderPathBytesVerbatim(t *testing.T) { + const ( + tokenName = "bridge-token" + tokenContents = "bridge-secret\n" + siblingBefore = "sibling-original\n" + siblingAfter = "sibling-patched\n" + ) + + for _, tc := range []struct { + name string + // sibling is the unprotected file whose name differs from the token's + // only by whitespace the executor must not trim away. + sibling string + patch string + // moved, when set, is the name the patch renames or copies sibling to. + moved string + // sourceGone marks a rename, where the source must not survive. + sourceGone bool + }{ + { + name: "update with trailing space", + sibling: tokenName + " ", + patch: "--- a/bridge-token \n" + + "+++ b/bridge-token \n" + + "@@ -1 +1 @@\n" + + "-sibling-original\n" + + "+sibling-patched\n", + }, + { + name: "update with leading space", + sibling: " " + tokenName, + patch: "--- a/ bridge-token\n" + + "+++ b/ bridge-token\n" + + "@@ -1 +1 @@\n" + + "-sibling-original\n" + + "+sibling-patched\n", + }, + { + name: "c-quoted update with trailing space", + sibling: tokenName + " ", + patch: "diff --git \"a/bridge-token \" \"b/bridge-token \"\n" + + "--- \"a/bridge-token \"\n" + + "+++ \"b/bridge-token \"\n" + + "@@ -1 +1 @@\n" + + "-sibling-original\n" + + "+sibling-patched\n", + }, + { + name: "copy with trailing space source", + sibling: tokenName + " ", + patch: "diff --git a/bridge-token b/bridge-copy\n" + + "similarity index 100%\n" + + "copy from bridge-token \n" + + "copy to bridge-copy\n", + moved: "bridge-copy", + }, + { + name: "rename with leading space source", + sibling: " " + tokenName, + patch: "diff --git a/ bridge-token b/bridge-renamed\n" + + "similarity index 100%\n" + + "rename from bridge-token\n" + + "rename to bridge-renamed\n", + moved: "bridge-renamed", + sourceGone: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if runtime.GOOS == "windows" && strings.HasSuffix(tc.sibling, " ") { + t.Skip("Windows filenames cannot end in a space") + } + + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(ws, tokenName) + if err := os.WriteFile(token, []byte(tokenContents), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + sibling := filepath.Join(ws, tc.sibling) + if err := os.WriteFile(sibling, []byte(siblingBefore), 0o600); err != nil { + t.Fatalf("write sibling: %v", err) + } + + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + if err := remote.CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv: %v", err) + } + + engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: ws, Policy: sandbox.DefaultPolicy()}) + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{ + "patch": tc.patch, + }, RunOptions{Sandbox: engine, PermissionGranted: true}) + // The control: the patch names an unprotected file, so it must be + // executable. A refusal here would make the token assertion below + // vacuous — every patch protects the token if no patch ever runs. + if result.Status != StatusOK { + t.Fatalf("patch on the unprotected sibling was refused: status=%s output=%q", result.Status, result.Output) + } + + target, want := sibling, siblingAfter + if tc.moved != "" { + target, want = filepath.Join(ws, tc.moved), siblingBefore + } + contents, err := os.ReadFile(target) + if err != nil || string(contents) != want { + t.Fatalf("patch target %q: contents=%q err=%v, want %q", filepath.Base(target), contents, err, want) + } + if tc.sourceGone { + if _, err := os.Stat(sibling); !os.IsNotExist(err) { + t.Fatalf("rename left its source %q in place: err=%v", tc.sibling, err) + } + } + + // The whole point: the effect landed on the name the gate read, and + // the protected token one byte away is untouched. + tokenContentsAfter, err := os.ReadFile(token) + if err != nil || string(tokenContentsAfter) != tokenContents { + t.Fatalf("protected token changed: contents=%q err=%v", tokenContentsAfter, err) + } + }) + } +} + +// TestApplyPatchDeniesWhitespaceNeighbourOfProtectedToken is the inverse of the +// case above: when the whitespace-bearing name IS the protected token, the same +// bytes must reach the gate and be refused before any file is opened. +func TestApplyPatchDeniesWhitespaceNeighbourOfProtectedToken(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filenames cannot end in a space") + } + const tokenContents = "bridge-secret\n" + + for _, tc := range []struct { + name string + patch string + moved string + }{ + { + name: "update", + patch: "--- a/bridge-token \n" + + "+++ b/bridge-token \n" + + "@@ -1 +1 @@\n" + + "-bridge-secret\n" + + "+attacker-controlled\n", + }, + { + name: "copy", + patch: "diff --git a/bridge-token b/exfiltrated\n" + + "similarity index 100%\n" + + "copy from bridge-token \n" + + "copy to exfiltrated\n", + moved: "exfiltrated", + }, + { + name: "rename", + patch: "diff --git a/bridge-token b/exfiltrated\n" + + "similarity index 100%\n" + + "rename from bridge-token \n" + + "rename to exfiltrated\n", + moved: "exfiltrated", + }, + } { + t.Run(tc.name, func(t *testing.T) { + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + token := filepath.Join(ws, "bridge-token ") + if err := os.WriteFile(token, []byte(tokenContents), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + if err := remote.CanonicalizeTokenFileEnv(); err != nil { + t.Fatalf("CanonicalizeTokenFileEnv: %v", err) + } + + engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: ws, Policy: sandbox.DefaultPolicy()}) + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{ + "patch": tc.patch, + }, RunOptions{Sandbox: engine, PermissionGranted: true}) + if result.Status == StatusOK || !strings.Contains(result.Output, "remote bridge token") { + t.Fatalf("apply_patch: status=%s output=%q, want bridge-token denial", result.Status, result.Output) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != tokenContents { + t.Fatalf("token changed after denied patch: contents=%q err=%v", contents, err) + } + if tc.moved != "" { + if _, err := os.Stat(filepath.Join(ws, tc.moved)); !os.IsNotExist(err) { + t.Fatalf("denied patch created %q: err=%v", tc.moved, err) + } + } + }) + } +} diff --git a/internal/tools/protected_credentials.go b/internal/tools/protected_credentials.go new file mode 100644 index 000000000..9b870d984 --- /dev/null +++ b/internal/tools/protected_credentials.go @@ -0,0 +1,142 @@ +package tools + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/Gitlawb/zero/internal/sandbox" +) + +// This file is the mandatory, engine-independent half of the daemon-token +// boundary for direct file tools. Registry.Run and RunWithOptions without a +// sandbox engine never reach Engine.Evaluate, so the tools must enforce the +// process-owned credential set themselves. +// +// Reads open through an os.Root tied to the selected workspace/scope root, then +// compare metadata from that same handle with the protected credential set. +// Writes open through the same root, verify the opened identity before +// truncation, and exclusively create new files. Consequently path resolution +// cannot be redirected to the token between authorization and use, while an +// ordinary existing file keeps its inode-level metadata and hard links. + +// protectedReadOpen opens path through the workspace root and verifies the +// identity obtained from that same handle before any content is consumed. +func protectedReadOpen(path, workspaceRoot string) (*os.File, os.FileInfo, error) { + rootPath, relative, err := rootedPathWithin([]string{workspaceRoot}, path) + if err != nil { + // Explicitly granted extra roots and spill files have already passed their + // own canonical containment checks. Bind their final lookup to the + // canonical parent rather than falling back to an unrestricted os.Open. + rootPath = filepath.Dir(path) + relative = filepath.Base(path) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + return nil, nil, err + } + file, err := root.Open(relative) + closeErr := root.Close() + if err != nil { + return nil, nil, err + } + if closeErr != nil { + file.Close() + return nil, nil, closeErr + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, nil, err + } + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.FileHandleExcluded(path, file, info) { + file.Close() + return nil, nil, protectedCredentialErr(path, "readable") + } + return file, info, nil +} + +// ProtectedReadOpen opens path through a rooted handle and rejects any handle +// whose identity belongs to a protected credential. Callers must read from the +// returned handle rather than reopening path. +func ProtectedReadOpen(path, workspaceRoot string) (*os.File, os.FileInfo, error) { + return protectedReadOpen(path, workspaceRoot) +} + +// protectedRootRead is the equivalent for callers that already hold the root, +// notably structured and staged unified patches. +func protectedRootRead(root *os.Root, relative, absolute, workspaceRoot string) (*os.File, os.FileInfo, error) { + file, err := root.Open(relative) + if err != nil { + return nil, nil, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, nil, err + } + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.FileHandleExcluded(absolute, file, info) { + file.Close() + return nil, nil, protectedCredentialErr(absolute, "readable") + } + return file, info, nil +} + +// protectedMutationDenied is the early, lexical refusal. Existing files are +// checked again from the handle that writeRootedFile will modify, closing a +// swap race without replacing the file's inode and losing its metadata. +func protectedMutationDenied(path, workspaceRoot string) error { + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.PathExcluded(path) { + return protectedCredentialErr(path, "writable") + } + return nil +} + +func writeRootedFile(root *os.Root, relative, absolute, workspaceRoot string, content []byte, mode os.FileMode, createOnly bool) (bool, error) { + parent := filepath.Dir(relative) + if err := root.MkdirAll(parent, 0o755); err != nil { + return false, err + } + flags := os.O_WRONLY + if createOnly { + flags |= os.O_CREATE | os.O_EXCL + } + file, err := root.OpenFile(relative, flags, mode.Perm()) + if err != nil { + return false, err + } + committed := createOnly + if !createOnly { + info, statErr := file.Stat() + if statErr != nil { + file.Close() + return false, statErr + } + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.FileHandleExcluded(absolute, file, info) { + file.Close() + return false, protectedCredentialErr(absolute, "writable") + } + if err := file.Truncate(0); err != nil { + file.Close() + return false, err + } + committed = true + } + _, writeErr := io.Copy(file, bytes.NewReader(content)) + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + return committed, errors.Join(writeErr, closeErr) + } + return true, nil +} + +func protectedCredentialErr(path, verb string) error { + return fmt.Errorf("%s holds the remote bridge token and is never %s", path, verb) +} diff --git a/internal/tools/protected_credentials_test.go b/internal/tools/protected_credentials_test.go new file mode 100644 index 000000000..0a44255b8 --- /dev/null +++ b/internal/tools/protected_credentials_test.go @@ -0,0 +1,532 @@ +package tools + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" +) + +func TestProtectedReadOpenParentFallback(t *testing.T) { + for _, kind := range []string{"hard link", "sibling symlink", "escaping symlink"} { + t.Run(kind, func(t *testing.T) { + ws := t.TempDir() + outside := t.TempDir() + path := filepath.Join(outside, "notes.txt") + token := filepath.Join(outside, "bridge-token") + if kind == "escaping symlink" { + token = filepath.Join(t.TempDir(), "bridge-token") + } + for name, content := range map[string]string{path: "ordinary notes\n", token: "bridge-secret\n"} { + if err := os.WriteFile(name, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + t.Setenv(remote.EnvTokenFileIdentity, "") + // Exercise the fallback directly: callers have already authorized an + // extra root or spill file, but the workspace root cannot contain it. + if _, _, err := rootedPathWithin([]string{ws}, path); err == nil { + t.Fatal("fixture did not select the parent-directory fallback") + } + file, _, err := protectedReadOpen(path, ws) + if err != nil { + t.Fatalf("ordinary fallback read: %v", err) + } + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil || string(content) != "ordinary notes\n" { + t.Fatalf("ordinary content = %q, read error = %v, close error = %v", content, readErr, closeErr) + } + // Replace the previously authorized ordinary path. A hard link or + // sibling symlink stays within the fallback root, so only the handle + // credential check can refuse it; an escaping symlink must also fail. + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + switch kind { + case "hard link": + err = os.Link(token, path) + case "sibling symlink": + err = os.Symlink(filepath.Base(token), path) + default: + err = os.Symlink(token, path) + } + if err != nil { + t.Skipf("%s unavailable: %v", kind, err) + } + file, info, err := protectedReadOpen(path, ws) + if file != nil { + file.Close() + } + if err == nil || file != nil || info != nil { + t.Fatalf("fallback returned a protected handle: file=%v info=%v err=%v", file, info, err) + } + if kind != "escaping symlink" && !strings.Contains(err.Error(), "remote bridge token and is never readable") { + t.Fatalf("wanted credential rejection, got %v", err) + } + }) + } +} + +// TestEngineLessRegistryMatrix drives every registry-dispatched tool that +// names a path through the plain registry API (Registry.Run — no sandbox +// engine) with a protected token selected, and separately with no token +// selected at all. Before protected_credentials.go this matrix would have +// failed on read_file, read_minified_file, grep, and glob: nothing upstream of +// those tools' own Run() ever consulted the protected set when no engine was +// supplied, because Registry.RunWithOptions only asks the engine at all when +// options.Sandbox is non-nil. +func TestEngineLessRegistryMatrix(t *testing.T) { + setup := func(t *testing.T) (ws, token string) { + t.Helper() + ws = t.TempDir() + token = filepath.Join(ws, "bridge-token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "ordinary.txt"), []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + return ws, token + } + + t.Run("read_file", func(t *testing.T) { + ws, _ := setup(t) + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + result := registry.Run(context.Background(), "read_file", map[string]any{"path": "bridge-token"}) + if result.Status == StatusOK || strings.Contains(result.Output, "bridge-secret") { + t.Fatalf("read_file leaked token: %+v", result) + } + }) + + t.Run("read_minified_file", func(t *testing.T) { + ws, _ := setup(t) + registry := NewRegistry() + registry.Register(NewScopedReadMinifiedFileTool(ws, nil)) + result := registry.Run(context.Background(), "read_minified_file", map[string]any{"path": "bridge-token"}) + if result.Status == StatusOK || strings.Contains(result.Output, "bridge-secret") { + t.Fatalf("read_minified_file leaked token: %+v", result) + } + }) + + t.Run("grep", func(t *testing.T) { + ws, _ := setup(t) + registry := NewRegistry() + registry.Register(NewScopedGrepTool(ws, nil)) + result := registry.Run(context.Background(), "grep", map[string]any{"pattern": "bridge-secret"}) + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("grep surfaced the protected filename: %+v", result) + } + }) + + t.Run("glob", func(t *testing.T) { + ws, _ := setup(t) + registry := NewRegistry() + registry.Register(NewScopedGlobTool(ws, nil)) + result := registry.Run(context.Background(), "glob", map[string]any{"pattern": "*"}) + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("glob surfaced the protected filename: %+v", result) + } + }) + + t.Run("list_directory", func(t *testing.T) { + ws, _ := setup(t) + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + result := registry.Run(context.Background(), "list_directory", map[string]any{"path": "."}) + if strings.Contains(result.Output, "bridge-token") { + t.Fatalf("list_directory surfaced the protected filename: %+v", result) + } + }) + + t.Run("write_file", func(t *testing.T) { + ws, token := setup(t) + registry := NewRegistry() + registry.Register(NewScopedWriteFileTool(ws, nil)) + result := registry.Run(context.Background(), "write_file", map[string]any{"path": "bridge-token", "content": "attacker\n", "overwrite": true}) + if result.Status == StatusOK { + t.Fatalf("write_file overwrote the protected token: %+v", result) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied write: contents=%q err=%v", contents, err) + } + }) + + t.Run("edit_file", func(t *testing.T) { + ws, token := setup(t) + registry := NewRegistry() + registry.Register(NewScopedEditFileTool(ws, nil)) + result := registry.Run(context.Background(), "edit_file", map[string]any{"path": "bridge-token", "old_string": "bridge-secret", "new_string": "attacker"}) + if result.Status == StatusOK { + t.Fatalf("edit_file rewrote the protected token: %+v", result) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied edit: contents=%q err=%v", contents, err) + } + }) + + t.Run("apply_patch unified", func(t *testing.T) { + ws, token := setup(t) + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + patch := "--- a/bridge-token\n+++ b/bridge-token\n@@ -1 +1 @@\n-bridge-secret\n+attacker\n" + result := registry.Run(context.Background(), "apply_patch", map[string]any{"patch": patch}) + if result.Status == StatusOK { + t.Fatalf("apply_patch rewrote the protected token: %+v", result) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied unified patch: contents=%q err=%v", contents, err) + } + }) + + t.Run("apply_patch structured", func(t *testing.T) { + ws, token := setup(t) + registry := NewRegistry() + registry.Register(NewScopedApplyPatchTool(ws, nil)) + patch := "*** Begin Patch\n*** Update File: bridge-token\n@@\n-bridge-secret\n+attacker\n*** End Patch\n" + result := registry.Run(context.Background(), "apply_patch", map[string]any{"patch": patch}) + if result.Status == StatusOK { + t.Fatalf("apply_patch (structured) rewrote the protected token: %+v", result) + } + contents, err := os.ReadFile(token) + if err != nil || string(contents) != "bridge-secret\n" { + t.Fatalf("token changed after a denied structured patch: contents=%q err=%v", contents, err) + } + }) + + // Ordinary content must still flow through every tool with no token + // configured at all — the mandatory guard must never turn into a permanent + // deny of everything. + t.Run("no token selected", func(t *testing.T) { + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, "ordinary.txt"), []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", "") + + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + result := registry.Run(context.Background(), "read_file", map[string]any{"path": "ordinary.txt"}) + if result.Status != StatusOK || !strings.Contains(result.Output, "ordinary") { + t.Fatalf("read_file with no token configured wrongly refused: %+v", result) + } + }) +} + +// TestProtectedReadOpenClosesTheCheckToUseWindow is the deterministic +// swap-race regression: a pathname check followed by a SEPARATE later open +// (what every direct file tool did before protectedReadOpen existed) leaves a +// window where a concurrent writer can replace an already-checked ordinary +// file with a symlink to the token before the later open runs. protectedReadOpen +// closes that window by deciding from the SAME handle content is read through, +// so there is no separate "check" step to race at all — every open +// independently re-verifies identity from its own freshly-obtained os.FileInfo. +// +// This is exercised without real goroutine concurrency (which cannot be made +// deterministic) by proving the invariant that makes the race impossible: an +// ordinary path served successfully once is re-verified, not cached, the next +// time it is opened — so a path that becomes the protected token BETWEEN two +// calls is caught on the second call exactly as if it had always been the +// token. Nothing about the first call's outcome could ever leak into the +// second. +func TestProtectedReadOpenClosesTheCheckToUseWindow(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(ws, "bridge-token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + swappable := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(swappable, []byte("ordinary notes\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + + before := registry.Run(context.Background(), "read_file", map[string]any{"path": "notes.txt"}) + if before.Status != StatusOK || !strings.Contains(before.Output, "ordinary notes") { + t.Fatalf("ordinary read before the swap failed: %+v", before) + } + + // The attacker's move: atomically replace the already-served ordinary file + // with a hard link to the protected token. A pre-open pathname check taken + // before this point would still describe the ordinary file; only a check + // bound to the NEXT open's own handle can see the swap. + if err := os.Remove(swappable); err != nil { + t.Fatal(err) + } + if err := os.Link(token, swappable); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + + after := registry.Run(context.Background(), "read_file", map[string]any{"path": "notes.txt"}) + if after.Status == StatusOK || strings.Contains(after.Output, "bridge-secret") { + t.Fatalf("read_file served the token through a path swapped after an earlier successful read: %+v", after) + } +} + +func TestStartupIdentityLifecycleProtectsReadFileAndGrep(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(ws, "bridge-token") + alias := filepath.Join(ws, "retired-token-alias") + const oldSecret = "startup-lifecycle-secret" + if err := os.WriteFile(token, []byte(oldSecret+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + t.Setenv(remote.EnvToken, "") + t.Setenv(remote.EnvTokenFile, token) + t.Setenv(remote.EnvTokenFileResolved, "") + t.Setenv(remote.EnvTokenFileIdentity, "") + auth, err := remote.NewAuthenticatorFromEnv() + if err != nil { + t.Fatalf("daemon startup: %v", err) + } + replacement := filepath.Join(ws, "replacement") + if err := os.WriteFile(replacement, []byte("replacement-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, token); err != nil { + t.Fatal(err) + } + if err := auth.Authenticate(oldSecret); err != nil { + t.Fatalf("startup credential must remain live after path replacement: %v", err) + } + + registry := NewRegistry() + registry.Register(NewScopedReadFileTool(ws, nil)) + registry.Register(NewScopedGrepTool(ws, nil)) + read := registry.Run(context.Background(), "read_file", map[string]any{"path": filepath.Base(alias)}) + if read.Status == StatusOK || strings.Contains(read.Output, oldSecret) { + t.Fatalf("read_file disclosed retired startup token: %+v", read) + } + grep := registry.Run(context.Background(), "grep", map[string]any{"pattern": oldSecret}) + if strings.Contains(grep.Output, filepath.Base(alias)) || strings.Contains(grep.Output, oldSecret) { + t.Fatalf("grep disclosed retired startup token: %+v", grep) + } +} + +func TestProtectedReadOpenRejectsRacedEscapingSymlink(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(t.TempDir(), "bridge-token") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + path := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(path, []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(token, path); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + file, _, err := protectedReadOpen(path, ws) + if file != nil { + file.Close() + } + if err == nil { + t.Fatal("rooted read followed a raced symlink outside the workspace") + } +} + +func TestRootedMutationPublishDoesNotClobberRacedTokenAlias(t *testing.T) { + for _, name := range []string{"write_file", "edit_file", "unified_patch"} { + t.Run(name, func(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(ws, "bridge-token") + path := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + if err := protectedMutationDenied(path, ws); err != nil { + t.Fatalf("ordinary preflight failed: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Link(token, path); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + root, err := os.OpenRoot(ws) + if err != nil { + t.Fatal(err) + } + defer root.Close() + switch name { + case "unified_patch": + // A unified diff is translated into the same operations the + // structured engine applies, so it publishes through the same + // rooted atomic write rather than an unrestricted pathname open. + operations, perr := parseUnifiedPatch("--- a/notes.txt\n+++ b/notes.txt\n@@ -1 +1 @@\n-ordinary\n+patched\n") + if perr != nil { + t.Fatal(perr) + } + var changes []structuredPatchChange + changes, err = planStructuredPatch(root, operations, nil) + if err == nil { + _, err = applyStructuredPatchChanges(root, ws, changes, nil) + } + default: + _, err = writeRootedFile(root, "notes.txt", path, ws, []byte("updated\n"), 0o644, false) + } + // Planning or the write-side handle check refuses the raced alias. The + // token must remain unchanged regardless of which layer catches it. + if err != nil && !strings.Contains(err.Error(), "remote bridge token") { + t.Fatal(err) + } + if content, err := os.ReadFile(token); err != nil || string(content) != "bridge-secret\n" { + t.Fatalf("token changed through raced alias: content=%q err=%v", content, err) + } + }) + } +} + +func TestStructuredPatchPublishDoesNotClobberRacedTokenAlias(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(ws, "bridge-token") + path := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("ordinary\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + operations, err := parseStructuredPatch("*** Begin Patch\n*** Update File: notes.txt\n@@\n-ordinary\n+updated\n*** End Patch\n") + if err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(ws) + if err != nil { + t.Fatal(err) + } + defer root.Close() + changes, err := planStructuredPatch(root, operations, nil) + if err != nil { + t.Fatal(err) + } + if len(changes) != 1 || strings.Contains(changes[0].before, "bridge-secret") { + t.Fatalf("structured plan exposed unexpected content: %#v", changes) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Link(token, path); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + // The commit either refuses the swapped target (the file changed between + // planning and commit, or it is the protected credential) or publishes + // atomically over the alias. Either is acceptable; an altered token is not. + _, err = applyStructuredPatchChanges(root, ws, changes, nil) + if err != nil && !strings.Contains(err.Error(), "changed on disk") && + !strings.Contains(err.Error(), "remote bridge token") { + t.Fatal(err) + } + if content, err := os.ReadFile(token); err != nil || string(content) != "bridge-secret\n" { + t.Fatalf("token changed through raced structured patch alias: content=%q err=%v", content, err) + } +} + +func TestStructuredPatchPlanningRejectsProtectedAliasHandle(t *testing.T) { + ws := t.TempDir() + token := filepath.Join(ws, "bridge-token") + alias := filepath.Join(ws, "notes.txt") + if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(token, alias); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token) + operations, err := parseStructuredPatch("*** Begin Patch\n*** Update File: notes.txt\n@@\n-bridge-secret\n+attacker\n*** End Patch\n") + if err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(ws) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if changes, err := planStructuredPatch(root, operations, nil); err == nil || len(changes) != 0 { + t.Fatalf("structured plan accepted protected alias: changes=%#v err=%v", changes, err) + } +} + +func TestDirectFileToolsPreserveHardLinks(t *testing.T) { + for _, name := range []string{"write_file", "edit_file"} { + t.Run(name, func(t *testing.T) { + ws := t.TempDir() + path := filepath.Join(ws, "file.txt") + alias := filepath.Join(ws, "alias.txt") + if err := os.WriteFile(path, []byte("before\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Link(path, alias); err != nil { + t.Skipf("workspace filesystem is not hard-linkable: %v", err) + } + + var result Result + switch name { + case "write_file": + result = NewScopedWriteFileTool(ws, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "file.txt", "content": "after\n", "overwrite": true, + }, RunOptions{}) + case "edit_file": + tracker := NewFileTracker() + read := NewScopedReadFileTool(ws, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{"path": "file.txt"}, RunOptions{FileTracker: tracker}) + if read.Status != StatusOK { + t.Fatalf("read failed: %+v", read) + } + result = NewScopedEditFileTool(ws, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": "file.txt", "old_string": "before", "new_string": "after", + }, RunOptions{FileTracker: tracker}) + } + if result.Status != StatusOK { + t.Fatalf("%s failed: %+v", name, result) + } + if content, err := os.ReadFile(alias); err != nil || string(content) != "after\n" { + t.Fatalf("hard-link content = %q, err=%v", content, err) + } + pathInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + aliasInfo, err := os.Stat(alias) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(pathInfo, aliasInfo) { + t.Fatal("tool replaced the existing inode and broke its hard link") + } + }) + } +} diff --git a/internal/tools/read_exclusions.go b/internal/tools/read_exclusions.go index 188f728f8..513835f23 100644 --- a/internal/tools/read_exclusions.go +++ b/internal/tools/read_exclusions.go @@ -1,6 +1,10 @@ package tools -import "github.com/Gitlawb/zero/internal/sandbox" +import ( + "os" + + "github.com/Gitlawb/zero/internal/sandbox" +) // readExcluder skips read-denied paths (the sandbox DenyRead policy) during a // search walk. The zero value (both funcs nil) excludes nothing, so a @@ -9,11 +13,26 @@ import "github.com/Gitlawb/zero/internal/sandbox" type readExcluder struct { file func(string) bool dir func(string) bool + // handle is the same decision taken from an OPENED object's own metadata. + // A walk-time pathname check cannot be the enforcement boundary for a read + // that opens the name again later: the candidate can be replaced by a + // symlink or hard link to a protected credential in between, and the open + // then returns the credential. Callers that open must re-ask here with the + // FileInfo from their handle. + handle func(string, *os.File, os.FileInfo) bool } func (e readExcluder) fileExcluded(path string) bool { return e.file != nil && e.file(path) } func (e readExcluder) dirExcluded(path string) bool { return e.dir != nil && e.dir(path) } +// openedFileExcluded is the authoritative check, run after the object is open. +func (e readExcluder) openedFileExcluded(path string, file *os.File, info os.FileInfo) bool { + if e.handle != nil { + return e.handle(path, file, info) + } + return e.fileExcluded(path) +} + // sandboxReadExcluder builds a readExcluder from a sandbox engine's DenyRead // policy. The engine's read-exclusion matcher resolves the policy paths ONCE here // (not per visited path), and the closures reuse it for the whole walk. A nil @@ -27,5 +46,26 @@ func sandboxReadExcluder(engine *sandbox.Engine) readExcluder { if !rx.Active() { return readExcluder{} } - return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded} + return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileHandleExcluded} +} + +// sandboxReadExcluderWithin is sandboxReadExcluder for callers that can name +// their workspace root, and it differs in one way that matters: when there is +// no engine it still excludes the automatic protected credentials. +// +// Registry.Run funnels into RunWithOptions with empty options, so "no engine" +// is a real production path (MCP, legacy callers), not just a test shape. The +// protected-credential set is derived from this process's environment rather +// than from a policy, so there is no engine to consult for it and no reason for +// that path to disclose the bridge bearer token. Policy-driven DenyRead still +// requires an engine — without one there is no policy to apply. +func sandboxReadExcluderWithin(engine *sandbox.Engine, workspaceRoot string) readExcluder { + if engine != nil { + return sandboxReadExcluder(engine) + } + rx := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if !rx.Active() { + return readExcluder{} + } + return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileHandleExcluded} } diff --git a/internal/tools/read_exclusions_test.go b/internal/tools/read_exclusions_test.go index 1feb9880b..0ca174a99 100644 --- a/internal/tools/read_exclusions_test.go +++ b/internal/tools/read_exclusions_test.go @@ -97,3 +97,68 @@ func TestGlobSkipsDenyReadSubtree(t *testing.T) { t.Fatalf("non-sandboxed glob should include the secret file, got:\n%s", plain.Output) } } + +func TestListDirectorySkipsDenyReadSubtree(t *testing.T) { + ws, engine := denyReadFixture(t) + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + args := map[string]any{"path": ".", "recursive": true, "max_depth": 2} + + sandboxed := registry.RunWithOptions(context.Background(), "list_directory", args, RunOptions{Sandbox: engine}) + if sandboxed.Status != StatusOK { + t.Fatalf("list_directory failed: %s", sandboxed.Output) + } + if !strings.Contains(sandboxed.Output, "main.go") { + t.Fatalf("list_directory must still show the non-denied file, got:\n%s", sandboxed.Output) + } + if strings.Contains(sandboxed.Output, "secret") || strings.Contains(sandboxed.Output, "creds.go") { + t.Fatalf("list_directory must NOT surface a DenyRead subtree, got:\n%s", sandboxed.Output) + } + + plain := NewScopedListDirectoryTool(ws, nil).Run(context.Background(), args) + if !strings.Contains(plain.Output, "secret/") || !strings.Contains(plain.Output, "creds.go") { + t.Fatalf("non-sandboxed list_directory should include the secret subtree, got:\n%s", plain.Output) + } +} + +func TestListDirectoryDescendsToNestedAllowRead(t *testing.T) { + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + secret := filepath.Join(ws, "secret") + allowed := filepath.Join(secret, "allowed") + if err := os.MkdirAll(allowed, 0o755); err != nil { + t.Fatalf("mkdir allowed: %v", err) + } + if err := os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("hidden\n"), 0o600); err != nil { + t.Fatalf("write hidden: %v", err) + } + if err := os.WriteFile(filepath.Join(allowed, "visible.txt"), []byte("visible\n"), 0o600); err != nil { + t.Fatalf("write visible: %v", err) + } + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: ws, + Policy: sandbox.Policy{ + Mode: sandbox.ModeEnforce, + EnforceWorkspace: true, + DenyRead: []string{secret}, + AllowRead: []string{allowed}, + }, + }) + registry := NewRegistry() + registry.Register(NewScopedListDirectoryTool(ws, nil)) + + result := registry.RunWithOptions(context.Background(), "list_directory", map[string]any{ + "path": ".", "recursive": true, "max_depth": 3, + }, RunOptions{Sandbox: engine}) + if result.Status != StatusOK { + t.Fatalf("list_directory failed: %s", result.Output) + } + if !strings.Contains(result.Output, "allowed/") || !strings.Contains(result.Output, "visible.txt") { + t.Fatalf("list_directory must descend to the nested AllowRead subtree, got:\n%s", result.Output) + } + if strings.Contains(result.Output, "secret/") || strings.Contains(result.Output, "hidden.txt") { + t.Fatalf("list_directory must hide denied entries outside the nested AllowRead subtree, got:\n%s", result.Output) + } +} diff --git a/internal/tools/read_file.go b/internal/tools/read_file.go index 0ea84b121..8777331cf 100644 --- a/internal/tools/read_file.go +++ b/internal/tools/read_file.go @@ -128,7 +128,7 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg return errorResult("Error reading file " + requestedPath + ": " + err.Error()) } - stats, err := scanReadFileStats(absolutePath) + stats, err := scanReadFileStats(absolutePath, tool.workspaceRoot) if err != nil { return errorResult("Error reading file " + relativePath + ": " + err.Error()) } @@ -138,7 +138,7 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg // not the authoritative content hash. options.FileTracker.RecordHash(absolutePath, stats.hash, stats.info) if byteMode { - result, seenStart, seenEnd := renderReadFileBytes(absolutePath, relativePath, stats.bytes, byteOffset, byteLimit) + result, seenStart, seenEnd := renderReadFileBytes(absolutePath, relativePath, tool.workspaceRoot, stats.bytes, byteOffset, byteLimit) if result.Status == StatusOK && !result.Truncated && seenEnd > seenStart { if options.deferFileObservation { result.pendingFileObservation = &pendingFileObservation{ @@ -158,7 +158,7 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg if directBudget { maxBytes = readOutputBudgetBytes } - result := renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines, maxBytes) + result := renderReadFileRange(absolutePath, relativePath, tool.workspaceRoot, stats.lines, startLine, endLine, maxLines, maxBytes) if result.Status == StatusOK && result.Meta["truncation_reason"] != "byte_budget" { seenStart, seenEnd := renderedReadRange(stats.lines, startLine, endLine, maxLines) if options.deferFileObservation { @@ -194,7 +194,7 @@ func renderedReadRange(total, start, end, maxLines int) (int, int) { return start, end } -func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result { +func renderReadFileRange(absolutePath string, relativePath string, workspaceRoot string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result { if startLine > total { return okResult(fmt.Sprintf("File: %s\n(offset %d is past the end of the file, which has %d lines)", relativePath, startLine, total)) } @@ -245,7 +245,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st budgetedOutput.WriteString("\n") } budgetedOutput.WriteString("\n") - if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines); err != nil { + if err := appendReadFileRange(budgetedOutput, absolutePath, workspaceRoot, startLine, selectedLines); err != nil { return errorResult("Error reading file " + relativePath + ": " + err.Error()) } if truncated { @@ -285,8 +285,8 @@ type readFileStats struct { info os.FileInfo } -func scanReadFileStats(path string) (readFileStats, error) { - file, err := os.Open(path) +func scanReadFileStats(path, workspaceRoot string) (readFileStats, error) { + file, _, err := protectedReadOpen(path, workspaceRoot) if err != nil { return readFileStats{}, err } @@ -317,11 +317,11 @@ func scanReadFileStats(path string) (readFileStats, error) { return readFileStats{lines: lines, bytes: bytes, hash: hex.EncodeToString(hasher.Sum(nil)), info: info}, nil } -func renderReadFileBytes(path, relativePath string, total, requestedStart, limit int) (Result, int, int) { +func renderReadFileBytes(path, relativePath, workspaceRoot string, total, requestedStart, limit int) (Result, int, int) { if requestedStart >= total { return okResult(fmt.Sprintf("File: %s\n(byte_offset %d is past the end of the file, which has %d bytes)", relativePath, requestedStart, total)), 0, 0 } - file, err := os.Open(path) + file, _, err := protectedReadOpen(path, workspaceRoot) if err != nil { return errorResult("Error reading file " + relativePath + ": " + err.Error()), 0, 0 } @@ -358,8 +358,8 @@ func renderReadFileBytes(path, relativePath string, total, requestedStart, limit return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end } -func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int) error { - file, err := os.Open(path) +func appendReadFileRange(output *outputBudgetBuilder, path string, workspaceRoot string, startLine int, selectedLines int) error { + file, _, err := protectedReadOpen(path, workspaceRoot) if err != nil { return err } diff --git a/internal/tools/read_minified_file.go b/internal/tools/read_minified_file.go index e168e9070..fb17a44cd 100644 --- a/internal/tools/read_minified_file.go +++ b/internal/tools/read_minified_file.go @@ -3,7 +3,7 @@ package tools import ( "context" "fmt" - "os" + "io" "strconv" "strings" @@ -74,14 +74,24 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di return errorResult("Error reading file " + requestedPath + ": " + err.Error()) } - content, err := os.ReadFile(absolutePath) + // protectedReadOpen binds the daemon-token check to the handle the content is + // actually read from — see internal/tools/protected_credentials.go. This is + // also the ONLY protection this tool has when called through the plain + // registry API (Registry.Run, or RunWithOptions with no sandbox engine): + // unlike read_file's sibling gate through Engine.Evaluate, nothing upstream + // of this call ever checked the path at all. + file, info, err := protectedReadOpen(absolutePath, tool.workspaceRoot) + if err != nil { + return errorResult("Error reading file " + relativePath + ": " + err.Error()) + } + content, err := io.ReadAll(file) + file.Close() if err != nil { return errorResult("Error reading file " + relativePath + ": " + err.Error()) } // Record the raw whole-file baseline (matching read_file/edit_file) so a later // write can still detect an out-of-Zero modification — the minification only // affects what the model SEES, not the tracked on-disk state. - info, _ := os.Stat(absolutePath) options.FileTracker.Record(absolutePath, content, info) selected := selectSourceLines(content, offset, limit) diff --git a/internal/tools/registry.go b/internal/tools/registry.go index e553c2e55..c8e096bb0 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -64,6 +64,10 @@ type RunOptions struct { // it introduced in the same turn instead of waiting for a later verification // pass. nil disables inline diagnostics. Diagnostics func(ctx context.Context, absPath string) string + // preparedApplyPatch carries the built-in parser's result from the sandbox + // gate into execution, so one parse chooses both the authorized paths and the + // operations that touch the filesystem. + preparedApplyPatch *applyPatchPreparation } type sandboxAwareTool interface { @@ -226,6 +230,13 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args return res } } + if _, builtIn := tool.(interface{ isBuiltInApplyPatch() }); builtIn { + prepared, err := prepareApplyPatchArguments(args) + if err != nil { + return errorResult("Error applying patch: " + err.Error()) + } + options.preparedApplyPatch = prepared + } permission := effectiveToolPermission(tool, args) sandboxGrantAuthorized := false @@ -238,6 +249,7 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args PermissionGranted: options.PermissionGranted, PermissionMode: sandbox.PermissionMode(options.PermissionMode), Args: args, + PatchPaths: preparedPatchPaths(options.preparedApplyPatch), Reason: tool.Safety().Reason, }) sandboxDecision = &d diff --git a/internal/tools/rooted_file.go b/internal/tools/rooted_file.go index 73aad9a4a..03e434796 100644 --- a/internal/tools/rooted_file.go +++ b/internal/tools/rooted_file.go @@ -4,13 +4,14 @@ import ( "fmt" "io" "os" + "path/filepath" ) // readRootedFile returns bytes and identity from the same opened object, and // verifies that the rooted path still names that object after the read. Root.Open // binds symlink containment to use rather than relying on a pathname pre-check. func readRootedFile(root *os.Root, relativePath string) ([]byte, os.FileInfo, error) { - file, err := root.Open(relativePath) + file, openedInfo, err := protectedRootRead(root, relativePath, filepath.Join(root.Name(), relativePath), root.Name()) if err != nil { return nil, nil, err } @@ -20,10 +21,6 @@ func readRootedFile(root *os.Root, relativePath string) ([]byte, os.FileInfo, er _ = file.Close() } }() - openedInfo, err := file.Stat() - if err != nil { - return nil, nil, err - } if !openedInfo.Mode().IsRegular() { return nil, nil, fmt.Errorf("%s is not a regular file", relativePath) } diff --git a/internal/tools/search_cancellation_test.go b/internal/tools/search_cancellation_test.go index dcb6349fd..bdb20eabd 100644 --- a/internal/tools/search_cancellation_test.go +++ b/internal/tools/search_cancellation_test.go @@ -140,7 +140,7 @@ func TestGrepScanStopsMidFileOnCancelledContext(t *testing.T) { const allowed = 5 ctx := &countingCancelContext{Context: context.Background(), remaining: allowed} matches := 0 - err = scanGrepFile(ctx, resolvedRoot, false, file, presenceGrepLineMatcher(compiled), func(grepMatch) bool { + err = scanGrepFile(ctx, resolvedRoot, false, file, readExcluder{}, presenceGrepLineMatcher(compiled), func(grepMatch) bool { matches++ return true }) diff --git a/internal/tools/structured_patch.go b/internal/tools/structured_patch.go index 48c8d679f..8de8dee2a 100644 --- a/internal/tools/structured_patch.go +++ b/internal/tools/structured_patch.go @@ -127,14 +127,6 @@ func isStructuredPatch(patch string) bool { return sandbox.IsStructuredPatch(patch) } -func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result { - operations, err := parseStructuredPatch(patch) - if err != nil { - return errorResult("Error applying patch: " + err.Error()) - } - return applyPatchOperations(applyRoot, relativeRoot, operations, options) -} - // applyPatchOperations applies parsed operations (from either patch format) // through an opened workspace root: every stat, read, create and write is // descriptor-relative and refuses to follow a link out of the root, so there @@ -172,15 +164,15 @@ func applyPatchOperations(applyRoot, relativeRoot string, operations []structure case structuredPatchDelete: options.FileTracker.Forget(change.from.absolute) case structuredPatchAdd: - recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true) + recordStructuredPatchFile(workspace, options.FileTracker, change.to, true, true) case structuredPatchUpdate: wasWhole := wholeBefore[change.from.absolute] options.FileTracker.Forget(change.from.absolute) - recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole) + recordStructuredPatchFile(workspace, options.FileTracker, change.to, false, wasWhole) case structuredPatchCopy: // The source is untouched; the destination inherits only what the // model had actually seen of the source. - recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, wholeBefore[change.from.absolute]) + recordStructuredPatchFile(workspace, options.FileTracker, change.to, true, wholeBefore[change.from.absolute]) } } @@ -519,6 +511,15 @@ func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, err if err != nil { return structuredPatchTarget{}, err } + // Every structured-patch target (add/delete/update, and both the from and to + // side of a move) funnels through here, so one check covers all of them — + // engine-independent, and the only protection a structured patch has when + // called through the plain registry API. os.Root already confines the actual + // read/write to this tree without following an escaping symlink, but that is + // containment, not a refusal of one specific in-tree file. + if err := protectedMutationDenied(absolute, root); err != nil { + return structuredPatchTarget{}, err + } return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil } @@ -1080,23 +1081,28 @@ func removeStructuredPatchTemp(root *os.Root, name string) error { return errors.Join(chmodErr, removeErr) } -func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) { +func recordStructuredPatchFile(root *os.Root, tracker *FileTracker, target structuredPatchTarget, created, seenWhole bool) { if tracker == nil { return } - content, err := os.ReadFile(absolute) + file, info, err := protectedRootRead(root, target.relative, target.absolute, root.Name()) if err != nil { - tracker.Forget(absolute) + tracker.Forget(target.absolute) + return + } + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + tracker.Forget(target.absolute) return } - info, _ := os.Stat(absolute) - tracker.Record(absolute, content, info) + tracker.Record(target.absolute, content, info) if seenWhole { lines := trackedLineTotal(string(content)) - tracker.RecordSeenRange(absolute, 1, lines, lines) + tracker.RecordSeenRange(target.absolute, 1, lines, lines) } if created { - tracker.RecordCreated(absolute) + tracker.RecordCreated(target.absolute) } } diff --git a/internal/tools/tracked_line_total_test.go b/internal/tools/tracked_line_total_test.go index 59491b17e..f9ecd4fbc 100644 --- a/internal/tools/tracked_line_total_test.go +++ b/internal/tools/tracked_line_total_test.go @@ -12,7 +12,7 @@ func TestTrackedLineTotalMatchesReadFileStats(t *testing.T) { for name, content := range map[string]string{"trailing": "a\nb\nc\n", "unterminated": "a\nb\nc", "single": "x\n", "empty": "", "crlf": "a\r\nb\r\n"} { path := filepath.Join(root, name+".txt") writeTestFile(t, path, content) - stats, err := scanReadFileStats(path) + stats, err := scanReadFileStats(path, root) if err != nil { t.Fatal(err) } diff --git a/internal/tools/unified_patch.go b/internal/tools/unified_patch.go index ab09b3f1d..c0de5b465 100644 --- a/internal/tools/unified_patch.go +++ b/internal/tools/unified_patch.go @@ -2,7 +2,10 @@ package tools import ( "fmt" + "path/filepath" "strings" + + "github.com/Gitlawb/zero/internal/sandbox" ) // parseUnifiedPatch converts a unified diff into the same operations a @@ -31,7 +34,12 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { var chunk *structuredPatchChunk var added []string // collected "+" lines for a file creation oldPath, newPath := "", "" - pendingFrom, pendingKind := "", structuredPatchUpdate + pendingFrom, pendingHeader := "", "" + pendingKind := structuredPatchUpdate + diffOldPath, diffNewPath := "", "" + diffOperands := "" + diffPathsOK := false + diffHeaderLine := 0 // git's header-only forms: "deleted file mode" / "new file mode" after a // "diff --git" line with no ---/+++ pair describe an empty file. diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new) @@ -92,6 +100,21 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { if oldPath == "" || newPath == "" { return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line) } + if diffHeaderLine != 0 { + matches := false + switch { + case oldPath == "/dev/null": + matches = newPath == diffNewPath + case newPath == "/dev/null": + matches = oldPath == diffOldPath + default: + matches = oldPath == diffOldPath && newPath == diffNewPath + } + if !matches { + return fmt.Errorf("invalid unified diff at line %d: ---/+++ paths disagree with diff --git paths from line %d", line, diffHeaderLine) + } + diffHeaderLine = 0 + } // A ---/+++ pair after a rename/copy header names the same files; keep // accumulating that operation's hunks instead of starting a new one. if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) { @@ -203,7 +226,15 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { if err := flushHeaderOnly(lineNumber); err != nil { return nil, err } - diffPath = diffGitNewPath(raw) + diffOperands = strings.TrimPrefix(raw, "diff --git ") + diffOldPath, diffNewPath, diffPathsOK = sandbox.DiffGitPaths(diffOperands) + diffOldPath, diffNewPath = filepath.ToSlash(diffOldPath), filepath.ToSlash(diffNewPath) + diffHeaderLine = lineNumber + if diffPathsOK { + diffPath = diffNewPath + } else { + diffPath = "" + } continue case strings.HasPrefix(raw, "deleted file mode "): headerOnly = 'd' @@ -216,34 +247,79 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "): continue case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "): - pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from "))) - pendingKind = structuredPatchUpdate + pendingHeader = "rename" if strings.HasPrefix(raw, "copy from ") { + pendingHeader = "copy" + } + from, ok := sandbox.ExtendedGitHeaderPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")) + if !ok { + return nil, fmt.Errorf("invalid unified diff at line %d: source path cannot be interpreted exactly", lineNumber) + } + pendingFrom = filepath.ToSlash(from) + pendingKind = structuredPatchUpdate + if pendingHeader == "copy" { pendingKind = structuredPatchCopy } if pendingFrom == "" { return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber) } + if diffHeaderLine != 0 && diffPathsOK && pendingFrom != diffOldPath { + return nil, fmt.Errorf("invalid unified diff at line %d: %s source disagrees with diff --git source from line %d", lineNumber, pendingHeader, diffHeaderLine) + } case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "): - to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to "))) + toHeader := "rename" + if strings.HasPrefix(raw, "copy to ") { + toHeader = "copy" + } + to, ok := sandbox.ExtendedGitHeaderPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")) + if !ok { + return nil, fmt.Errorf("invalid unified diff at line %d: destination path cannot be interpreted exactly", lineNumber) + } + to = filepath.ToSlash(to) if pendingFrom == "" || to == "" { return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber) } + if pendingHeader != toHeader { + return nil, fmt.Errorf("invalid unified diff at line %d: mismatched %s from/%s to headers", lineNumber, pendingHeader, toHeader) + } + if diffHeaderLine != 0 { + matches := pendingFrom == diffOldPath && to == diffNewPath + if !diffPathsOK { + // Git's --no-prefix output does not quote ordinary spaces in + // diff --git operands. Extended headers delimit those paths, + // allowing their exact concatenation to resolve the ambiguity. + matches = diffOperands == pendingFrom+" "+to + if matches { + diffOldPath, diffNewPath, diffPathsOK = pendingFrom, to, true + } + } + if !matches { + return nil, fmt.Errorf("invalid unified diff at line %d: %s paths disagree with diff --git paths from line %d", lineNumber, toHeader, diffHeaderLine) + } + } if err := finish(); err != nil { return nil, err } current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber} - oldPath, newPath, pendingFrom = pendingFrom, to, "" + oldPath, newPath, pendingFrom, pendingHeader = pendingFrom, to, "", "" case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"): return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber) case strings.HasPrefix(raw, "--- "): - oldPath = stripPatchPrefix(patchFileHeaderPath(raw)) + path, ok := patchFileHeaderPath(raw) + if !ok { + return nil, fmt.Errorf("invalid unified diff at line %d: path in --- header cannot be interpreted exactly", lineNumber) + } + oldPath = stripPatchPrefix(path) newPath = "" if oldPath == "" { return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber) } case strings.HasPrefix(raw, "+++ "): - newPath = stripPatchPrefix(patchFileHeaderPath(raw)) + path, ok := patchFileHeaderPath(raw) + if !ok { + return nil, fmt.Errorf("invalid unified diff at line %d: path in +++ header cannot be interpreted exactly", lineNumber) + } + newPath = stripPatchPrefix(path) if newPath == "" { return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber) } @@ -304,37 +380,6 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { return operations, nil } -// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y" -// line, handling git's C-quoted form; "" when it cannot be determined. -func diffGitNewPath(line string) string { - rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git ")) - if strings.HasPrefix(rest, "\"") { - // Two quoted tokens: skip the first, unquote the second. - end := strings.Index(rest[1:], "\"") - for end > 0 && rest[end] == '\\' { - next := strings.Index(rest[end+2:], "\"") - if next < 0 { - return "" - } - end += next + 2 - } - if end < 0 || end+2 > len(rest) { - return "" - } - rest = strings.TrimSpace(rest[end+2:]) - return stripPatchPrefix(unquoteGitPath(rest)) - } - fields := strings.Fields(rest) - if len(fields) < 2 { - return "" - } - last := fields[len(fields)-1] - if strings.HasPrefix(last, "\"") { - return stripPatchPrefix(unquoteGitPath(last)) - } - return stripPatchPrefix(last) -} - // parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing // count means 1 per unified-diff convention. func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) { diff --git a/internal/tools/view_image.go b/internal/tools/view_image.go index ba909a9c7..11190d368 100644 --- a/internal/tools/view_image.go +++ b/internal/tools/view_image.go @@ -2,6 +2,7 @@ package tools import ( "context" + "os" "github.com/Gitlawb/zero/internal/imageinput" "github.com/Gitlawb/zero/internal/zeroruntime" @@ -62,9 +63,23 @@ func (tool viewImageTool) Run(_ context.Context, args map[string]any) Result { if err != nil { return errorResult("Error: " + err.Error()) } - image, err := imageinput.LoadFile(absolutePath, tool.workspaceRoot) + // Preserve the loader's early non-regular-file rejection (notably FIFOs). + // The consumed handle is validated again below; this is only a fast check. + info, err := os.Stat(absolutePath) if err != nil { - // LoadFile's errors already name the problem (missing, too large, + return errorResult("Error viewing " + relativePath + ": " + err.Error()) + } + if !info.Mode().IsRegular() { + return errorResult("Error viewing " + relativePath + ": image file must be a regular file") + } + file, _, err := ProtectedReadOpen(absolutePath, tool.workspaceRoot) + if err != nil { + return errorResult("Error viewing " + relativePath + ": " + err.Error()) + } + defer file.Close() + image, err := imageinput.LoadOpenFile(file, absolutePath, tool.workspaceRoot) + if err != nil { + // The loader's errors already name the problem (missing, too large, // unsupported type) and are safe to show: they carry the path the caller // asked for, which it already knows. return errorResult("Error viewing " + relativePath + ": " + err.Error()) diff --git a/internal/tools/workspace.go b/internal/tools/workspace.go index 8b8322b32..1b667ebb1 100644 --- a/internal/tools/workspace.go +++ b/internal/tools/workspace.go @@ -217,6 +217,16 @@ type PathScope interface { Roots() []string } +// rootedScopedPath carries the pathname spellings used for user-facing output +// alongside the root handle boundary used for the actual filesystem operation. +// absolute is canonical and relative is expressed against root. +type rootedScopedPath struct { + absolute string + display string + root string + relative string +} + type readPathScope interface { ReadRoots() []string } @@ -372,30 +382,41 @@ func resolveScopedTargetPath(workspaceRoot string, scope PathScope, requestedPat return "", "", firstErr } -// recheckScopedWriteTarget mirrors recheckWorkspaceWriteTarget across roots. -// Prefix symlinks outside a root are resolved per root (macOS /var aliasing); -// symlinks INSIDE a root stay visible to the single-root checks, so a write -// target may resolve through a symlink only when its final location lies -// inside a DIFFERENT granted root — mirroring sandbox.Scope.validate's -// documented widening. -func recheckScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPath string) error { - if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil { - return recheckWorkspaceWriteTarget(workspaceRoot, requestedPath) +func resolveScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPath string) (rootedScopedPath, error) { + absolute, display, err := resolveScopedTargetPath(workspaceRoot, scope, requestedPath) + if err != nil { + return rootedScopedPath{}, err } roots, err := scopedRoots(workspaceRoot, scope) if err != nil { - return err + return rootedScopedPath{}, err } - var firstErr error - for _, root := range roots { - candidate := sandbox.NormalizePrefixForRoot(requestedPath, root) - err := recheckWorkspaceWriteTarget(root, candidate) - if err == nil { - return nil + root, relative, err := rootedPathWithin(roots, absolute) + if err != nil { + return rootedScopedPath{}, err + } + return rootedScopedPath{absolute: absolute, display: display, root: root, relative: relative}, nil +} + +func rootedPathWithin(roots []string, absolute string) (string, string, error) { + for _, candidate := range roots { + root, err := filepath.Abs(candidate) + if err != nil { + continue } - if firstErr == nil { - firstErr = err + root, err = filepath.EvalSymlinks(root) + if err != nil { + continue + } + relative, err := filepath.Rel(root, absolute) + if err != nil || filepath.IsAbs(relative) || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + continue + } + if relative == "" { + relative = "." } + return root, relative, nil } - return firstErr + return "", "", outsideWorkspaceError(absolute) } diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index 937d0c02a..82a5ec4c2 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" ) @@ -12,6 +11,7 @@ type writeFileTool struct { baseTool workspaceRoot string scope PathScope + formatter writtenFileFormatter readFile func(string) ([]byte, error) } @@ -35,7 +35,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, - readFile: os.ReadFile, + formatter: maybeFormatWrittenFile, } } @@ -57,15 +57,23 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an return errorResult("Error: Invalid arguments for write_file: " + err.Error()) } - absolutePath, relativePath, err := resolveScopedTargetPath(tool.workspaceRoot, tool.scope, requestedPath) + target, err := resolveScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath) if err != nil { return errorResult("Error writing file " + requestedPath + ": " + err.Error()) } + absolutePath, relativePath := target.absolute, target.display + root, err := os.OpenRoot(target.root) + if err != nil { + return errorResult("Error writing file " + relativePath + ": " + err.Error()) + } + defer root.Close() existed := false + writeMode := os.FileMode(0o644) var priorInfo os.FileInfo - if info, err := os.Stat(absolutePath); err == nil { + if info, err := root.Stat(target.relative); err == nil { existed = true + writeMode = info.Mode() priorInfo = info if !overwrite { return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.") @@ -74,11 +82,24 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an return errorResult("Error writing file " + relativePath + ": " + err.Error()) } - // 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. + priorContent := "" + priorContentKnown := !existed if existed { + var current []byte + var rerr error + if tool.readFile != nil { + current, rerr = tool.readFile(absolutePath) + } else { + current, priorInfo, rerr = readRootedFile(root, target.relative) + if rerr != nil { + return errorResult("Error writing file " + relativePath + ": " + rerr.Error()) + } + } + if rerr == nil { + priorContent, priorContentKnown = string(current), true + } + // On overwrite, refuse to clobber a tracked file that changed on disk + // outside Zero since it was last read. if options.FileTracker != nil && !options.FileTracker.SeenWhole(absolutePath) { return errorResult(fileUnseenMessage(relativePath)) } @@ -86,7 +107,6 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an // 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)) } @@ -96,53 +116,31 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an } } - // 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) - priorContentKnown = true - } - } - - if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { - return errorResult("Error writing file " + relativePath + ": " + err.Error()) - } - if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { + if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } var expectedContent *string if priorContentKnown { expectedContent = &priorContent } - if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil { + if err := commitRootedFileContents(root, target.relative, priorInfo, expectedContent, content); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } modelKnownContent := content // Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the // FileTracker baseline: recording pre-format content would make the very // next edit look like an external modification and trip the conflict guard. - restoreMode := os.FileMode(0o644) - if priorInfo != nil { - restoreMode = priorInfo.Mode().Perm() + formatting := tool.formatter(ctx, root, target.relative, absolutePath, tool.workspaceRoot, content, writeMode) + published, newInfo, err := readRootedFile(root, target.relative) + if err != nil { + options.FileTracker.Forget(absolutePath) + return errorResult("Error reading written file " + relativePath + ": " + err.Error()) } - formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, content, restoreMode) - content = formatting.Content - finalContentKnown := formatting.ContentKnown + content = string(published) // Baseline the freshly written content so a later edit/overwrite in this // session compares against what is now on disk. - newInfo := formatting.Info - if newInfo == nil { - newInfo, _ = os.Stat(absolutePath) - } - if finalContentKnown { - options.FileTracker.Record(absolutePath, []byte(content), newInfo) - } else { - options.FileTracker.Forget(absolutePath) - } - if finalContentKnown && content == modelKnownContent { + options.FileTracker.Record(absolutePath, []byte(content), newInfo) + if content == modelKnownContent { options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content)) } if !existed { @@ -161,14 +159,12 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an } summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines) summary += formatting.notice(relativePath) - if finalContentKnown { - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) - } + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) result := okResult(summary) result.ChangedFiles = []string{relativePath} // Do not pretend an unreadable overwrite was a creation. The write may be // valid, but ACP only receives an exact before/after pair we actually saw. - if priorContentKnown && finalContentKnown { + if priorContentKnown { if diff, ok := boundedFileDiff(absolutePath, priorContent, content, existed, true); ok { result.FileDiffs = []FileDiff{diff} } else if diffTextRevealsObfuscatedSecret(priorContent) || diffTextRevealsObfuscatedSecret(content) { @@ -179,7 +175,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an // an overwrite) on Display.Preview. Output stays the summary, so the model never // re-reads the file — the rich preview costs zero model tokens. preview := "" - if priorContentKnown && finalContentKnown { + if priorContentKnown { preview = boundedUnifiedDiff(relativePath, priorContent, content) } result.Display = Display{Summary: summary, Kind: "file", Preview: preview} diff --git a/internal/tui/checkpoint_credentials_test.go b/internal/tui/checkpoint_credentials_test.go new file mode 100644 index 000000000..4d643596c --- /dev/null +++ b/internal/tui/checkpoint_credentials_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Gitlawb/zero/internal/remotetoken" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +func TestTUICheckpointBeforeDeniedCredentialMutation(t *testing.T) { + for _, name := range []string{"write_file", "edit_file"} { + t.Run(name, func(t *testing.T) { + store := testSessionStore(t) + root := t.TempDir() + const secret = "tui-checkpoint-bearer" + token := filepath.Join(root, "token") + if err := os.WriteFile(token, []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(remotetoken.EnvToken, "") + t.Setenv(remotetoken.EnvTokenFile, token) + t.Setenv(remotetoken.EnvTokenFileResolved, "") + t.Setenv(remotetoken.EnvTokenFileIdentity, "") + args := `{"path":"token","content":"replacement","overwrite":true,"old_string":"tui-checkpoint-bearer","new_string":"replacement"}` + provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{ + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "mutate", ToolName: name}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "mutate", ArgumentsFragment: args}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "mutate"}, + {Type: zeroruntime.StreamEventDone}, + }, textScript("finished")}} + registry := tools.NewRegistry() + registry.Register(tools.NewScopedWriteFileTool(root, nil)) + registry.Register(tools.NewScopedEditFileTool(root, nil)) + messages := make(chan tea.Msg, 128) + m := newPermissionTestModel(root, provider, registry, store, nil, messages) + m.input.SetValue("update the file") + updated, cmd := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if cmd == nil { + t.Fatal("agent run did not start") + } + _, _ = next.Update(execCmd(cmd)) + events := readOnlySessionEvents(t, store) + if countSessionEvents(events, sessions.EventToolCall) != 1 { + t.Fatal("mutation callback was not reached") + } + if countSessionEvents(events, sessions.EventToolResult) != 1 { + t.Fatal("mutation result was not recorded") + } + if countSessionEvents(events, sessions.EventSessionCheckpoint) != 0 { + t.Fatal("credential was checkpointed before denied mutation") + } + content, err := os.ReadFile(token) + if err != nil || string(content) != secret { + t.Fatalf("credential mutation was not denied: %v", err) + } + for _, event := range events { + if event.Type == sessions.EventToolResult && !strings.Contains(string(event.Payload), "holds the remote bridge token") { + t.Fatalf("mutation failed for a reason other than credential protection: %s", event.Payload) + } + } + }) + } +}