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 fe691ac4c..f948bac14 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2183,6 +2183,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, @@ -2191,6 +2195,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 f17e9be46..adbf0fd6b 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 TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) { engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()}) call := ToolCall{Name: tools.ExecCommandToolName} diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 6879e9b90..5b76e642a 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -64,6 +64,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 @@ -519,11 +524,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) } @@ -629,7 +633,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, @@ -672,7 +678,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..d11368398 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -2,7 +2,15 @@ package cli import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "errors" + "math/big" + "net" "os" "os/exec" "path/filepath" @@ -36,6 +44,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 +226,83 @@ 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", "") + + 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) + } +} + +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/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/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go new file mode 100644 index 000000000..5b6a2d8ea --- /dev/null +++ b/internal/mcp/daemon_token_test.go @@ -0,0 +1,253 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" + "github.com/Gitlawb/zero/internal/tools" +) + +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..8ab88ef9f 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -2,6 +2,8 @@ package sandbox import ( "errors" + "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -232,8 +234,24 @@ 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, 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 +312,77 @@ 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. + +// 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 b930420f4..e7566239f 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -263,6 +263,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 @@ -454,7 +538,6 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Fatal(err) } } - options := credentialPathOptions{ Homes: []string{home}, ConfigDirs: []string{configDir}, @@ -550,7 +633,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) } @@ -974,7 +1056,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) @@ -1030,7 +1112,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) } @@ -1050,7 +1132,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) } @@ -1081,7 +1163,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) } @@ -1220,7 +1302,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) { @@ -1485,7 +1567,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) } @@ -1569,6 +1651,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") @@ -1678,3 +1844,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 349e2b1c6..2a5c3039b 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 @@ -151,6 +156,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, @@ -207,6 +213,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 @@ -221,15 +228,19 @@ 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 switches the Windows // runner onto the capability-SID/ACL deny path and away from the // WRITE_RESTRICTED token, which the unelevated tier depends on. Revisit // once the Windows deny-read model is settled. // - 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 @@ -260,6 +271,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 { @@ -604,7 +620,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)), @@ -772,13 +788,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) { @@ -791,6 +822,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/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go new file mode 100644 index 000000000..e0900b522 --- /dev/null +++ b/internal/sandbox/protected_credentials_test.go @@ -0,0 +1,984 @@ +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) + } + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, token) + + 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) + } +} + +// 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/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..b8f7a2fbe 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,164 @@ 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 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/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..1f30ef0d7 --- /dev/null +++ b/internal/tools/daemon_token_matrix_test.go @@ -0,0 +1,229 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// 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 dc70b01da..9049b7983 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()) } @@ -150,20 +166,24 @@ 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 := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil { + // The write-side handle is checked before truncation, so a target swapped + // after the read cannot redirect the edit to the token. In-place publication + // preserves the existing inode, ACLs and hard links. + if _, err := writeRootedFile(root, target.relative, absolutePath, tool.workspaceRoot, []byte(updated), readInfo.Mode(), false); 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. - updated = maybeFormatWrittenFile(ctx, absolutePath, updated) + tool.formatter(ctx, root, target.relative, absolutePath, tool.workspaceRoot, updated, readInfo.Mode()) + updated, err = readPublishedContent(root, target.relative, absolutePath, tool.workspaceRoot) + if err != nil { + return errorResult("Error reading written file " + relativePath + ": " + err.Error()) + } // 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, _ := os.Stat(absolutePath) + newInfo, _ := root.Stat(target.relative) 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. diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 62671cbb1..312570a9d 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 diff --git a/internal/tools/format_on_write.go b/internal/tools/format_on_write.go index cb5bc6159..8a66c5d63 100644 --- a/internal/tools/format_on_write.go +++ b/internal/tools/format_on_write.go @@ -2,19 +2,24 @@ package tools import ( "context" + "errors" + "io" "os" "os/exec" "path/filepath" "strings" "time" + + "github.com/Gitlawb/zero/internal/sandbox" ) // 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 runs the language's standard formatter on detached +// content and publishes it through the protected write primitive. Formatters +// retain the original working directory, but settings discovered solely from +// the input file's ancestors may differ because staging is outside the +// workspace. 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 @@ -25,6 +30,8 @@ import ( // hang a tool call. On timeout the unformatted write stands. const formatOnWriteTimeout = 10 * time.Second +type writtenFileFormatter func(context.Context, *os.Root, string, string, string, string, os.FileMode) string + // 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. @@ -65,12 +72,11 @@ func formatOnWriteEnabled() bool { return value != "" && value != "0" && !strings.EqualFold(value, "false") } -// maybeFormatWrittenFile runs the configured formatter for absolutePath (when -// enabled and on PATH) and returns the file's content afterwards. Best-effort -// throughout: any failure — no formatter, formatter error, timeout, unreadable -// result — returns writtenContent so the caller's state matches the last write -// it performed itself. -func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string { +// maybeFormatWrittenFile formats a detached staging file populated only from +// writtenContent. The formatter never receives the destination path. The +// staged result is read from a rooted, credential-checked handle and published +// through the same protected handle primitive as the original write. +func maybeFormatWrittenFile(ctx context.Context, root *os.Root, relativePath, absolutePath, workspaceRoot, writtenContent string, mode os.FileMode) string { if !formatOnWriteEnabled() { return writtenContent } @@ -82,18 +88,71 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon if err != nil { return writtenContent } + // Keep the formatter's input outside the mutable workspace. A stage next + // to the destination could itself be swapped to a token alias before the + // external formatter opens it. CWD remains the original directory for + // formatters that discover project settings from their working directory. + stageDir, err := os.MkdirTemp("", "zero-format-*") + if err != nil { + return writtenContent + } + defer os.RemoveAll(stageDir) + stageRoot, err := os.OpenRoot(stageDir) + if err != nil { + return writtenContent + } + defer stageRoot.Close() + stageRelative := filepath.Base(absolutePath) + stagePath := filepath.Join(stageDir, stageRelative) + stage, err := stageRoot.OpenFile(stageRelative, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return writtenContent + } + if _, err := io.WriteString(stage, writtenContent); err != nil { + stage.Close() + return writtenContent + } + if err := stage.Close(); err != nil { + return writtenContent + } formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout) defer cancel() - arguments := append(append([]string(nil), command[1:]...), absolutePath) + arguments := append(append([]string(nil), command[1:]...), stagePath) formatter := exec.CommandContext(formatCtx, binaryPath, arguments...) formatter.Dir = filepath.Dir(absolutePath) formatter.Stdin = strings.NewReader("") + formatter.Env = sandbox.ScrubSensitiveEnv(os.Environ()) if err := formatter.Run(); err != nil { return writtenContent } - formatted, err := os.ReadFile(absolutePath) + formattedFile, _, err := protectedRootRead(stageRoot, stageRelative, stagePath, workspaceRoot) if err != nil { return writtenContent } + formatted, readErr := io.ReadAll(formattedFile) + closeErr := formattedFile.Close() + if readErr != nil || closeErr != nil { + return writtenContent + } + if _, err := writeRootedFile(root, relativePath, absolutePath, workspaceRoot, formatted, mode, false); err != nil { + return writtenContent + } return string(formatted) } + +// readPublishedContent binds all post-write consumers (tracker, preview and +// diagnostics) to a newly opened, protected handle. In particular, a pathname +// swapped after the initial rooted write cannot make those consumers ingest a +// credential even when formatting is disabled or best-effort formatting stops. +func readPublishedContent(root *os.Root, relativePath, absolutePath, workspaceRoot string) (string, error) { + file, _, err := protectedRootRead(root, relativePath, absolutePath, workspaceRoot) + if err != nil { + return "", err + } + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + return "", errors.Join(readErr, closeErr) + } + return string(content), nil +} diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index acca3e868..c2cb33b40 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -95,9 +96,42 @@ 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") - content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text") + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + content := maybeFormatWrittenFile(context.Background(), root, "notes.xyz", filepath.Join(dir, "notes.xyz"), dir, "raw text", 0o644) if content != "raw text" { t.Fatalf("unknown extension must pass through: %q", content) } @@ -111,8 +145,217 @@ func TestFormatOnWriteFormatterLookupFailure(t *testing.T) { if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil { t.Fatal(err) } - content := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent) + root, err := os.OpenRoot(filepath.Dir(targetPath)) + if err != nil { + t.Fatal(err) + } + defer root.Close() + content := maybeFormatWrittenFile(context.Background(), root, filepath.Base(targetPath), targetPath, filepath.Dir(targetPath), uglyContent, 0o644) if content != uglyContent { t.Fatalf("missing formatter must return written content, got %q", content) } } + +func TestFormatOnWriteUsesDetachedFileAndScrubsSensitiveEnvironment(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 +[ "$1" != "$FORMATTER_ORIGINAL_TARGET" ] || 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 +printf 'formatted\n' > "$1" +` + if err := os.WriteFile(formatter, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + old, existed := formatterCommands[".mock"] + formatterCommands[".mock"] = []string{formatter} + defer func() { + if existed { + formatterCommands[".mock"] = old + } else { + delete(formatterCommands, ".mock") + } + }() + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + 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) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + got := maybeFormatWrittenFile(context.Background(), root, "target.mock", target, dir, "raw\n", 0o644) + if got != "formatted\n" { + t.Fatalf("detached scrubbed formatter result = %q, want formatted content", got) + } + onDisk, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(onDisk) != got { + t.Fatalf("published content = %q, want %q", onDisk, got) + } +} + +func TestFormatOnWriteRejectsDestinationSwapDuringFormatter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX formatter fixture") + } + 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 +printf formatted > "$4" +` + 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() + got := 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 got != want { + t.Fatalf("formatter result = %q, want %q", got, want) + } + if data, err := os.ReadFile(token); err != nil || string(data) != "secret" { + t.Fatalf("formatter changed token: %q, %v", data, err) + } + }) + } +} + +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) { + dir := t.TempDir() + 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 + formatter := func(_ context.Context, _ *os.Root, _, _, _, written string, _ os.FileMode) string { + // 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 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 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) + } + version, tracked := tracker.Version(target) + if toolName == "write" && tracked { + t.Fatal("swapped token alias was recorded by FileTracker") + } + if toolName == "edit" && (!tracked || version.Hash != HashContent([]byte("package ordinary\n\nfunc Old() {}\n"))) { + t.Fatalf("FileTracker consumed swapped alias: tracked=%v version=%+v", tracked, version) + } + 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/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/mutation_targets.go b/internal/tools/mutation_targets.go index 0b352b8ca..9a07b48ae 100644 --- a/internal/tools/mutation_targets.go +++ b/internal/tools/mutation_targets.go @@ -1,6 +1,8 @@ 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 @@ -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..bbec8a1ee 100644 --- a/internal/tools/mutation_targets_test.go +++ b/internal/tools/mutation_targets_test.go @@ -116,3 +116,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..60d403e1f --- /dev/null +++ b/internal/tools/protected_credentials_test.go @@ -0,0 +1,467 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" +) + +// 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, 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, 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 e270a67d6..0a25623ac 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/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 653f0cbb5..7d4bdc3f1 100644 --- a/internal/tools/structured_patch.go +++ b/internal/tools/structured_patch.go @@ -126,14 +126,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 @@ -165,15 +157,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]) } } @@ -394,14 +386,15 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t } change.after = operation.contents case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy: - info, err := root.Stat(from.relative) + file, info, err := protectedRootRead(root, from.relative, from.absolute, root.Name()) if err != nil { - return nil, fmt.Errorf("stating %s: %w", from.relative, err) + return nil, fmt.Errorf("opening %s: %w", from.relative, err) } change.mode = info.Mode() - content, err := root.ReadFile(from.relative) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", from.relative, err) + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + return nil, fmt.Errorf("reading %s: %w", from.relative, errors.Join(readErr, closeErr)) } if err := tracker.CheckConflict(from.absolute, content); err != nil { return nil, fmt.Errorf("%s", fileConflictMessage(from.relative)) @@ -447,6 +440,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 } @@ -890,23 +892,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..1e3280967 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,76 @@ 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 { + 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 +377,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/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 76f5f1baa..2d5d4ab57 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -2,9 +2,10 @@ package tools import ( "context" + "errors" "fmt" + "io" "os" - "path/filepath" "strings" ) @@ -12,6 +13,7 @@ type writeFileTool struct { baseTool workspaceRoot string scope PathScope + formatter writtenFileFormatter } func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { @@ -34,6 +36,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, + formatter: maybeFormatWrittenFile, } } @@ -55,14 +58,22 @@ 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 - if _, err := os.Stat(absolutePath); err == nil { + writeMode := os.FileMode(0o644) + if info, err := root.Stat(target.relative); err == nil { existed = true + writeMode = info.Mode() if !overwrite { return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.") } @@ -70,54 +81,48 @@ 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 := "" if existed { + readFile, _, rerr := protectedRootRead(root, target.relative, absolutePath, tool.workspaceRoot) + if rerr != nil { + return errorResult("Error writing file " + relativePath + ": " + rerr.Error()) + } + current, rerr := io.ReadAll(readFile) + closeErr := readFile.Close() + if rerr != nil || closeErr != nil { + return errorResult("Error writing file " + relativePath + ": " + errors.Join(rerr, closeErr).Error()) + } + priorContent = string(current) + // 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)) } if _, tracked := options.FileTracker.Version(absolutePath); tracked { - // Fail CLOSED: if the tracked file can't be re-read to verify it, refuse - // the overwrite rather than clobbering a file whose current state is - // unknown (it may have been replaced or removed out from under us). - current, rerr := os.ReadFile(absolutePath) - if rerr != nil { - return errorResult(fileConflictMessage(relativePath)) - } if cerr := options.FileTracker.CheckConflict(absolutePath, current); cerr != nil { return errorResult(fileConflictMessage(relativePath)) } } } - // Capture the prior content (before we replace it) so an overwrite can show a - // real diff; a fresh create stays "" and previews as all-additions. - priorContent := "" - if existed { - if prev, rerr := os.ReadFile(absolutePath); rerr == nil { - priorContent = string(prev) - } - } - - if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { + // The lexical check rejects the configured path early. writeRootedFile then + // checks the opened handle before truncating, so a raced alias cannot redirect + // the write to the token. Existing files retain their inode, ACLs and links. + if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } - if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { - return errorResult("Error writing file " + relativePath + ": " + err.Error()) - } - if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil { + if _, err := writeRootedFile(root, target.relative, absolutePath, tool.workspaceRoot, []byte(content), writeMode, !existed); 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. - content = maybeFormatWrittenFile(ctx, absolutePath, content) + tool.formatter(ctx, root, target.relative, absolutePath, tool.workspaceRoot, content, writeMode) + content, err = readPublishedContent(root, target.relative, absolutePath, tool.workspaceRoot) + if err != nil { + return errorResult("Error reading written file " + relativePath + ": " + err.Error()) + } // Baseline the freshly written content so a later edit/overwrite in this // session compares against what is now on disk. - newInfo, _ := os.Stat(absolutePath) + newInfo, _ := root.Stat(target.relative) options.FileTracker.Record(absolutePath, []byte(content), newInfo) if content == modelKnownContent { options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))