From fd1efaa60901316746c86c8c69a63cbf9c0fda9f Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 10 Aug 2026 20:11:50 +0200 Subject: [PATCH 01/23] fix(sandbox): protect daemon token file Co-authored-by: Pierre Bruno --- internal/cli/daemon.go | 7 + internal/cli/daemon_test.go | 81 +++ internal/daemon/remote/auth.go | 61 ++- internal/daemon/remote/auth_test.go | 137 +++++ internal/sandbox/engine.go | 55 +- internal/sandbox/linux_helper.go | 11 +- internal/sandbox/manager.go | 61 ++- internal/sandbox/manager_test.go | 205 +++++++- internal/sandbox/pathlists.go | 268 +++++++++- internal/sandbox/profile.go | 30 +- .../sandbox/protected_credentials_test.go | 452 ++++++++++++++++ internal/sandbox/risk.go | 245 ++++++++- internal/sandbox/runner.go | 55 +- internal/sandbox/runner_test.go | 23 + internal/tools/apply_patch.go | 56 +- internal/tools/apply_patch_paths_test.go | 32 +- internal/tools/bash_auto_allow_test.go | 7 + internal/tools/daemon_token_exclusion_test.go | 488 ++++++++++++++++++ internal/tools/exec_command_test.go | 7 + internal/tools/list_directory.go | 27 +- internal/tools/read_exclusions_test.go | 65 +++ 21 files changed, 2270 insertions(+), 103 deletions(-) create mode 100644 internal/sandbox/protected_credentials_test.go create mode 100644 internal/tools/daemon_token_exclusion_test.go diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 6879e9b90..92b9ab3be 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -64,6 +64,7 @@ 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. 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,6 +520,12 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } + // Pin the token file to the path this process reads BEFORE any worker + // inherits the variable, so a relative or symlinked value cannot make a + // session's sandbox profile protect a different path than the live bearer file. + if err := remote.CanonicalizeTokenFileEnv(); err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } token, err := remote.TokenFromEnv() if err != nil { return writeAppError(stderr, err.Error(), exitCrash) diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index 3e316b8c1..83d7851df 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" @@ -209,3 +217,76 @@ 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") + + 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) + } + want := filepath.Join(startDir, "token") + want, err := filepath.EvalSymlinks(want) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", want, err) + } + if got := os.Getenv("ZERO_DAEMON_REMOTE_TOKEN_FILE"); got != want { + t.Fatalf("ZERO_DAEMON_REMOTE_TOKEN_FILE = %q, want daemon-pinned path %q", got, want) + } +} + +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..b1cedd455 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/Gitlawb/zero/internal/daemon" @@ -66,13 +67,30 @@ func (a *TokenAuthenticator) Authenticate(token string) error { return ErrUnauthorized } +// TokenFilePathFromEnv returns the configured token-file pointer exactly as the +// operator set it, or "" when the variable is unset or holds only whitespace. +// +// The value is a PATHNAME, and every consumer of it — os.ReadFile here, the +// canonicalization below, the sandbox's protected-credential list — must agree +// on which bytes name the file. A filename may legitimately begin or end with a +// space, so trimming the value would make this boundary read one file while the +// deny rules protect another. A value that is only whitespace still reads as +// unset, which is what a blank variable means. +func TokenFilePathFromEnv() string { + configured := os.Getenv(EnvTokenFile) + if strings.TrimSpace(configured) == "" { + return "" + } + return configured +} + // TokenFromEnv resolves the bridge token from EnvToken, or a file named by // EnvTokenFile. It never logs the token. func TokenFromEnv() (string, error) { if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" { return t, nil } - if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" { + if file := TokenFilePathFromEnv(); file != "" { data, err := os.ReadFile(file) if err != nil { return "", fmt.Errorf("remote: read token file: %w", err) @@ -86,6 +104,47 @@ func TokenFromEnv() (string, error) { return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile) } +// CanonicalizeTokenFileEnv rewrites EnvTokenFile in this process's environment +// to the absolute, symlink-resolved path TokenFromEnv actually reads, so every +// child process — and the sandbox profile derived for it — refers to the same +// file this bridge authenticated against. `zero daemon serve-remote` calls it +// before it starts serving. +// +// Two mismatches motivate it. TokenFromEnv passes the value to os.ReadFile, so a +// relative value resolves against the STARTING process's working directory, +// while a worker inherits the same string and resolves it against its own +// session directory — the profile would then protect a path that holds no token +// while the real bearer file stays readable. Resolving symlinks up front also +// keeps a link pathname out of the derived deny rules, which matters because +// bubblewrap cannot mount over a symlink destination. +// +// 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 { + if strings.TrimSpace(os.Getenv(EnvToken)) != "" { + return nil + } + configured := TokenFilePathFromEnv() + if configured == "" { + return nil + } + absolute, err := filepath.Abs(configured) + if err != nil { + return fmt.Errorf("remote: resolve token file %q: %w", configured, err) + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + // TokenFromEnv would fail on the same path a moment later; reporting it here + // names the resolution step that failed. + return fmt.Errorf("remote: resolve token file %q: %w", configured, err) + } + if resolved == configured { + return nil + } + return os.Setenv(EnvTokenFile, resolved) +} + // 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..0ba660df4 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" ) @@ -57,6 +58,142 @@ func TestTokenFromEnv(t *testing.T) { } } +// TestTokenFilePathFromEnvPreservesFilenameWhitespace 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. +func TestTokenFilePathFromEnvPreservesFilenameWhitespace(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 := TokenFilePathFromEnv(); got != configured { + t.Fatalf("TokenFilePathFromEnv() = %q, want the configured pathname %q", got, configured) + } + } + for _, blank := range []string{"", " ", "\t\n"} { + t.Setenv(EnvTokenFile, blank) + if got := TokenFilePathFromEnv(); got != "" { + t.Fatalf("TokenFilePathFromEnv() = %q for a blank value, 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) { + 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 value every child process (and the +// sandbox profile derived for it) inherits to the file this process reads. +func TestCanonicalizeTokenFileEnv(t *testing.T) { + 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 is resolved", 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 != token { + t.Fatalf("%s = %q, want the resolved target %q", EnvTokenFile, got, token) + } + }) + + 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/sandbox/engine.go b/internal/sandbox/engine.go index ed7440780..f179619e4 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -144,19 +144,28 @@ func (engine *Engine) LookupCommandPrefixForSession(toolName string, command []s // don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine // (the matcher's methods treat nil as "exclude 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 + protected := protectedCredentialPaths() + if len(protected) == 0 { + return nil + } + return &ReadExclusions{workspaceRoot: engine.workspaceRoot, protectedRoots: protected} } return &ReadExclusions{ - workspaceRoot: engine.workspaceRoot, - denyRoots: resolvePolicyPaths(policy.DenyRead), - allowRoots: resolvePolicyPaths(policy.AllowRead), + workspaceRoot: engine.workspaceRoot, + denyRoots: resolvePolicyPaths(policy.DenyRead), + allowRoots: resolvePolicyPaths(policy.AllowRead), + protectedRoots: protectedCredentialPaths(), } } @@ -164,13 +173,14 @@ func (engine *Engine) ReadExclusions() *ReadExclusions { // 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 +193,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 +209,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 @@ -318,6 +335,24 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision { risk := classifyWithScope(request, scope) 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 { diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index f3ea5c457..8ddecedfb 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "sort" "strings" ) @@ -398,10 +399,18 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { } func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - path = normalizeProfilePath(path) + if !slices.Contains(protectedCredentialPaths(), path) { + path = normalizeProfilePath(path) + } if path == "" { return args } + // Bubblewrap cannot mount over a symlink destination. Protected daemon-token + // paths include both the lexical link and its resolved target; the target is + // materialized by its own entry, while Seatbelt can still deny both names. + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return args + } if info, err := os.Stat(path); err == nil && !info.IsDir() { return append(args, "--ro-bind", "/dev/null", path) } diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 44fd6f0a0..3a7bcbda4 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -228,8 +228,15 @@ 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" && + protectedCredentialInWritableMacOSRoot(profile, protectedCredentials) { + return SandboxExecutionRequest{}, errors.New("macOS sandbox cannot protect the remote token file inside a shell-writable root; move the token file outside the workspace and temporary directories") } // Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup` // (it applies WFP network filters + workspace ACLs and writes a marker). @@ -290,6 +297,56 @@ func policyHasExplicitDeny(policy Policy) bool { return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0 } +func protectedCredentialInWritableMacOSRoot(profile PermissionProfile, protected []string) bool { + if len(protected) == 0 { + return false + } + if profile.FileSystem.Kind == FileSystemUnrestricted { + return 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 + } + for _, root := range writeRoots { + if pathWithinMacOSRoot(root, credential) { + return true + } + } + } + return false +} + +func pathWithinMacOSRoot(root, candidate string) bool { + if pathWithinRoot(root, candidate) || pathWithinRoot(strings.ToLower(root), strings.ToLower(candidate)) { + return true + } + rootInfo, err := os.Stat(root) + if err != nil { + return false + } + for current := candidate; ; current = filepath.Dir(current) { + if info, err := os.Stat(current); err == nil && os.SameFile(rootInfo, info) { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + } +} + func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) { execRequest, err := manager.BuildExecutionRequest(request) if err != nil { diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index b930420f4..906f70fd9 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -263,6 +263,126 @@ 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 TestSandboxManagerRejectsMacOSTokenInsideWritableWorkspace(t *testing.T) { + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, "/workspace/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: PermissionProfileFromPolicy(workspace, policy, nil), + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil || !strings.Contains(err.Error(), "inside a shell-writable root") { + t.Fatalf("BuildCommandPlan error = %v, want macOS writable-token failure", err) + } +} + +func TestProtectedCredentialInWritableMacOSRootMatchesSeatbeltWrites(t *testing.T) { + restricted := PermissionProfile{FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: "/Users/Test/Workspace"}}, + }} + if !protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/users/test/workspace/token")}) { + t.Fatal("case-variant token under a macOS write root should be rejected") + } + if protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/Users/Test/Credentials/token")}) { + t.Fatal("token outside every macOS write root should remain allowed") + } + restricted.FileSystem.AllowTemp = true + if !protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/private/tmp/bridge-token")}) { + t.Fatal("token under an allowed temporary root should be rejected") + } + unrestricted := PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted}} + if !protectedCredentialInWritableMacOSRoot(unrestricted, []string{"/credentials/token"}) { + t.Fatal("an unrestricted macOS filesystem makes every token path shell-writable") + } + + writable := t.TempDir() + targetDir := t.TempDir() + target := filepath.Join(targetDir, "token") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(writable, "token-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + symlinkProfile := PermissionProfile{FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: writable}}, + }} + if !protectedCredentialInWritableMacOSRoot(symlinkProfile, []string{link, target}) { + t.Fatal("selected symlink inside a write root must be rejected even when its target is outside") + } +} + +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,12 +574,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Fatal(err) } } + daemonTokenFile := filepath.Join(home, "daemon-token") + if err := os.WriteFile(daemonTokenFile, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } options := credentialPathOptions{ Homes: []string{home}, ConfigDirs: []string{configDir}, CloudSDKConfigDirs: []string{gcloudDir}, GoogleCredentials: []string{keyFile}, + DaemonTokenFiles: []string{daemonTokenFile}, NPMUserConfigs: []string{npmrc}, GHConfigDirs: []string{filepath.Dir(ghHosts)}, Netrcs: []string{netrc}, @@ -474,6 +599,7 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { awsDir, gcloudDir, keyFile, + daemonTokenFile, npmrc, ghHosts, netrc, @@ -533,7 +659,7 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { } // An explicit AllowRead entry covering a store is an opt-out. - optedOut := credentialDenyReadPathsIn(options, []string{awsDir, zeroDir}) + optedOut := credentialDenyReadPathsIn(options, []string{awsDir, zeroDir, daemonTokenFile}) if stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{awsDir})[0]) { t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut.Paths) } @@ -550,6 +676,13 @@ 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) } + // The bridge bearer token is the exception: the in-process tool boundary + // (protectedCredentialPaths) refuses to re-include it through AllowRead, so + // the OS-sandbox profile must not either — otherwise the guarantee would + // depend on whether a wrapped shell command or a built-in tool reads it. + if !stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{daemonTokenFile})[0]) { + t.Errorf("credential deny paths = %#v, want the daemon token file denied despite AllowRead", optedOut.Paths) + } if got := credentialDenyReadPathsIn(credentialPathOptions{}, nil); len(got.Paths) != 0 { t.Errorf("credential deny paths for blank home = %#v, want none", got.Paths) @@ -1569,6 +1702,50 @@ 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 := normalizeProfilePaths([]string{tokenFile})[0] + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want the absent token path %q still denied", profile.FileSystem.DenyReadIfExists, 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) + } + + // Bubblewrap has no target to bind for a missing path, so the fallback must + // still produce an unreadable, unwritable mount rather than nothing. + args := appendUnreadableLinuxPathArgs(nil, want, nil) + if !slices.Contains(args, "--tmpfs") || !slices.Contains(args, want) { + t.Fatalf("bubblewrap args for the absent token = %#v, want an unreadable tmpfs at %q", args, want) + } +} + func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) { root := t.TempDir() configHome := filepath.Join(root, "xdg") @@ -1678,3 +1855,29 @@ func TestCredentialPathOptionsFromEnvironmentResolvesToolPathLists(t *testing.T) t.Errorf("credential deny paths = %#v, want absolute KUBECONFIG entry %q", credentials.Paths, absoluteKubeConfig) } } + +func TestPermissionProfileDeniesDaemonTokenFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read paths are disabled on Windows pending the ACL model") + } + 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) + want := normalizeProfilePaths([]string{tokenFile})[0] + if !stringSliceContains(profile.FileSystem.DenyRead, want) { + t.Fatalf("DenyRead = %#v, want daemon token file %q", profile.FileSystem.DenyRead, 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) + if stringSliceContains(profile.FileSystem.DenyRead, want) { + t.Fatalf("DenyRead = %#v, must not protect unused token file %q when the inline token takes precedence", profile.FileSystem.DenyRead, want) + } +} diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index f6815a2c7..2612b2937 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "runtime" "strings" ) @@ -41,6 +42,208 @@ func resolvePolicyPath(entry string) (string, bool) { return resolved, true } +// These name the alternative sources of the remote bridge's bearer token. They +// are duplicated from internal/daemon/remote (which cannot be imported here) +// exactly like the copies scrubSensitiveEnv keeps. +const ( + daemonRemoteTokenEnv = "ZERO_DAEMON_REMOTE_TOKEN" + daemonRemoteTokenFileEnv = "ZERO_DAEMON_REMOTE_TOKEN_FILE" +) + +// selectedDaemonRemoteTokenFile returns the token-file pointer only when the +// daemon would use it. TokenFromEnv gives the inline token precedence, so an +// inherited file pointer is not a credential when both variables are set. +// +// The pointer is used verbatim, mirroring remote.TokenFilePathFromEnv: the +// daemon reads whatever bytes the variable names, so trimming whitespace here +// would leave a token file whose name begins or ends with a space unprotected +// while the daemon still reads it. Only a value that is entirely whitespace +// counts as unset. +func selectedDaemonRemoteTokenFile() string { + if strings.TrimSpace(os.Getenv(daemonRemoteTokenEnv)) != "" { + return "" + } + configured := os.Getenv(daemonRemoteTokenFileEnv) + if strings.TrimSpace(configured) == "" { + return "" + } + return configured +} + +// protectedCredentialPaths returns credential files that Zero's own in-process +// file tools must never read or modify, independent of Policy. +// +// This is deliberately separate from credentialDenyReadPaths, which only shapes +// the OS sandbox profile for wrapped shell commands: read_file resolves scoped +// paths itself, and grep/glob build their exclusions from the policy, so a +// profile-only rule leaves the in-process tool boundary open. It is also +// separate from Policy.DenyRead, whose emptiness gates escalated (unsandboxed) +// execution and must keep reflecting user configuration alone. +// +// Entries here are NOT re-includable through AllowRead/AllowWrite or a +// session/turn permission profile: the bridge bearer token grants control of +// this daemon, so a remote-controlled agent must not be able to read it, nor +// replace it to hijack the next bridge start, even when the file sits inside +// its own session workspace. Unlike the profile list this applies on Windows +// too, where filesystem deny-read has no sandbox representation (#662). +// +// Both the selected pathname and the target it currently resolves to are +// protected: `zero daemon serve-remote` canonicalizes the value it selects (see +// remote.CanonicalizeTokenFileEnv), but a symlinked selected value inherited +// from elsewhere must not leave the link replaceable. +func protectedCredentialPaths() []string { + // os.ReadFile — the daemon's own reader — treats the value literally, so a + // relative path resolves against the working directory and a leading "~" is + // NOT expanded. resolvePolicyPath would expand it and protect the wrong file. + return daemonTokenDenyPaths(selectedDaemonRemoteTokenFile()) +} + +// daemonTokenDenyPaths is the shared pathname authority for both the +// in-process boundary and OS sandbox profiles. Filename whitespace is data: +// only an entirely blank setting is unset, matching the daemon reader. +func daemonTokenDenyPaths(configured string) []string { + if strings.TrimSpace(configured) == "" { + return nil + } + absolute, err := filepath.Abs(configured) + if err != nil { + return nil + } + paths := []string{absolute} + if resolved, err := filepath.EvalSymlinks(absolute); err == nil && resolved != absolute { + paths = append(paths, resolved) + } + return dedupeStrings(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 { + if len(protected) == 0 { + return false + } + for _, entry := range protected { + if pathUnderProtectedRoot(path, entry, workspaceRoot) { + 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). + // + // This inode-level closure is specific to Zero's in-process tools, which see + // every requested path before opening it. 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 + } + for _, entry := range protected { + protectedInfo, err := os.Stat(entry) + if err == nil && os.SameFile(requestInfo, protectedInfo) { + return true + } + } + return false +} + +// protectedPathFoldsCase reports whether a case-variant spelling of a path opens +// the SAME file on this platform, so the protected-credential comparison must +// fold case to stay closed. +// +// pathWithinRoot ends in filepath.Rel, which ALREADY folds case on Windows +// (path_windows.go's sameWord uses strings.EqualFold) but never on Unix. macOS +// volumes are case-insensitive by default (APFS), so without folding a request +// for `.../Bridge-Token` misses a protected `.../bridge-token` while the OS +// opens the very same bearer-token file. Windows is listed too so the guarantee +// does not silently depend on a filepath.Rel implementation detail. +// +// Case-sensitive outliers (a case-sensitive APFS volume) only make this +// over-deny a genuinely different file that happens to differ by case alone, +// which is the safe direction for a credential the sandbox must never expose. +func protectedPathFoldsCase() bool { + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" +} + +// pathUnderProtectedRoot is pathUnderPolicyRoot for the automatic credential +// exclusions: identical anchoring and symlink normalization, plus the platform's +// filesystem case semantics. Only the final containment comparison folds — the +// normalization above it keeps operating on the path as spelled, so symlink +// resolution is unaffected. +func pathUnderProtectedRoot(requestedPath, root, workspaceRoot string) bool { + normalized, ok := normalizePathForPolicyRoot(requestedPath, root, workspaceRoot) + if !ok { + return false + } + if pathWithinRoot(root, normalized) { + return true + } + if !protectedPathFoldsCase() { + return false + } + return pathWithinRoot(strings.ToLower(root), strings.ToLower(normalized)) +} + // 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 +295,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 @@ -151,20 +365,28 @@ type ReadExclusions struct { workspaceRoot string denyRoots []string allowRoots []string + // protectedRoots are the automatic credential exclusions + // (protectedCredentialPaths); AllowRead never re-includes them. + protectedRoots []string } -// 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 protectedPathDenied(rx.protectedRoots, rx.workspaceRoot, path) { + return true + } return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) } @@ -176,6 +398,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 protectedPathDenied(rx.protectedRoots, rx.workspaceRoot, path) { + return true + } if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) { return false } @@ -212,6 +439,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 +489,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 +502,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 +571,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..59137625e 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -221,15 +221,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 @@ -437,11 +441,16 @@ func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) crede kubeConfigs = append(kubeConfigs, filepath.Join(home, ".kube", "config")) } } + var daemonTokenFiles []string + if strings.TrimSpace(credentialEnvValue(env, "ZERO_DAEMON_REMOTE_TOKEN")) == "" { + daemonTokenFiles = daemonTokenDenyPaths(credentialEnvValue(env, "ZERO_DAEMON_REMOTE_TOKEN_FILE")) + } return credentialPathOptions{ Homes: homes, ConfigDirs: dedupeStrings(configDirs), CloudSDKConfigDirs: dedupeStrings(cloudSDKConfigDirs), GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs), + DaemonTokenFiles: daemonTokenFiles, NPMUserConfigs: dedupeStrings(npmUserConfigs), GHConfigDirs: dedupeStrings(ghConfigDirs), Netrcs: dedupeStrings(netrcs), @@ -469,6 +478,7 @@ type credentialPathOptions struct { ConfigDirs []string CloudSDKConfigDirs []string GoogleCredentials []string + DaemonTokenFiles []string NPMUserConfigs []string GHConfigDirs []string Netrcs []string @@ -595,14 +605,26 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string // atomic writer or create publication directories it never uses. candidates = append(candidates, tokenPath, tokenPath+".migrated") } + // The bridge bearer token grants control of this daemon, so unlike the + // opt-outable credential stores above it stays denied even when AllowRead + // covers it — matching protectedCredentialPaths at the in-process boundary. + mandatory := dedupeStrings(options.DaemonTokenFiles) allowRoots := normalizeProfilePaths(allowRead) - out := make([]string, 0, len(candidates)) + out := make([]string, 0, len(candidates)+len(mandatory)) for _, path := range normalizeProfilePaths(candidates) { if credentialPathReincluded(allowRoots, path) { continue } out = append(out, path) } + // Unlike the candidates above, the token path is NOT existence-filtered. The + // entry protects a future write target as much as a current secret: after an + // external rotation deletes the file, dropping the rule would let a sandboxed + // process recreate it with an attacker-chosen bearer that the next + // `serve-remote` start would then accept. Each enforcing backend materializes + // a missing target fail-closed. + out = append(out, mandatory...) + out = dedupeStrings(out) return credentialDenyPaths{ Paths: out, Carveouts: credentialCarveoutPaths(out, carveouts), diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go new file mode 100644 index 000000000..b85d35741 --- /dev/null +++ b/internal/sandbox/protected_credentials_test.go @@ -0,0 +1,452 @@ +package sandbox + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// 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) + } + } + }) +} + +// 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 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 ") + 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) + } + } + + args := appendUnreadableLinuxPathArgs(nil, token, nil) + if !stringSliceContains(args, "--tmpfs") || !stringSliceContains(args, token) { + t.Fatalf("bubblewrap args = %#v, want unreadable tmpfs at exact pathname %q", args, token) + } + + if !protectedCredentialInWritableMacOSRoot(profile, protectedCredentialPaths()) { + t.Fatalf("spaced token %q under writable root %q must fail macOS preflight", token, workspace) + } +} + +func TestUnreadableLinuxPathSkipsProtectedSymlinkDestination(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) + } + if got := appendUnreadableLinuxPathArgs(nil, link, nil); len(got) != 0 { + t.Fatalf("bubblewrap args for symlink destination = %#v, want no mount", got) + } + if got := appendUnreadableLinuxPathArgs(nil, target, nil); !stringSliceContains(got, target) { + t.Fatalf("bubblewrap args for resolved target = %#v, want target bind", got) + } +} + +// 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") + } +} + +// TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems covers the +// bypass a case-variant spelling opened: pathWithinRoot ends in filepath.Rel, +// which folds case on Windows but NOT on darwin, whose default APFS volume is +// case-insensitive — so `.../BRIDGE-TOKEN` missed the protected `.../bridge-token` +// while the OS opened the same bearer-token file. On a case-sensitive filesystem +// the variant is a genuinely different file and must stay unblocked. +func TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems(t *testing.T) { + ws, token := protectedTokenFixture(t) + variant := filepath.Join(filepath.Dir(token), strings.ToUpper(filepath.Base(token))) + if variant == token { + t.Fatalf("fixture token %q has no case variant", token) + } + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowRead: []string{ws}, AllowWrite: []string{ws}} + wantDenied := protectedPathFoldsCase() + + 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) + } + // The exact spelling is denied on every platform regardless. + if block := validatePathWithPolicy(scope, policy, SideEffectRead, true, ws, token); block == nil { + t.Fatalf("the configured token path %q must always be denied", token) + } +} + +// 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) + } +} diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index a37868c63..441463025 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -261,7 +261,40 @@ func applyPatchRequestPaths(args map[string]any) []string { } cwd := firstArgString(args, "cwd") var paths []string - for _, path := range applyPatchPaths(patch) { +1: for _, path := range PatchHeaderPaths(patch) { +2: for _, path := range PatchHeaderPaths(patch) { +3: // PatchHeaderPaths returns every source and destination path declared by a +// patch. Keeping this parser shared with apply_patch ensures the sandbox gate +// and the tool's own path validation agree on what the executor will read or write. +func PatchHeaderPaths(patch string) []string { + if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { + return structuredPatchHeaderPaths(patch) + } + 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 + } + } + } + return paths +} + +func patchHeaderPaths(patch string) []string { if path == "" || path == "/dev/null" { continue } @@ -281,7 +314,40 @@ func applyPatchPathBlock(request Request) *pathBlock { if patch == "" { return nil } - for _, path := range applyPatchPaths(patch) { +1: for _, path := range PatchHeaderPaths(patch) { +2: for _, path := range PatchHeaderPaths(patch) { +3: // PatchHeaderPaths returns every source and destination path declared by a +// patch. Keeping this parser shared with apply_patch ensures the sandbox gate +// and the tool's own path validation agree on what the executor will read or write. +func PatchHeaderPaths(patch string) []string { + if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { + return structuredPatchHeaderPaths(patch) + } + 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 + } + } + } + return paths +} + +func patchHeaderPaths(patch string) []string { if path == "" || path == "/dev/null" { continue } @@ -296,7 +362,12 @@ func applyPatchPathBlock(request Request) *pathBlock { return nil } -func applyPatchPaths(patch string) []string { +1: for _, path := range PatchHeaderPaths(patch) { +2: for _, path := range PatchHeaderPaths(patch) { +3: // PatchHeaderPaths returns every source and destination path declared by a +// patch. Keeping this parser shared with apply_patch ensures the sandbox gate +// and the tool's own path validation agree on what the executor will read or write. +func PatchHeaderPaths(patch string) []string { if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { return structuredPatchHeaderPaths(patch) } @@ -345,23 +416,178 @@ func patchHeaderPaths(patch string) []string { 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])) + if source, destination, ok := parseDiffGitPaths(line[len("diff --git "):]); ok { + paths = append(paths, stripPatchPrefix(source), stripPatchPrefix(destination)) + } + case strings.HasPrefix(line, "copy from "): + if path, ok := parseExtendedGitPath(line[len("copy from "):]); ok { + paths = append(paths, filepath.ToSlash(path)) + } + case strings.HasPrefix(line, "copy to "): + if path, ok := parseExtendedGitPath(line[len("copy to "):]); ok { + paths = append(paths, filepath.ToSlash(path)) + } + case strings.HasPrefix(line, "rename from "): + if path, ok := parseExtendedGitPath(line[len("rename from "):]); ok { + paths = append(paths, filepath.ToSlash(path)) + } + case strings.HasPrefix(line, "rename to "): + if path, ok := parseExtendedGitPath(line[len("rename to "):]); ok { + paths = append(paths, filepath.ToSlash(path)) + } + case strings.HasPrefix(line, "rename old "): + if path, ok := parseExtendedGitPath(line[len("rename old "):]); ok { + paths = append(paths, filepath.ToSlash(path)) + } + case strings.HasPrefix(line, "rename new "): + if path, ok := parseExtendedGitPath(line[len("rename new "):]); ok { + paths = append(paths, filepath.ToSlash(path)) } 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])) + if path := patchFileHeaderPath(line); path != "" { + paths = append(paths, stripPatchPrefix(path)) } } } return paths } +func parseDiffGitPaths(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "\"") { + source, rest, ok := consumeGitPath(line) + if !ok { + return "", "", false + } + destination, ok := parseWholeGitPath(rest) + return source, destination, ok + } + + // When only the destination is quoted, its opening quote uniquely separates + // the two operands even if the unquoted source contains ordinary spaces. + for separator := strings.Index(line, " \""); separator >= 0; { + if destination, ok := parseWholeGitPath(line[separator+1:]); ok { + return line[:separator], destination, true + } + next := strings.Index(line[separator+1:], " \"") + if next < 0 { + break + } + separator += next + 1 + } + + // Git commonly leaves ordinary spaces unquoted. For a non-rename diff, the + // synthetic a/ and b/ operands name the same path, which identifies the split + // without treating a filename space as an operand separator. + for separator := 0; separator < len(line); { + next := strings.IndexByte(line[separator:], ' ') + if next < 0 { + break + } + separator += next + source, destination := line[:separator], strings.TrimLeft(line[separator:], " ") + if strings.HasPrefix(source, "a/") && strings.HasPrefix(destination, "b/") && source[2:] == destination[2:] { + return source, destination, true + } + separator++ + } + + // Different unquoted names are disambiguated by the copy/rename headers. Keep + // the simple no-space form for ordinary diffs with distinct path operands. + fields := strings.Fields(line) + if len(fields) == 2 { + return fields[0], fields[1], true + } + return "", "", false +} + +func parseWholeGitPath(input string) (string, bool) { + input = strings.TrimSpace(input) + if input == "" { + return "", false + } + if input[0] != '"' { + return input, true + } + path, rest, ok := consumeGitPath(input) + return path, ok && strings.TrimSpace(rest) == "" +} + +// 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, _, ok := consumeGitPath(input) + return path, ok +} + +// 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) { + input = strings.TrimLeft(input, " \t") + if input == "" { + return "", "", false + } + if input[0] != '"' { + end := strings.IndexAny(input, " \t") + if end < 0 { + return input, "", true + } + return input[:end], input[end:], true + } + + escaped := false + for i := 1; i < len(input); i++ { + switch { + 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 "", "", false +} + +// 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 { + rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes + if tab := strings.IndexByte(rest, '\t'); tab >= 0 { + rest = rest[:tab] + } + if strings.HasPrefix(rest, `"`) { + // A quoted operand ends at its closing quote; git allows nothing but the + // (already removed) timestamp after it, so a trailing remainder means the + // line is not the quoted form and the raw bytes are the pathname. + if path, remainder, ok := consumeGitPath(rest); ok && remainder == "" { + return path + } + } + return rest +} + func parsePatchHunkCounts(line string) (int, int) { _, rest, ok := strings.Cut(line, "@@") if !ok { @@ -394,7 +620,6 @@ func patchHunkCount(spec string) int { } 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..b12a611dc 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 name created +// afterwards for the same inode, most obviously a hard link made by a sandboxed +// shell, is not covered by any of them. That applies equally to a +// user-configured DenyRead and to the automatic remote-bridge-token deny; the +// 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")`, diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index e707e2397..c41da3b2d 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -499,6 +499,13 @@ 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+`"))`) { + 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 +637,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 @@ -853,6 +875,7 @@ func TestScrubSensitiveEnv(t *testing.T) { "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/tools/apply_patch.go b/internal/tools/apply_patch.go index 2a74ae90a..fac700b85 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/Gitlawb/zero/internal/sandbox" ) type applyPatchTool struct { @@ -88,7 +90,7 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a if options.FileTracker != nil { createdTargets = missingPatchTargets(applyRoot, patch) fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch) - for _, path := range patchHeaderPaths(patch) { + for _, path := range sandbox.PatchHeaderPaths(patch) { if path == "" || path == "/dev/null" { continue } @@ -147,7 +149,7 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a func missingPatchTargets(root string, patch string) []string { seen := map[string]bool{} var missing []string - for _, path := range patchHeaderPaths(patch) { + for _, path := range sandbox.PatchHeaderPaths(patch) { if path == "" || path == "/dev/null" { continue } @@ -243,7 +245,7 @@ func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) { func changedFilesFromPatch(relativeRoot string, patch string) []string { seen := map[string]bool{} var paths []string - for _, path := range patchHeaderPaths(patch) { + for _, path := range sandbox.PatchHeaderPaths(patch) { if path == "" || path == "/dev/null" { continue } @@ -261,7 +263,7 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string { } func validatePatchPaths(root string, patch string) error { - for _, path := range patchHeaderPaths(patch) { + for _, path := range sandbox.PatchHeaderPaths(patch) { if path == "" || path == "/dev/null" { continue } @@ -276,7 +278,7 @@ func validatePatchPaths(root string, patch string) error { } func recheckPatchWriteTargets(root string, patch string) error { - for _, path := range patchHeaderPaths(patch) { + for _, path := range sandbox.PatchHeaderPaths(patch) { if path == "" || path == "/dev/null" { continue } @@ -287,50 +289,6 @@ func recheckPatchWriteTargets(root string, patch string) error { 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 "" diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index 2d255a358..d5b8bb766 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -3,6 +3,8 @@ package tools import ( "slices" "testing" + + "github.com/Gitlawb/zero/internal/sandbox" ) func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { @@ -13,7 +15,7 @@ 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 := sandbox.PatchHeaderPaths(patch) if !slices.Contains(got, "dir/file name.go") { t.Fatalf("quoted spaced path not extracted: %v", got) } @@ -21,7 +23,7 @@ func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { func TestPatchHeaderPathsUnspacedStillWorks(t *testing.T) { patch := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n" - got := patchHeaderPaths(patch) + got := sandbox.PatchHeaderPaths(patch) if !slices.Contains(got, "x.go") { t.Fatalf("plain path not extracted: %v", got) } @@ -30,3 +32,29 @@ 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 TestPatchHeaderPathsPreservesSurroundingSpacesInNames(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 := sandbox.PatchHeaderPaths(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 TestPatchHeaderPathsHandlesCQuotedDiffGitOperands(t *testing.T) { + patch := "diff --git \"a/bridge\\040token\" \"b/exposed\\tcopy\"\n" + got := sandbox.PatchHeaderPaths(patch) + want := []string{"bridge token", "exposed\tcopy"} + if !slices.Equal(got, want) { + t.Fatalf("C-quoted diff --git operands = %q, want %q", got, want) + } +} 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..472defb17 --- /dev/null +++ b/internal/tools/daemon_token_exclusion_test.go @@ -0,0 +1,488 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "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() + 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, "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) + + 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 + }{ + { + 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, + }, + { + 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, + 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) + } + }) + } +} 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/list_directory.go b/internal/tools/list_directory.go index dd764bd6f..008e69e5a 100644 --- a/internal/tools/list_directory.go +++ b/internal/tools/list_directory.go @@ -46,14 +46,14 @@ 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, readExcluder{}, true) } -func (tool listDirectoryTool) RunWithOptions(_ context.Context, args map[string]any, _ RunOptions) Result { - return tool.run(args, false) +func (tool listDirectoryTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result { + return tool.run(args, sandboxReadExcluder(options.Sandbox), 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 +80,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 +95,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 +112,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/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) + } +} From 8b1c61785ea8df232d10933fbc63107dd55277df Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 11 Aug 2026 12:56:13 +0000 Subject: [PATCH 02/23] fix(sandbox): close daemon token review gaps Co-authored-by: Pierre Bruno Amp-Thread-ID: https://ampcode.com/threads/T-019ff0ba-ceda-71ea-84a2-2dd1371fdac9 --- internal/sandbox/engine.go | 9 +- internal/sandbox/export_test.go | 1 + internal/sandbox/linux_helper.go | 100 +++++- internal/sandbox/linux_helper_test.go | 47 ++- internal/sandbox/manager_test.go | 115 +++++-- internal/sandbox/profile.go | 59 ++-- .../sandbox/protected_credentials_test.go | 23 +- internal/sandbox/risk.go | 320 +++++++++++------- internal/tools/apply_patch.go | 34 +- internal/tools/apply_patch_paths_test.go | 34 +- internal/tools/daemon_token_exclusion_test.go | 112 +++--- internal/tools/mutation_targets.go | 13 +- 12 files changed, 572 insertions(+), 295 deletions(-) diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index f179619e4..f2b4d781c 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -333,6 +333,12 @@ 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: the automatic daemon-token boundary still applies + // to in-process tools when user sandboxing is off. + 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 @@ -387,9 +393,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/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/linux_helper.go b/internal/sandbox/linux_helper.go index 8ddecedfb..78eac72dd 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "runtime" - "slices" "sort" "strings" ) @@ -189,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) @@ -222,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, ", ")) } @@ -245,12 +250,48 @@ 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 { + baseline[path] = struct{}{} + } + mandatory := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) + for _, path := range fs.MandatoryDenyReadPaths { + if path == "" || !filepath.IsAbs(path) { + return fmt.Errorf("invalid mandatory deny-read path %q: path must be absolute", path) + } + if _, ok := baseline[path]; !ok { + return fmt.Errorf("invalid mandatory deny-read path %q: path is not in denyReadIfExists", path) + } + mandatory[path] = struct{}{} + } + for path := range mandatory { + 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 { + continue + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve mandatory credential symlink %q: %w", path, err) + } + if _, ok := mandatory[resolved]; !ok { + return fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q without its resolved target %q", path, resolved) + } + } + return nil } -func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan { +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 @@ -261,7 +302,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{} @@ -311,8 +352,13 @@ 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 := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) + for _, path := range fs.MandatoryDenyReadPaths { + mandatory[path] = struct{}{} + } for _, path := range fs.DenyReadIfExists { - if !pathExists(path) { + _, required := mandatory[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 @@ -322,12 +368,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 { @@ -399,24 +453,34 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { } func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - if !slices.Contains(protectedCredentialPaths(), path) { - path = normalizeProfilePath(path) - } + args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true) + return args +} + +func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string) ([]string, error) { + return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false) +} + +func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts []string, allowMissing bool) ([]string, error) { if path == "" { - return args + return args, nil } // Bubblewrap cannot mount over a symlink destination. Protected daemon-token // paths include both the lexical link and its resolved target; the target is // materialized by its own entry, while Seatbelt can still deny both names. if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { - 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.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 @@ -429,7 +493,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..41eceb4d4 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,30 @@ 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) + } +} + // 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 +347,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 +383,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 +426,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 +443,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_test.go b/internal/sandbox/manager_test.go index 906f70fd9..ed894b1a8 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -574,17 +574,11 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Fatal(err) } } - daemonTokenFile := filepath.Join(home, "daemon-token") - if err := os.WriteFile(daemonTokenFile, []byte("secret"), 0o600); err != nil { - t.Fatal(err) - } - options := credentialPathOptions{ Homes: []string{home}, ConfigDirs: []string{configDir}, CloudSDKConfigDirs: []string{gcloudDir}, GoogleCredentials: []string{keyFile}, - DaemonTokenFiles: []string{daemonTokenFile}, NPMUserConfigs: []string{npmrc}, GHConfigDirs: []string{filepath.Dir(ghHosts)}, Netrcs: []string{netrc}, @@ -599,7 +593,6 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { awsDir, gcloudDir, keyFile, - daemonTokenFile, npmrc, ghHosts, netrc, @@ -659,7 +652,7 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { } // An explicit AllowRead entry covering a store is an opt-out. - optedOut := credentialDenyReadPathsIn(options, []string{awsDir, zeroDir, daemonTokenFile}) + optedOut := credentialDenyReadPathsIn(options, []string{awsDir, zeroDir}) if stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{awsDir})[0]) { t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut.Paths) } @@ -676,14 +669,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) } - // The bridge bearer token is the exception: the in-process tool boundary - // (protectedCredentialPaths) refuses to re-include it through AllowRead, so - // the OS-sandbox profile must not either — otherwise the guarantee would - // depend on whether a wrapped shell command or a built-in tool reads it. - if !stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{daemonTokenFile})[0]) { - t.Errorf("credential deny paths = %#v, want the daemon token file denied despite AllowRead", optedOut.Paths) - } - if got := credentialDenyReadPathsIn(credentialPathOptions{}, nil); len(got.Paths) != 0 { t.Errorf("credential deny paths for blank home = %#v, want none", got.Paths) } @@ -1107,7 +1092,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) @@ -1163,7 +1148,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) } @@ -1183,7 +1168,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) } @@ -1214,7 +1199,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) } @@ -1353,7 +1338,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) { @@ -1618,7 +1603,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) } @@ -1726,10 +1711,13 @@ func TestPermissionProfileDeniesAbsentDaemonTokenFile(t *testing.T) { } profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil) - want := normalizeProfilePaths([]string{tokenFile})[0] + 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. @@ -1738,11 +1726,48 @@ func TestPermissionProfileDeniesAbsentDaemonTokenFile(t *testing.T) { t.Fatalf("seatbelt credential write rules = %#v, want a deny for %q", rules, want) } - // Bubblewrap has no target to bind for a missing path, so the fallback must - // still produce an unreadable, unwritable mount rather than nothing. - args := appendUnreadableLinuxPathArgs(nil, want, nil) - if !slices.Contains(args, "--tmpfs") || !slices.Contains(args, want) { - t.Fatalf("bubblewrap args for the absent token = %#v, want an unreadable tmpfs at %q", args, 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) } } @@ -1857,9 +1882,6 @@ func TestCredentialPathOptionsFromEnvironmentResolvesToolPathLists(t *testing.T) } func TestPermissionProfileDeniesDaemonTokenFile(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("credential deny-read paths are disabled on Windows pending the ACL model") - } tokenFile := filepath.Join(t.TempDir(), "daemon-token") if err := os.WriteFile(tokenFile, []byte("secret"), 0o600); err != nil { t.Fatal(err) @@ -1868,16 +1890,37 @@ func TestPermissionProfileDeniesDaemonTokenFile(t *testing.T) { t.Setenv(daemonRemoteTokenFileEnv, tokenFile) profile := PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) - want := normalizeProfilePaths([]string{tokenFile})[0] - if !stringSliceContains(profile.FileSystem.DenyRead, want) { - t.Fatalf("DenyRead = %#v, want daemon token file %q", profile.FileSystem.DenyRead, want) + 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) - if stringSliceContains(profile.FileSystem.DenyRead, want) { - t.Fatalf("DenyRead = %#v, must not protect unused token file %q when the inline token takes precedence", profile.FileSystem.DenyRead, want) + 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/profile.go b/internal/sandbox/profile.go index 59137625e..6b8a29cb4 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 @@ -264,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 { @@ -441,16 +453,11 @@ func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) crede kubeConfigs = append(kubeConfigs, filepath.Join(home, ".kube", "config")) } } - var daemonTokenFiles []string - if strings.TrimSpace(credentialEnvValue(env, "ZERO_DAEMON_REMOTE_TOKEN")) == "" { - daemonTokenFiles = daemonTokenDenyPaths(credentialEnvValue(env, "ZERO_DAEMON_REMOTE_TOKEN_FILE")) - } return credentialPathOptions{ Homes: homes, ConfigDirs: dedupeStrings(configDirs), CloudSDKConfigDirs: dedupeStrings(cloudSDKConfigDirs), GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs), - DaemonTokenFiles: daemonTokenFiles, NPMUserConfigs: dedupeStrings(npmUserConfigs), GHConfigDirs: dedupeStrings(ghConfigDirs), Netrcs: dedupeStrings(netrcs), @@ -478,7 +485,6 @@ type credentialPathOptions struct { ConfigDirs []string CloudSDKConfigDirs []string GoogleCredentials []string - DaemonTokenFiles []string NPMUserConfigs []string GHConfigDirs []string Netrcs []string @@ -605,28 +611,16 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string // atomic writer or create publication directories it never uses. candidates = append(candidates, tokenPath, tokenPath+".migrated") } - // The bridge bearer token grants control of this daemon, so unlike the - // opt-outable credential stores above it stays denied even when AllowRead - // covers it — matching protectedCredentialPaths at the in-process boundary. - mandatory := dedupeStrings(options.DaemonTokenFiles) allowRoots := normalizeProfilePaths(allowRead) - out := make([]string, 0, len(candidates)+len(mandatory)) + out := make([]string, 0, len(candidates)) for _, path := range normalizeProfilePaths(candidates) { if credentialPathReincluded(allowRoots, path) { continue } out = append(out, path) } - // Unlike the candidates above, the token path is NOT existence-filtered. The - // entry protects a future write target as much as a current secret: after an - // external rotation deletes the file, dropping the rule would let a sandboxed - // process recreate it with an attacker-chosen bearer that the next - // `serve-remote` start would then accept. Each enforcing backend materializes - // a missing target fail-closed. - out = append(out, mandatory...) - out = dedupeStrings(out) return credentialDenyPaths{ - Paths: out, + Paths: dedupeStrings(out), Carveouts: credentialCarveoutPaths(out, carveouts), EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)), Dirs: credentialRetainedDirs(out, normalizeProfilePaths(dirs)), @@ -795,12 +789,24 @@ func credentialRetainedDirs(denied []string, dirs []string) []string { // carveout re-allows that subtree. func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead []string) credentialDenyPaths { credentials.Paths = pathsOutsideRoots(credentials.Paths, userDenyRead) + credentials.MandatoryPaths = pathsOutsideRoots(credentials.MandatoryPaths, userDenyRead) 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) { @@ -813,6 +819,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 index b85d35741..3da9c3d14 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -253,6 +253,9 @@ func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { 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) @@ -272,10 +275,8 @@ func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { } } - args := appendUnreadableLinuxPathArgs(nil, token, nil) - if !stringSliceContains(args, "--tmpfs") || !stringSliceContains(args, token) { - t.Fatalf("bubblewrap args = %#v, want unreadable tmpfs at exact pathname %q", args, token) - } + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) + assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", token) if !protectedCredentialInWritableMacOSRoot(profile, protectedCredentialPaths()) { t.Fatalf("spaced token %q under writable root %q must fail macOS preflight", token, workspace) @@ -295,12 +296,18 @@ func TestUnreadableLinuxPathSkipsProtectedSymlinkDestination(t *testing.T) { if err := os.Symlink(target, link); err != nil { t.Skipf("symlink unsupported: %v", err) } - if got := appendUnreadableLinuxPathArgs(nil, link, nil); len(got) != 0 { - t.Fatalf("bubblewrap args for symlink destination = %#v, want no mount", got) + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, link) + profile := PermissionProfileFromPolicy(dir, DefaultPolicy(), nil) + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) + if stringSliceContains(plan.Args, link) { + t.Fatalf("bubblewrap plan attempted to mount over symlink destination %q: %#v", link, plan.Args) } - if got := appendUnreadableLinuxPathArgs(nil, target, nil); !stringSliceContains(got, target) { - t.Fatalf("bubblewrap args for resolved target = %#v, want target bind", got) + resolvedTarget, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatalf("EvalSymlinks target: %v", err) } + assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", resolvedTarget) } // TestProtectedCredentialsSurviveDisabledPolicy covers the one route that skips diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index 441463025..cfcb2ce29 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -236,6 +236,15 @@ 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 "" +} + func requestPaths(request Request) []string { paths := []string{} // Keep this aligned with the path-arg alias lists the tools accept (see @@ -255,46 +264,17 @@ func requestPaths(request Request) []string { } func applyPatchRequestPaths(args map[string]any) []string { - patch := firstArgString(args, "patch", "diff") + patch := firstExactStringArg(args, "patch", "diff") if patch == "" { return nil } cwd := firstArgString(args, "cwd") var paths []string -1: for _, path := range PatchHeaderPaths(patch) { -2: for _, path := range PatchHeaderPaths(patch) { -3: // PatchHeaderPaths returns every source and destination path declared by a -// patch. Keeping this parser shared with apply_patch ensures the sandbox gate -// and the tool's own path validation agree on what the executor will read or write. -func PatchHeaderPaths(patch string) []string { - if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { - return structuredPatchHeaderPaths(patch) - } - 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 - } - } + parsed, err := PatchHeaderPaths(patch) + if err != nil { + return nil } - return paths -} - -func patchHeaderPaths(patch string) []string { + for _, path := range parsed { if path == "" || path == "/dev/null" { continue } @@ -310,44 +290,18 @@ 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 } -1: for _, path := range PatchHeaderPaths(patch) { -2: for _, path := range PatchHeaderPaths(patch) { -3: // PatchHeaderPaths returns every source and destination path declared by a -// patch. Keeping this parser shared with apply_patch ensures the sandbox gate -// and the tool's own path validation agree on what the executor will read or write. -func PatchHeaderPaths(patch string) []string { - if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { - return structuredPatchHeaderPaths(patch) - } - 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 - } + paths, err := PatchHeaderPaths(patch) + if err != nil { + return &pathBlock{ + Code: BlockDenied, + Reason: "patch paths cannot be established safely: " + err.Error(), } } - return paths -} - -func patchHeaderPaths(patch string) []string { + for _, path := range paths { if path == "" || path == "/dev/null" { continue } @@ -362,14 +316,14 @@ func patchHeaderPaths(patch string) []string { return nil } -1: for _, path := range PatchHeaderPaths(patch) { -2: for _, path := range PatchHeaderPaths(patch) { -3: // PatchHeaderPaths returns every source and destination path declared by a +// PatchHeaderPaths returns every source and destination path declared by a // patch. Keeping this parser shared with apply_patch ensures the sandbox gate -// and the tool's own path validation agree on what the executor will read or write. -func PatchHeaderPaths(patch string) []string { +// and the tool's own path validation agree on what the executor will read or write. A +// path-bearing git header that cannot be interpreted exactly rejects the patch: +// silently omitting it would let git operate on a path the policy never saw. +func PatchHeaderPaths(patch string) ([]string, error) { if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") { - return structuredPatchHeaderPaths(patch) + return structuredPatchHeaderPaths(patch), nil } return patchHeaderPaths(patch) } @@ -395,10 +349,68 @@ func structuredPatchHeaderPaths(patch string) []string { return paths } -func patchHeaderPaths(patch string) []string { +func patchHeaderPaths(patch string) ([]string, error) { var paths []string oldRemaining, newRemaining := 0, 0 inHunk := false + type diffSection struct { + rawDiff string + diffParsed bool + extendedSource string + extendedDestination string + hasExtendedSource bool + hasExtendedDest bool + unifiedSource string + unifiedDestination string + hasUnifiedSource bool + hasUnifiedDestination bool + } + var section diffSection + flushSection := func() error { + if section.rawDiff == "" { + return nil + } + if section.hasExtendedSource != section.hasExtendedDest { + return fmt.Errorf("incomplete copy or rename path headers") + } + if section.hasUnifiedSource != section.hasUnifiedDestination { + return fmt.Errorf("incomplete unified file path headers") + } + if section.diffParsed { + return nil + } + var source, destination string + switch { + case section.hasExtendedSource && section.hasExtendedDest: + source = "a/" + section.extendedSource + destination = "b/" + section.extendedDestination + case section.hasUnifiedSource && section.hasUnifiedDestination && section.unifiedSource != "/dev/null" && section.unifiedDestination != "/dev/null": + source = section.unifiedSource + destination = section.unifiedDestination + default: + return fmt.Errorf("ambiguous diff --git path operands") + } + if !diffGitLineMatchesPaths(section.rawDiff, source, destination) { + return fmt.Errorf("diff --git paths disagree with the file path headers") + } + paths = append(paths, stripPatchPrefix(source), stripPatchPrefix(destination)) + return nil + } + setExtendedPath := func(source bool, path string) error { + if source { + if section.hasExtendedSource && section.extendedSource != path { + return fmt.Errorf("conflicting copy or rename source paths") + } + section.extendedSource, section.hasExtendedSource = path, true + } else { + if section.hasExtendedDest && section.extendedDestination != path { + return fmt.Errorf("conflicting copy or rename destination paths") + } + section.extendedDestination, section.hasExtendedDest = path, true + } + paths = append(paths, filepath.ToSlash(path)) + return nil + } for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") { if inHunk && (oldRemaining > 0 || newRemaining > 0) { switch { @@ -416,61 +428,109 @@ func patchHeaderPaths(patch string) []string { inHunk = false switch { case strings.HasPrefix(line, "diff --git "): + if err := flushSection(); err != nil { + return nil, err + } + section = diffSection{rawDiff: line[len("diff --git "):]} if source, destination, ok := parseDiffGitPaths(line[len("diff --git "):]); ok { paths = append(paths, stripPatchPrefix(source), stripPatchPrefix(destination)) + section.diffParsed = true } case strings.HasPrefix(line, "copy from "): - if path, ok := parseExtendedGitPath(line[len("copy from "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("copy from "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid copy source path header") + } + if err := setExtendedPath(true, path); err != nil { + return nil, err } case strings.HasPrefix(line, "copy to "): - if path, ok := parseExtendedGitPath(line[len("copy to "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("copy to "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid copy destination path header") + } + if err := setExtendedPath(false, path); err != nil { + return nil, err } case strings.HasPrefix(line, "rename from "): - if path, ok := parseExtendedGitPath(line[len("rename from "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("rename from "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid rename source path header") + } + if err := setExtendedPath(true, path); err != nil { + return nil, err } case strings.HasPrefix(line, "rename to "): - if path, ok := parseExtendedGitPath(line[len("rename to "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("rename to "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid rename destination path header") + } + if err := setExtendedPath(false, path); err != nil { + return nil, err } case strings.HasPrefix(line, "rename old "): - if path, ok := parseExtendedGitPath(line[len("rename old "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("rename old "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid rename source path header") + } + if err := setExtendedPath(true, path); err != nil { + return nil, err } case strings.HasPrefix(line, "rename new "): - if path, ok := parseExtendedGitPath(line[len("rename new "):]); ok { - paths = append(paths, filepath.ToSlash(path)) + path, ok := parseExtendedGitPath(line[len("rename new "):]) + if !ok || section.rawDiff == "" { + return nil, fmt.Errorf("invalid rename destination path header") + } + if err := setExtendedPath(false, path); err != nil { + return nil, err } case strings.HasPrefix(line, "@@"): oldRemaining, newRemaining = parsePatchHunkCounts(line) inHunk = oldRemaining > 0 || newRemaining > 0 case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "): - if path := patchFileHeaderPath(line); path != "" { + path, ok := patchFileHeaderPath(line) + if !ok { + return nil, fmt.Errorf("invalid unified file path header") + } + if path != "" { paths = append(paths, stripPatchPrefix(path)) + if section.rawDiff != "" { + if strings.HasPrefix(line, "--- ") { + section.unifiedSource, section.hasUnifiedSource = path, true + } else { + section.unifiedDestination, section.hasUnifiedDestination = path, true + } + } } } } - return paths + if err := flushSection(); err != nil { + return nil, err + } + return paths, nil } func parseDiffGitPaths(line string) (string, string, bool) { - line = strings.TrimSpace(line) + if line == "" { + return "", "", false + } if strings.HasPrefix(line, "\"") { source, rest, ok := consumeGitPath(line) - if !ok { + if !ok || len(rest) < 2 || rest[0] != ' ' { return "", "", false } - destination, ok := parseWholeGitPath(rest) - return source, destination, ok + destination, ok := parseWholeGitPath(rest[1:]) + return source, destination, ok && validDiffGitPaths(source, destination) } // When only the destination is quoted, its opening quote uniquely separates // the two operands even if the unquoted source contains ordinary spaces. for separator := strings.Index(line, " \""); separator >= 0; { if destination, ok := parseWholeGitPath(line[separator+1:]); ok { - return line[:separator], destination, true + source := line[:separator] + if validDiffGitPaths(source, destination) { + return source, destination, true + } } next := strings.Index(line[separator+1:], " \"") if next < 0 { @@ -479,33 +539,36 @@ func parseDiffGitPaths(line string) (string, string, bool) { separator += next + 1 } - // Git commonly leaves ordinary spaces unquoted. For a non-rename diff, the - // synthetic a/ and b/ operands name the same path, which identifies the split - // without treating a filename space as an operand separator. - for separator := 0; separator < len(line); { - next := strings.IndexByte(line[separator:], ' ') - if next < 0 { - break + // For unquoted operands, only a space immediately followed by the synthetic + // b/ prefix can be the separator. Preserve exactly one separator byte: any + // preceding or trailing spaces remain filename data. + type candidate struct{ source, destination string } + var candidates []candidate + for separator := 0; separator+1 < len(line); separator++ { + if line[separator] != ' ' || !strings.HasPrefix(line[separator+1:], "b/") { + continue } - separator += next - source, destination := line[:separator], strings.TrimLeft(line[separator:], " ") - if strings.HasPrefix(source, "a/") && strings.HasPrefix(destination, "b/") && source[2:] == destination[2:] { - return source, destination, true + source, destination := line[:separator], line[separator+1:] + if validDiffGitPaths(source, destination) { + candidates = append(candidates, candidate{source: source, destination: destination}) } - separator++ } - - // Different unquoted names are disambiguated by the copy/rename headers. Keep - // the simple no-space form for ordinary diffs with distinct path operands. - fields := strings.Fields(line) - if len(fields) == 2 { - return fields[0], fields[1], true + if len(candidates) == 1 { + return candidates[0].source, candidates[0].destination, true + } + var same []candidate + for _, candidate := range candidates { + if candidate.source[2:] == candidate.destination[2:] { + same = append(same, candidate) + } + } + if len(same) == 1 { + return same[0].source, same[0].destination, true } return "", "", false } func parseWholeGitPath(input string) (string, bool) { - input = strings.TrimSpace(input) if input == "" { return "", false } @@ -513,7 +576,20 @@ func parseWholeGitPath(input string) (string, bool) { return input, true } path, rest, ok := consumeGitPath(input) - return path, ok && strings.TrimSpace(rest) == "" + return path, ok && rest == "" +} + +func validDiffGitPaths(source, destination string) bool { + return len(source) > 2 && len(destination) > 2 && strings.HasPrefix(source, "a/") && strings.HasPrefix(destination, "b/") +} + +func diffGitLineMatchesPaths(line, source, destination string) bool { + for separator := 0; separator < len(line); separator++ { + if line[separator] == ' ' && line[:separator] == source && line[separator+1:] == destination { + return true + } + } + return false } // Extended copy/rename headers consume the whole unquoted remainder as the @@ -526,15 +602,14 @@ func parseExtendedGitPath(input string) (string, bool) { if input[0] != '"' { return input, true } - path, _, ok := consumeGitPath(input) - return path, ok + 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) { - input = strings.TrimLeft(input, " \t") if input == "" { return "", "", false } @@ -572,20 +647,19 @@ func consumeGitPath(input string) (string, string, bool) { // 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 { +func patchFileHeaderPath(line string) (string, bool) { rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes if tab := strings.IndexByte(rest, '\t'); tab >= 0 { rest = rest[:tab] } if strings.HasPrefix(rest, `"`) { - // A quoted operand ends at its closing quote; git allows nothing but the - // (already removed) timestamp after it, so a trailing remainder means the - // line is not the quoted form and the raw bytes are the pathname. - if path, remainder, ok := consumeGitPath(rest); ok && remainder == "" { - return path + path, remainder, ok := consumeGitPath(rest) + if !ok || remainder != "" { + return "", false } + return path, true } - return rest + return rest, rest != "" } func parsePatchHunkCounts(line string) (int, int) { diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index fac700b85..a35281c2c 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -61,7 +61,11 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a if isStructuredPatch(patch) { return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options) } - if err := validatePatchPaths(applyRoot, patch); err != nil { + patchPaths, err := sandbox.PatchHeaderPaths(patch) + if err != nil { + return errorResult("Error applying patch: patch paths cannot be established safely: " + err.Error()) + } + if err := validatePatchPaths(applyRoot, patchPaths); err != nil { return errorResult("Error applying patch: " + err.Error()) } @@ -81,16 +85,16 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a return errorResult("Error applying patch: " + err.Error()) } - if err := recheckPatchWriteTargets(applyRoot, patch); err != nil { + if err := recheckPatchWriteTargets(applyRoot, patchPaths); err != nil { return errorResult("Error applying patch: " + err.Error()) } var createdTargets []string var fullySuppliedTargets []string wholeBefore := map[string]bool{} if options.FileTracker != nil { - createdTargets = missingPatchTargets(applyRoot, patch) + createdTargets = missingPatchTargets(applyRoot, patchPaths) fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch) - for _, path := range sandbox.PatchHeaderPaths(patch) { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -116,7 +120,7 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a summary = "Patch applied successfully in " + relativeRoot + "." } result := okResult(summary) - result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch) + result.ChangedFiles = changedFilesFromPatch(relativeRoot, patchPaths) result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)} fullySupplied := make(map[string]bool, len(fullySuppliedTargets)) for _, absolute := range fullySuppliedTargets { @@ -146,10 +150,10 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a return result } -func missingPatchTargets(root string, patch string) []string { +func missingPatchTargets(root string, patchPaths []string) []string { seen := map[string]bool{} var missing []string - for _, path := range sandbox.PatchHeaderPaths(patch) { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -242,10 +246,10 @@ func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) { // 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 { +func changedFilesFromPatch(relativeRoot string, patchPaths []string) []string { seen := map[string]bool{} var paths []string - for _, path := range sandbox.PatchHeaderPaths(patch) { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -262,8 +266,8 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string { return paths } -func validatePatchPaths(root string, patch string) error { - for _, path := range sandbox.PatchHeaderPaths(patch) { +func validatePatchPaths(root string, patchPaths []string) error { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -277,8 +281,8 @@ func validatePatchPaths(root string, patch string) error { return nil } -func recheckPatchWriteTargets(root string, patch string) error { - for _, path := range sandbox.PatchHeaderPaths(patch) { +func recheckPatchWriteTargets(root string, patchPaths []string) error { + for _, path := range patchPaths { if path == "" || path == "/dev/null" { continue } @@ -297,7 +301,7 @@ func patchFileHeaderPath(line string) string { if tab := strings.IndexByte(rest, '\t'); tab >= 0 { rest = rest[:tab] } - return strings.TrimSpace(unquoteGitPath(rest)) + return unquoteGitPath(rest) } // parseHunkCounts reads the old/new line counts from a "@@ -a,b +c,d @@" header. @@ -345,7 +349,6 @@ func hunkCount(spec string) int { // 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 @@ -355,7 +358,6 @@ func unquoteGitPath(s string) string { } 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/") { diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index d5b8bb766..853d4b3f6 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -7,6 +7,15 @@ import ( "github.com/Gitlawb/zero/internal/sandbox" ) +func mustPatchHeaderPaths(t *testing.T, patch string) []string { + t.Helper() + paths, err := sandbox.PatchHeaderPaths(patch) + if err != nil { + t.Fatalf("PatchHeaderPaths: %v", err) + } + return paths +} + func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(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` @@ -15,7 +24,7 @@ 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 := sandbox.PatchHeaderPaths(patch) + got := mustPatchHeaderPaths(t, patch) if !slices.Contains(got, "dir/file name.go") { t.Fatalf("quoted spaced path not extracted: %v", got) } @@ -23,7 +32,7 @@ func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { func TestPatchHeaderPathsUnspacedStillWorks(t *testing.T) { patch := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n" - got := sandbox.PatchHeaderPaths(patch) + got := mustPatchHeaderPaths(t, patch) if !slices.Contains(got, "x.go") { t.Fatalf("plain path not extracted: %v", got) } @@ -41,7 +50,7 @@ func TestPatchHeaderPathsPreservesSurroundingSpacesInNames(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 := sandbox.PatchHeaderPaths(patch) + got := mustPatchHeaderPaths(t, patch) if !slices.Contains(got, "bridge-token ") { t.Fatalf("trailing-space path not preserved: %q", got) } @@ -52,9 +61,26 @@ func TestPatchHeaderPathsPreservesSurroundingSpacesInNames(t *testing.T) { func TestPatchHeaderPathsHandlesCQuotedDiffGitOperands(t *testing.T) { patch := "diff --git \"a/bridge\\040token\" \"b/exposed\\tcopy\"\n" - got := sandbox.PatchHeaderPaths(patch) + got := mustPatchHeaderPaths(t, patch) want := []string{"bridge token", "exposed\tcopy"} if !slices.Equal(got, want) { t.Fatalf("C-quoted diff --git operands = %q, want %q", got, want) } } + +func TestPatchHeaderPathsPreservesTrailingSpaceInBinaryDiffOperands(t *testing.T) { + patch := "diff --git a/bridge-token b/bridge-token \n" + + "GIT binary patch\n" + got := mustPatchHeaderPaths(t, patch) + want := []string{"bridge-token ", "bridge-token "} + if !slices.Equal(got, want) { + t.Fatalf("binary diff paths = %q, want %q", got, want) + } +} + +func TestPatchHeaderPathsRejectsAmbiguousDiffOperands(t *testing.T) { + patch := "diff --git a/source b/part b/destination\nGIT binary patch\n" + if paths, err := sandbox.PatchHeaderPaths(patch); err == nil { + t.Fatalf("PatchHeaderPaths = %q, want ambiguous-path error", paths) + } +} diff --git a/internal/tools/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index 472defb17..2276193f9 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -269,35 +269,51 @@ func TestApplyPatchDeniesHeaderOnlyAndBinaryDaemonTokenPatches(t *testing.T) { "ecmYc)%1lX5)h$j Date: Wed, 12 Aug 2026 11:07:28 +0200 Subject: [PATCH 03/23] fix(sandbox): reject linkable macOS daemon tokens Co-authored-by: Pierre Bruno --- internal/sandbox/filesystem_darwin.go | 14 ++++++ internal/sandbox/filesystem_other.go | 7 +++ internal/sandbox/manager.go | 8 ++-- internal/sandbox/manager_darwin_test.go | 47 +++++++++++++++++++ internal/sandbox/manager_test.go | 16 +++---- .../sandbox/protected_credentials_test.go | 2 +- internal/sandbox/runner.go | 8 ++-- 7 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 internal/sandbox/filesystem_darwin.go create mode 100644 internal/sandbox/filesystem_other.go create mode 100644 internal/sandbox/manager_darwin_test.go diff --git a/internal/sandbox/filesystem_darwin.go b/internal/sandbox/filesystem_darwin.go new file mode 100644 index 000000000..bf67a1bfe --- /dev/null +++ b/internal/sandbox/filesystem_darwin.go @@ -0,0 +1,14 @@ +package sandbox + +import "golang.org/x/sys/unix" + +func pathsShareFilesystem(left, right string) bool { + var leftStat, rightStat unix.Stat_t + if err := unix.Stat(left, &leftStat); err != nil { + return false + } + if err := unix.Stat(right, &rightStat); err != nil { + return false + } + return leftStat.Dev == rightStat.Dev +} diff --git a/internal/sandbox/filesystem_other.go b/internal/sandbox/filesystem_other.go new file mode 100644 index 000000000..2b5c4dd89 --- /dev/null +++ b/internal/sandbox/filesystem_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package sandbox + +func pathsShareFilesystem(_, _ string) bool { + return false +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 3a7bcbda4..8794fe98d 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -235,8 +235,8 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques 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" && - protectedCredentialInWritableMacOSRoot(profile, protectedCredentials) { - return SandboxExecutionRequest{}, errors.New("macOS sandbox cannot protect the remote token file inside a shell-writable root; move the token file outside the workspace and temporary directories") + protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentials) { + return SandboxExecutionRequest{}, errors.New("macOS sandbox cannot protect the remote token file from hard-link aliases in a shell-writable root; use ZERO_DAEMON_REMOTE_TOKEN, place the file on a separate filesystem, or remove shell write access") } // Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup` // (it applies WFP network filters + workspace ACLs and writes a marker). @@ -297,7 +297,7 @@ func policyHasExplicitDeny(policy Policy) bool { return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0 } -func protectedCredentialInWritableMacOSRoot(profile PermissionProfile, protected []string) bool { +func protectedCredentialLinkableIntoWritableMacOSRoot(profile PermissionProfile, protected []string) bool { if len(protected) == 0 { return false } @@ -320,7 +320,7 @@ func protectedCredentialInWritableMacOSRoot(profile PermissionProfile, protected continue } for _, root := range writeRoots { - if pathWithinMacOSRoot(root, credential) { + if pathWithinMacOSRoot(root, credential) || pathsShareFilesystem(root, credential) { return true } } diff --git a/internal/sandbox/manager_darwin_test.go b/internal/sandbox/manager_darwin_test.go new file mode 100644 index 000000000..c05ae833c --- /dev/null +++ b/internal/sandbox/manager_darwin_test.go @@ -0,0 +1,47 @@ +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) + 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(), "hard-link aliases") { + t.Fatalf("BuildCommandPlan error = %v, want macOS hard-link-alias failure", err) + } +} diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index ed894b1a8..a54e93f85 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -301,28 +301,28 @@ func TestSandboxManagerRejectsMacOSTokenInsideWritableWorkspace(t *testing.T) { Preference: SandboxPreferenceAuto, ValidateExecution: true, }) - if err == nil || !strings.Contains(err.Error(), "inside a shell-writable root") { - t.Fatalf("BuildCommandPlan error = %v, want macOS writable-token failure", err) + if err == nil || !strings.Contains(err.Error(), "hard-link aliases") { + t.Fatalf("BuildCommandPlan error = %v, want macOS hard-link-alias failure", err) } } -func TestProtectedCredentialInWritableMacOSRootMatchesSeatbeltWrites(t *testing.T) { +func TestProtectedCredentialLinkableIntoWritableMacOSRoot(t *testing.T) { restricted := PermissionProfile{FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: "/Users/Test/Workspace"}}, }} - if !protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/users/test/workspace/token")}) { + if !protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/users/test/workspace/token")}) { t.Fatal("case-variant token under a macOS write root should be rejected") } - if protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/Users/Test/Credentials/token")}) { + if protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/Users/Test/Credentials/token")}) { t.Fatal("token outside every macOS write root should remain allowed") } restricted.FileSystem.AllowTemp = true - if !protectedCredentialInWritableMacOSRoot(restricted, []string{normalizeProfilePath("/private/tmp/bridge-token")}) { + if !protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/private/tmp/bridge-token")}) { t.Fatal("token under an allowed temporary root should be rejected") } unrestricted := PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted}} - if !protectedCredentialInWritableMacOSRoot(unrestricted, []string{"/credentials/token"}) { + if !protectedCredentialLinkableIntoWritableMacOSRoot(unrestricted, []string{"/credentials/token"}) { t.Fatal("an unrestricted macOS filesystem makes every token path shell-writable") } @@ -340,7 +340,7 @@ func TestProtectedCredentialInWritableMacOSRootMatchesSeatbeltWrites(t *testing. Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: writable}}, }} - if !protectedCredentialInWritableMacOSRoot(symlinkProfile, []string{link, target}) { + if !protectedCredentialLinkableIntoWritableMacOSRoot(symlinkProfile, []string{link, target}) { t.Fatal("selected symlink inside a write root must be rejected even when its target is outside") } } diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index 3da9c3d14..8603f025e 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -278,7 +278,7 @@ func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", token) - if !protectedCredentialInWritableMacOSRoot(profile, protectedCredentialPaths()) { + if !protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { t.Fatalf("spaced token %q under writable root %q must fail macOS preflight", token, workspace) } } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index b12a611dc..c6eeeac76 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -808,10 +808,10 @@ func seatbeltProtectedMetadataRegex(root string, name string) string { // 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 name created -// afterwards for the same inode, most obviously a hard link made by a sandboxed -// shell, is not covered by any of them. That applies equally to a -// user-configured DenyRead and to the automatic remote-bridge-token deny; the +// — 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...)) From 03c6ab56adc8bcf7e9fbd18125beea843b733dfe Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 12 Aug 2026 17:58:46 +0200 Subject: [PATCH 04/23] fix(sandbox): close the Linux mandatory-token validate/build gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateLinuxMandatoryDenyReadPaths Lstat'd each mandatory deny-read path once, up front, to confirm it was either a regular file or a symlink resolving to another mandatory entry. buildLinuxBwrapFilesystemPlan then Lstat'd the same path again, later, to decide whether to mask it in the Bubblewrap plan — and skipped masking outright whenever it saw a symlink, trusting that the earlier validation pass had already proven the target safe. An external token rotation (or a remote worker racing sandbox launch) that replaces the mandatory path with a symlink to an unrelated, unmasked target in the gap between those two Lstat calls defeated the mandatory-token guarantee entirely: the plan built successfully with no mask for the pathname, and the sandboxed shell could read the rotated bearer through it. Close the gap by making the symlink-target check part of the exact Lstat that decides whether to mask the path, instead of a separate earlier syscall whose result can go stale: - mandatoryDenyReadSymlinkTarget centralizes "does this symlink resolve to another protected entry", called from both the early best-effort validation pass and the authoritative check. - appendUnreadableLinuxPathArgsForPath takes the mandatory-path set and performs this check against its own fresh Lstat immediately before deciding to skip masking a symlink, so there is no window between the check and the decision it gates. - The early validateLinuxMandatoryDenyReadPaths pass is now documented as advisory/fail-fast only; the late check is what actually enforces the guarantee. Adds a regression that rotates a validated regular file into a symlink pointing outside the mandatory set between validation and plan construction, asserting the plan now fails closed, plus a control confirming a symlink to another mandatory path is still correctly masked through its resolved target. Co-authored-by: Pierre Bruno --- internal/sandbox/linux_helper.go | 76 ++++++++++++++++++------ internal/sandbox/linux_helper_test.go | 84 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index 78eac72dd..58d5cce55 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -255,7 +255,7 @@ func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { for _, path := range fs.DenyReadIfExists { baseline[path] = struct{}{} } - mandatory := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) + mandatory := mandatoryDenyReadPathSet(fs) for _, path := range fs.MandatoryDenyReadPaths { if path == "" || !filepath.IsAbs(path) { return fmt.Errorf("invalid mandatory deny-read path %q: path must be absolute", path) @@ -263,8 +263,14 @@ func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { if _, ok := baseline[path]; !ok { return fmt.Errorf("invalid mandatory deny-read path %q: path is not in denyReadIfExists", path) } - mandatory[path] = struct{}{} } + // This pass is a best-effort, early fail-fast diagnostic only: a rotation + // between this Lstat and the one buildLinuxBwrapFilesystemPlan performs + // immediately before emitting each path's mount args could change what it + // finds. appendUnreadableLinuxPathArgsForPath repeats the symlink-target + // check below against its own fresh Lstat and is the authoritative one; + // this loop exists to surface an obviously missing or misconfigured token + // with a clear message before the (potentially expensive) plan is built. for path := range mandatory { info, err := os.Lstat(path) if err != nil { @@ -276,17 +282,42 @@ func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { if info.Mode()&os.ModeSymlink == 0 { continue } - resolved, err := filepath.EvalSymlinks(path) - if err != nil { - return fmt.Errorf("resolve mandatory credential symlink %q: %w", path, err) - } - if _, ok := mandatory[resolved]; !ok { - return fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q without its resolved target %q", path, resolved) + if _, err := mandatoryDenyReadSymlinkTarget(path, mandatory); err != nil { + return err } } return nil } +// mandatoryDenyReadPathSet returns fs.MandatoryDenyReadPaths as a set, for +// resolved-symlink-target membership checks. +func mandatoryDenyReadPathSet(fs FileSystemPolicy) map[string]struct{} { + mandatory := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) + for _, path := range fs.MandatoryDenyReadPaths { + mandatory[path] = struct{}{} + } + return mandatory +} + +// mandatoryDenyReadSymlinkTarget resolves path — already known by the caller's +// own Lstat to currently be a symlink — and reports an error unless its +// target is itself one of the plan's mandatory deny-read paths (and so gets +// its own mount-masking entry). Every caller must pass a path it just Lstat'd +// itself: resolving against a decision cached from an earlier syscall is +// exactly the gap that let an external token rotation swap a validated +// regular file for a symlink pointing outside the protected set before the +// plan was built. +func mandatoryDenyReadSymlinkTarget(path string, mandatory map[string]struct{}) (string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("resolve mandatory credential symlink %q: %w", path, err) + } + if _, ok := mandatory[resolved]; !ok { + return "", fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q without its resolved target %q", path, resolved) + } + return resolved, nil +} + func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesystemPlan, error) { fs := profile.FileSystem if err := validateLinuxMandatoryDenyReadPaths(fs); err != nil { @@ -352,10 +383,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesys // here. Command-controlled credential roots remain deny-if-present and must // never cause host filesystem mutations before sandbox launch. ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs) - mandatory := make(map[string]struct{}, len(fs.MandatoryDenyReadPaths)) - for _, path := range fs.MandatoryDenyReadPaths { - mandatory[path] = struct{}{} - } + mandatory := mandatoryDenyReadPathSet(fs) for _, path := range fs.DenyReadIfExists { _, required := mandatory[path] if !required && !pathExists(path) { @@ -370,7 +398,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesys } if required { var err error - args, err = appendMandatoryUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + args, err = appendMandatoryUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts, mandatory) if err != nil { return linuxBwrapFilesystemPlan{}, err } @@ -453,15 +481,24 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { } func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true) + args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true, nil) return args } -func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string) ([]string, error) { - return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false) +func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string, mandatory map[string]struct{}) ([]string, error) { + return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false, mandatory) } -func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts []string, allowMissing bool) ([]string, error) { +// appendUnreadableLinuxPathArgsForPath's own Lstat is the single source of +// truth for path's current state; it must never trust a symlink/regular-file +// decision made by any earlier syscall, because the only realistic attacker +// here (a rotation racing sandbox launch, or a remote worker with write +// access to the token's directory) acts exactly in the gap between an +// earlier check and this one. mandatory is nil for ordinary (non-mandatory) +// credential paths, whose symlinks are skipped without further verification +// exactly as before; it is non-nil only for MandatoryDenyReadPaths entries, +// where a symlink must additionally resolve to another path in the same set. +func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts []string, allowMissing bool, mandatory map[string]struct{}) ([]string, error) { if path == "" { return args, nil } @@ -469,6 +506,11 @@ func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts // paths include both the lexical link and its resolved target; the target is // materialized by its own entry, while Seatbelt can still deny both names. if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + if mandatory != nil { + if _, err := mandatoryDenyReadSymlinkTarget(path, mandatory); err != nil { + return nil, err + } + } return args, nil } if info, err := os.Stat(path); err == nil { diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index 41eceb4d4..f2d46b89c 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -327,6 +327,90 @@ func TestLinuxBwrapRejectsMandatoryPathOutsideDenyBaseline(t *testing.T) { } } +// 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.Fatal(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(), "resolved target") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want a resolved-target mismatch error", err) + } +} + +// TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked is the +// accepted-shape counterpart: a mandatory path that is a symlink to another +// path that is itself in MandatoryDenyReadPaths is not masked directly (bwrap +// cannot mount over a symlink destination), but the plan must still succeed +// and mask the resolved target through its own entry. +func TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked(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.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: []string{link, target}, + MandatoryDenyReadPaths: []string{link, target}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) + normalizedTarget := normalizeProfilePath(target) + assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", normalizedTarget) + if stringSliceContains(plan.Args, link) { + t.Fatalf("bubblewrap cannot mount over the symlink itself; it must not appear as a mount target: %#v", plan.Args) + } +} + // 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 From 5d69f15290ff5b10d431379d9824d0ca5b8c2b87 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 13 Aug 2026 16:25:29 +0200 Subject: [PATCH 05/23] fix(sandbox): fail closed on mandatory symlinks Co-authored-by: Pierre Bruno --- internal/sandbox/linux_helper.go | 100 ++++++++++---------------- internal/sandbox/linux_helper_test.go | 43 +++++++---- 2 files changed, 68 insertions(+), 75 deletions(-) diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index 58d5cce55..a369228b7 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -253,25 +253,18 @@ func validateLinuxBwrapPermissionProfile(profile PermissionProfile) error { func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { baseline := make(map[string]struct{}, len(fs.DenyReadIfExists)) for _, path := range fs.DenyReadIfExists { - baseline[path] = struct{}{} + if identity := canonicalLinuxMandatoryPath(path); identity != "" { + baseline[identity] = struct{}{} + } } - mandatory := mandatoryDenyReadPathSet(fs) for _, path := range fs.MandatoryDenyReadPaths { if path == "" || !filepath.IsAbs(path) { return fmt.Errorf("invalid mandatory deny-read path %q: path must be absolute", path) } - if _, ok := baseline[path]; !ok { + identity := canonicalLinuxMandatoryPath(path) + if _, ok := baseline[identity]; !ok { return fmt.Errorf("invalid mandatory deny-read path %q: path is not in denyReadIfExists", path) } - } - // This pass is a best-effort, early fail-fast diagnostic only: a rotation - // between this Lstat and the one buildLinuxBwrapFilesystemPlan performs - // immediately before emitting each path's mount args could change what it - // finds. appendUnreadableLinuxPathArgsForPath repeats the symlink-target - // check below against its own fresh Lstat and is the authoritative one; - // this loop exists to surface an obviously missing or misconfigured token - // with a clear message before the (potentially expensive) plan is built. - for path := range mandatory { info, err := os.Lstat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -279,45 +272,39 @@ func validateLinuxMandatoryDenyReadPaths(fs FileSystemPolicy) error { } return fmt.Errorf("inspect mandatory credential path %q: %w", path, err) } - if info.Mode()&os.ModeSymlink == 0 { - continue - } - if _, err := mandatoryDenyReadSymlinkTarget(path, mandatory); err != nil { - return 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 } -// mandatoryDenyReadPathSet returns fs.MandatoryDenyReadPaths as a set, for -// resolved-symlink-target membership checks. +// 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 { - mandatory[path] = struct{}{} + if identity := canonicalLinuxMandatoryPath(path); identity != "" { + mandatory[identity] = struct{}{} + } } return mandatory } -// mandatoryDenyReadSymlinkTarget resolves path — already known by the caller's -// own Lstat to currently be a symlink — and reports an error unless its -// target is itself one of the plan's mandatory deny-read paths (and so gets -// its own mount-masking entry). Every caller must pass a path it just Lstat'd -// itself: resolving against a decision cached from an earlier syscall is -// exactly the gap that let an external token rotation swap a validated -// regular file for a symlink pointing outside the protected set before the -// plan was built. -func mandatoryDenyReadSymlinkTarget(path string, mandatory map[string]struct{}) (string, error) { - resolved, err := filepath.EvalSymlinks(path) - if err != nil { - return "", fmt.Errorf("resolve mandatory credential symlink %q: %w", path, err) - } - if _, ok := mandatory[resolved]; !ok { - return "", fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q without its resolved target %q", path, resolved) - } - return resolved, nil -} - func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesystemPlan, error) { fs := profile.FileSystem if err := validateLinuxMandatoryDenyReadPaths(fs); err != nil { @@ -385,7 +372,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesys ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs) mandatory := mandatoryDenyReadPathSet(fs) for _, path := range fs.DenyReadIfExists { - _, required := mandatory[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 @@ -398,7 +385,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) (linuxBwrapFilesys } if required { var err error - args, err = appendMandatoryUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts, mandatory) + args, err = appendMandatoryUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) if err != nil { return linuxBwrapFilesystemPlan{}, err } @@ -481,35 +468,26 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { } func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true, nil) + args, _ = appendUnreadableLinuxPathArgsForPath(args, normalizeProfilePath(path), carveouts, true, false) return args } -func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string, mandatory map[string]struct{}) ([]string, error) { - return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false, mandatory) +func appendMandatoryUnreadableLinuxPathArgs(args []string, path string, carveouts []string) ([]string, error) { + return appendUnreadableLinuxPathArgsForPath(args, path, carveouts, false, true) } -// appendUnreadableLinuxPathArgsForPath's own Lstat is the single source of -// truth for path's current state; it must never trust a symlink/regular-file -// decision made by any earlier syscall, because the only realistic attacker -// here (a rotation racing sandbox launch, or a remote worker with write -// access to the token's directory) acts exactly in the gap between an -// earlier check and this one. mandatory is nil for ordinary (non-mandatory) -// credential paths, whose symlinks are skipped without further verification -// exactly as before; it is non-nil only for MandatoryDenyReadPaths entries, -// where a symlink must additionally resolve to another path in the same set. -func appendUnreadableLinuxPathArgsForPath(args []string, path string, carveouts []string, allowMissing bool, mandatory map[string]struct{}) ([]string, error) { +// 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, nil } - // Bubblewrap cannot mount over a symlink destination. Protected daemon-token - // paths include both the lexical link and its resolved target; the target is - // materialized by its own entry, while Seatbelt can still deny both names. if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { - if mandatory != nil { - if _, err := mandatoryDenyReadSymlinkTarget(path, mandatory); err != nil { - return nil, err - } + if mandatory { + return nil, fmt.Errorf("bubblewrap cannot enforce mandatory credential symlink %q; configure the resolved token path", path) } return args, nil } diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index f2d46b89c..f8dc6e390 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -373,17 +373,15 @@ func TestLinuxBwrapMandatoryPathRotationToUnprotectedSymlinkFailsClosed(t *testi if err == nil { t.Fatalf("buildLinuxBwrapFilesystemPlan after rotation to an unprotected symlink = plan %#v, want an error", plan) } - if !strings.Contains(err.Error(), "resolved target") { - t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want a resolved-target mismatch error", err) + if !strings.Contains(err.Error(), "mandatory credential symlink") { + t.Fatalf("buildLinuxBwrapFilesystemPlan error = %v, want a mandatory-symlink error", err) } } -// TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked is the -// accepted-shape counterpart: a mandatory path that is a symlink to another -// path that is itself in MandatoryDenyReadPaths is not masked directly (bwrap -// cannot mount over a symlink destination), but the plan must still succeed -// and mask the resolved target through its own entry. -func TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked(t *testing.T) { +// 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 { @@ -391,7 +389,7 @@ func TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked(t *testing } link := filepath.Join(dir, "daemon-token-link") if err := os.Symlink(target, link); err != nil { - t.Fatal(err) + t.Skipf("symlink unavailable: %v", err) } profile := PermissionProfile{ FileSystem: FileSystemPolicy{ @@ -403,11 +401,28 @@ func TestLinuxBwrapMandatoryPathSymlinkToAnotherMandatoryPathIsMasked(t *testing Network: NetworkPolicy{Mode: NetworkDeny}, } - plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) - normalizedTarget := normalizeProfilePath(target) - assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", normalizedTarget) - if stringSliceContains(plan.Args, link) { - t.Fatalf("bubblewrap cannot mount over the symlink itself; it must not appear as a mount target: %#v", plan.Args) + 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) } } From 22439182961229bb13032dfdfc4898753f744704 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 14 Aug 2026 16:10:43 +0000 Subject: [PATCH 06/23] fix(sandbox): close the Linux token hard-link alias path Two review findings on #685. The mandatory-symlink regression asserted the old contract. Head made validateLinuxMandatoryDenyReadPaths reject a symlinked token pathname, but TestUnreadableLinuxPathSkipsProtectedSymlinkDestination still expected a successful plan masking the resolved target, so the package test failed at that call and took the required smoke steps with it. Split it: a mandatory symlinked token now asserts the fail-closed refusal, and the "never bind over the link itself" coverage is retained in a non-mandatory credential case. Add a command-plan-level regression so a future change cannot restore the accepted-symlink behavior while leaving only a helper-level test green. The Linux plan also left the hard-link alias class open. Bubblewrap only binds /dev/null over each lexical pathname, but a hard link is another directory entry for the same inode, so a pre-existing alias in a shell-readable root still yields the bearer token; macOS had a preflight and Linux had none. Reject a file-backed token before building a Linux shell plan when it shares a filesystem with a shell-visible read or write root, or when its link count already proves an alias the planner cannot enumerate. Read roots count too: reading an alias is enough, no write access needed. This does not rely on the token-file variable having been scrubbed. Fold filesystem_darwin.go into filesystem_unix.go so the device comparison and the new link-count probe are shared by both Unix platforms, leaving the non-Unix stubs in filesystem_other.go. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Pierre Bruno --- internal/sandbox/filesystem_darwin.go | 14 -- internal/sandbox/filesystem_other.go | 6 +- internal/sandbox/filesystem_unix.go | 31 ++++ internal/sandbox/manager.go | 57 ++++++++ .../sandbox/protected_credentials_test.go | 138 +++++++++++++++++- 5 files changed, 227 insertions(+), 19 deletions(-) delete mode 100644 internal/sandbox/filesystem_darwin.go create mode 100644 internal/sandbox/filesystem_unix.go diff --git a/internal/sandbox/filesystem_darwin.go b/internal/sandbox/filesystem_darwin.go deleted file mode 100644 index bf67a1bfe..000000000 --- a/internal/sandbox/filesystem_darwin.go +++ /dev/null @@ -1,14 +0,0 @@ -package sandbox - -import "golang.org/x/sys/unix" - -func pathsShareFilesystem(left, right string) bool { - var leftStat, rightStat unix.Stat_t - if err := unix.Stat(left, &leftStat); err != nil { - return false - } - if err := unix.Stat(right, &rightStat); err != nil { - return false - } - return leftStat.Dev == rightStat.Dev -} diff --git a/internal/sandbox/filesystem_other.go b/internal/sandbox/filesystem_other.go index 2b5c4dd89..fbd39b790 100644 --- a/internal/sandbox/filesystem_other.go +++ b/internal/sandbox/filesystem_other.go @@ -1,7 +1,11 @@ -//go:build !darwin +//go:build !darwin && !linux package sandbox func pathsShareFilesystem(_, _ string) bool { return false } + +func pathHardLinkCount(_ string) (uint64, bool) { + return 0, false +} diff --git a/internal/sandbox/filesystem_unix.go b/internal/sandbox/filesystem_unix.go new file mode 100644 index 000000000..0f41b81f7 --- /dev/null +++ b/internal/sandbox/filesystem_unix.go @@ -0,0 +1,31 @@ +//go:build darwin || linux + +package sandbox + +import "golang.org/x/sys/unix" + +func pathsShareFilesystem(left, right string) bool { + var leftStat, rightStat unix.Stat_t + if err := unix.Stat(left, &leftStat); err != nil { + return false + } + if err := unix.Stat(right, &rightStat); err != nil { + return false + } + return leftStat.Dev == rightStat.Dev +} + +// 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. +// The second result is false when the count cannot be determined. +func pathHardLinkCount(path string) (uint64, bool) { + var stat unix.Stat_t + if err := unix.Lstat(path, &stat); err != nil { + return 0, false + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return 0, false + } + return uint64(stat.Nlink), true +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 8794fe98d..112d7d898 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -2,6 +2,7 @@ package sandbox import ( "errors" + "fmt" "os" "path/filepath" "runtime" @@ -238,6 +239,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentials) { return SandboxExecutionRequest{}, errors.New("macOS sandbox cannot protect the remote token file from hard-link aliases in a shell-writable root; use ZERO_DAEMON_REMOTE_TOKEN, place the file on a separate filesystem, or remove shell write access") } + 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). // Without it, a restricted-filesystem profile can still run in the UNELEVATED @@ -328,6 +334,57 @@ func protectedCredentialLinkableIntoWritableMacOSRoot(profile PermissionProfile, return false } +// protectedCredentialLinkableIntoLinuxShellRoot reports a mandatory token that a +// shell command could reach through a second directory entry for the same inode, +// and the offending pathname. +// +// The bubblewrap plan binds /dev/null over each configured pathname, which hides +// that name and nothing else. A hard link is another name for the same inode, so +// an alias in any root the shell can read defeats the mask, and one that already +// exists needs neither the token-file variable nor a new link operation. Linking +// requires the alias and the target to sit on one filesystem, so sharing a device +// with a shell-visible root is what makes the class reachable; an existing link +// count above one proves an alias the planner cannot enumerate. +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 + } + // Read roots count as well as write roots: reading an existing alias is + // enough to recover the token, no write access required. + roots := make([]string, 0, len(profile.FileSystem.ReadRoots)+len(profile.FileSystem.WriteRoots)+len(sandboxWritableSubpaths)) + roots = append(roots, normalizeProfilePaths(profile.FileSystem.ReadRoots)...) + for _, root := range profile.FileSystem.WriteRoots { + roots = append(roots, normalizeProfilePath(root.Root)) + } + if profile.FileSystem.AllowTemp { + roots = append(roots, normalizeProfilePaths(sandboxWritableSubpaths)...) + } + for _, credential := range protected { + credential = filepath.Clean(credential) + if credential == "." || credential == "" { + continue + } + if count, ok := pathHardLinkCount(credential); ok && count > 1 { + return credential, true + } + for _, root := range roots { + if root == "" { + continue + } + if pathWithinRoot(root, credential) || pathsShareFilesystem(root, credential) { + return credential, true + } + } + } + return "", false +} + func pathWithinMacOSRoot(root, candidate string) bool { if pathWithinRoot(root, candidate) || pathWithinRoot(strings.ToLower(root), strings.ToLower(candidate)) { return true diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index 8603f025e..04e6e27e9 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -283,7 +283,10 @@ func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { } } -func TestUnreadableLinuxPathSkipsProtectedSymlinkDestination(t *testing.T) { +// 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") } @@ -299,14 +302,141 @@ func TestUnreadableLinuxPathSkipsProtectedSymlinkDestination(t *testing.T) { t.Setenv(daemonRemoteTokenEnv, "") t.Setenv(daemonRemoteTokenFileEnv, link) profile := PermissionProfileFromPolicy(dir, DefaultPolicy(), nil) - plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) - if stringSliceContains(plan.Args, link) { - t.Fatalf("bubblewrap plan attempted to mount over symlink destination %q: %#v", link, plan.Args) + + _, 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 _, ok := pathHardLinkCount(token); !ok { + t.Fatal("pathHardLinkCount could not inspect the token fixture") + } + 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) + } +} + +// 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) } From 74ea6af48b5686b812e8560b4417e3ac233faf2f Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 17 Aug 2026 15:57:17 +0200 Subject: [PATCH 07/23] fix(sandbox): gate path args on exact bytes and close the engine-less listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the consolidated review on #685. [P1] requestPaths ran every path-carrying tool argument through argString, which TrimSpaces, while the tools resolve the same arguments with aliasedStringArg, which does not. A credential whose filename carries meaningful whitespace was therefore protected under its real spelling while the gate inspected a different one: with the token file named " bridge-token", read_file {"path": " bridge-token"} cleared a gate that checked "bridge-token" and then opened and returned the bearer. The gate now reads the exact bytes the tool will open. The trimmed spelling is still emitted when it differs, so the gate never inspects less than it did before. The regression runs the production path — registry.RunWithOptions with the sandbox engine — not a profile builder, because the divergence is invisible to a test that calls the gate directly. It reproduces the leak without the fix. Note the whitespace has to sit at the boundary of the argument string for TrimSpace to reach it, so the case is the RELATIVE spelling; in an absolute path the space is mid-string and the old gate happened to behave, which is why the existing absolute-path coverage never caught this. [P3] list_directory disclosed the token filename when reached without a sandbox engine. Registry.Run funnels into RunWithOptions with empty options, so that is the MCP/legacy production path rather than a test shape, and the earlier Run() override was dead code. 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 be less protected: sandboxReadExcluderWithin applies it with or without an engine, while policy DenyRead still requires one. Also adds the pathname contract to pathlists.go. Each review round on this surface found a new layer that disagreed about what the token pathname is, so the four rules every consumer must share — data not a word, selected and resolved spellings both, exact bytes at tool arguments, never re-includable — are now written down in one place. The Step C matrix crosses read_file, write_file, list_directory, grep, and apply_patch with exact, trailing-space, relative, dot-segment, and parent-traversal spellings, all through the production entrypoint, so a future gap fails as a matrix cell rather than arriving as a new report. Fixtures skip rather than fail where the host filesystem will not store the name, verified against the real directory entry because Windows silently strips trailing spaces from both the create and the lookup. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Pierre Bruno --- internal/sandbox/pathlists.go | 45 ++++ internal/sandbox/risk.go | 35 ++- internal/tools/daemon_token_exclusion_test.go | 32 ++- internal/tools/daemon_token_matrix_test.go | 208 ++++++++++++++++++ internal/tools/list_directory.go | 8 +- internal/tools/read_exclusions.go | 21 ++ 6 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 internal/tools/daemon_token_matrix_test.go diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index 2612b2937..e710951a5 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -70,6 +70,32 @@ func selectedDaemonRemoteTokenFile() string { return configured } +// 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 (selectedDaemonRemoteTokenFile). It is never trimmed, +// never shell-split, and "~" is never expanded — os.ReadFile, the daemon's +// own reader, treats it literally, so anything else protects a file the +// daemon does not read. +// 2. Both the SELECTED spelling and the target it currently resolves to are +// protected (daemonTokenDenyPaths). `serve-remote` canonicalizes what it +// selects, but an inherited symlinked value must not leave the link +// replaceable. +// 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; see the comment there for the read_file bypass that trimming +// produced. +// 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. // @@ -357,6 +383,25 @@ 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 ReadExclusions{ + workspaceRoot: workspaceRoot, + protectedRoots: protectedCredentialPaths(), + } +} + // 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 diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index cfcb2ce29..acbd67989 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -245,16 +245,35 @@ func firstExactStringArg(args map[string]any, keys ...string) string { 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" { diff --git a/internal/tools/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index 2276193f9..f0b63c05e 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -18,6 +18,16 @@ import ( // 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 { @@ -26,9 +36,27 @@ func daemonTokenFixture(t *testing.T) (string, string, *sandbox.Engine) { 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, "bridge-token") + token := filepath.Join(ws, tokenName) if err := os.WriteFile(token, []byte("bridge-secret\n"), 0o600); err != nil { - t.Fatalf("write token: %v", err) + 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) diff --git a/internal/tools/daemon_token_matrix_test.go b/internal/tools/daemon_token_matrix_test.go new file mode 100644 index 000000000..a0ea2c054 --- /dev/null +++ b/internal/tools/daemon_token_matrix_test.go @@ -0,0 +1,208 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "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}) + if result.Status == StatusOK { + t.Fatalf("write_file overwrote the protected token: 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) + patch := "*** Begin Patch\n*** Update File: " + target + + "\n@@\n-bridge-secret\n+attacker\n*** End Patch\n" + result := registry.RunWithOptions(context.Background(), "apply_patch", + map[string]any{"patch": patch}, RunOptions{Sandbox: engine}) + 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) + } + }) + }) + } +} + +// 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/list_directory.go b/internal/tools/list_directory.go index 008e69e5a..c9354893d 100644 --- a/internal/tools/list_directory.go +++ b/internal/tools/list_directory.go @@ -46,11 +46,15 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool { } func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result { - return tool.run(args, readExcluder{}, true) + return tool.run(args, sandboxReadExcluderWithin(nil, tool.workspaceRoot), true) } +// 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, sandboxReadExcluder(options.Sandbox), false) + return tool.run(args, sandboxReadExcluderWithin(options.Sandbox, tool.workspaceRoot), false) } func (tool listDirectoryTool) run(args map[string]any, exclude readExcluder, directBudget bool) Result { diff --git a/internal/tools/read_exclusions.go b/internal/tools/read_exclusions.go index 188f728f8..a8746c383 100644 --- a/internal/tools/read_exclusions.go +++ b/internal/tools/read_exclusions.go @@ -29,3 +29,24 @@ func sandboxReadExcluder(engine *sandbox.Engine) readExcluder { } return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded} } + +// 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} +} From bbce40c1d467ce7557537b605ed301d486638095 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 18 Aug 2026 20:44:35 +0000 Subject: [PATCH 08/23] fix(sandbox): complete daemon token boundary Amp-Thread-ID: https://ampcode.com/threads/T-01a01695-4107-712b-b4b6-45e1290d1865 Co-authored-by: Pierre Bruno --- internal/mcp/daemon_token_test.go | 118 ++++++++++++++++++ internal/mcp/resources.go | 6 + internal/mcp/server.go | 30 +++-- internal/sandbox/profile.go | 7 +- .../sandbox/protected_credentials_test.go | 25 ++++ internal/sandbox/risk.go | 4 +- internal/tools/apply_patch_cwd_token_test.go | 56 +++++++++ 7 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 internal/mcp/daemon_token_test.go create mode 100644 internal/tools/apply_patch_cwd_token_test.go diff --git a/internal/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go new file mode 100644 index 000000000..cbb4ba6fd --- /dev/null +++ b/internal/mcp/daemon_token_test.go @@ -0,0 +1,118 @@ +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) + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 29fedc72b..26398c5a9 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -94,6 +94,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,6 +149,9 @@ func (server toolServer) readResource(rawParams json.RawMessage) ([]ResourceCont // outside the granted roots. return nil, jsonRPCResourceNotFound, err } + if server.credentialGuard.ReadExclusions().PathExcluded(absolute) { + return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) + } info, err := os.Stat(absolute) if err != nil { 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/sandbox/profile.go b/internal/sandbox/profile.go index 6b8a29cb4..2a5c3039b 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -788,8 +788,11 @@ 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) - credentials.MandatoryPaths = pathsOutsideRoots(credentials.MandatoryPaths, 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) diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index 04e6e27e9..81c8321fb 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -244,6 +244,31 @@ func TestProtectedCredentialsDenyReadAndWriteInSeatbeltProfile(t *testing.T) { } } +func TestMandatoryTokenKeepsExactSeatbeltDenialsUnderParentDenyRead(t *testing.T) { + 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") diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index acbd67989..b3857dde6 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -287,7 +287,9 @@ func applyPatchRequestPaths(args map[string]any) []string { if patch == "" { 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 git applies them. + cwd := firstExactStringArg(args, "cwd") var paths []string parsed, err := PatchHeaderPaths(patch) if err != nil { 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) + } + }) + } +} From af7a3ccb8c0ad569ea044fdfe8a3e52e56dda023 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 19 Aug 2026 14:08:08 +0200 Subject: [PATCH 09/23] test(sandbox): scope Seatbelt token denial to macOS Amp-Thread-ID: https://ampcode.com/threads/T-01a019e6-1e35-7668-8963-ffb208f3a3f8 Co-authored-by: Amp --- internal/sandbox/protected_credentials_test.go | 3 +++ internal/tools/mutation_targets.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index 81c8321fb..de6ba8156 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -245,6 +245,9 @@ func TestProtectedCredentialsDenyReadAndWriteInSeatbeltProfile(t *testing.T) { } 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} diff --git a/internal/tools/mutation_targets.go b/internal/tools/mutation_targets.go index 4797913bc..2ccf310e5 100644 --- a/internal/tools/mutation_targets.go +++ b/internal/tools/mutation_targets.go @@ -5,6 +5,7 @@ import ( "github.com/Gitlawb/zero/internal/sandbox" ) + // MutationTargets returns the workspace-relative paths a tool call will write to, // so the session layer can snapshot their before-state for safe rewind. It is a // pure helper (no I/O beyond path resolution) and returns nil for read-only tools From 01538a8b474a250d44a9864569e1f397434dc431 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 21 Aug 2026 14:36:30 +0200 Subject: [PATCH 10/23] fix(sandbox): reject pre-existing macOS token aliases and pin the case-fold test Address the 08/20-08/21 review round on #685: - The macOS preflight now rejects a mandatory token that already has another directory entry (pathHardLinkCount > 1) before the writable- root loop, mirroring the Linux branch: Seatbelt denies the selected pathname, not the inode, so an existing alias is readable without any shell write access. Darwin regression covers aliased and single-link tokens under a read-only profile. - TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems no longer derives its expectation from protectedPathFoldsCase (a test that asks the code under test what to expect cannot fail): it pins wantDenied on runtime.GOOS and adds an absent-token sub-case where the fold is the only defence (creation/replacement window, no SameFile fallback possible). A wrongly-broad fold on Linux now goes red. - Document in the pathname contract how the exact-bytes arg gate and the case fold compose (extraction vs final containment comparison). - daemon serve-remote help documents the file-token shell limitation on Linux/macOS. --- internal/cli/daemon.go | 5 + internal/sandbox/manager.go | 18 +- internal/sandbox/pathlists.go | 7 +- .../sandbox/protected_credentials_test.go | 170 +++++++++++++++--- 4 files changed, 173 insertions(+), 27 deletions(-) diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 92b9ab3be..f26a58fee 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -65,6 +65,11 @@ Commands: (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. + While a token FILE is selected, sandboxed shell commands + are refused on Linux/macOS: a pathname deny cannot cover + hard-link aliases of the same inode. Prefer the inline + token, or place the file on a filesystem no shell root + shares. 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 diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 112d7d898..fcf6e321a 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -237,7 +237,7 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques } if request.ValidateExecution && preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled && manager.goos == "darwin" && protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentials) { - return SandboxExecutionRequest{}, errors.New("macOS sandbox cannot protect the remote token file from hard-link aliases in a shell-writable root; use ZERO_DAEMON_REMOTE_TOKEN, place the file on a separate filesystem, or remove shell write access") + return SandboxExecutionRequest{}, errors.New("macOS Seatbelt denies the remote token pathname, not its inode, so hard-link aliases defeat it: an existing alias is reachable without any write access and a shell-writable root sharing the token's filesystem can create one; use ZERO_DAEMON_REMOTE_TOKEN, remove aliases of the token file, or place it outside every shell-writable root") } if request.ValidateExecution && preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled && manager.goos == "linux" { if credential, ok := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentials); ok { @@ -303,6 +303,19 @@ func policyHasExplicitDeny(policy Policy) bool { return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0 } +// protectedCredentialLinkableIntoWritableMacOSRoot reports a mandatory token +// whose bearer bytes a sandboxed shell could reach through a second directory +// entry, in either of two classes: +// +// - An EXISTING alias: Seatbelt denies the selected pathname, not the inode, +// so any other name for the same file defeats the denial without waiting +// for shell write access. pathHardLinkCount above one proves one exists +// somewhere the planner cannot enumerate. +// - A FUTURE alias: a writable root that contains the token or shares its +// filesystem lets the shell create the link itself. +// +// This mirrors protectedCredentialLinkableIntoLinuxShellRoot, which applies the +// same existing-link check before its root loop. func protectedCredentialLinkableIntoWritableMacOSRoot(profile PermissionProfile, protected []string) bool { if len(protected) == 0 { return false @@ -325,6 +338,9 @@ func protectedCredentialLinkableIntoWritableMacOSRoot(profile PermissionProfile, if credential == "." || credential == "" { continue } + if count, ok := pathHardLinkCount(credential); ok && count > 1 { + return true + } for _, root := range writeRoots { if pathWithinMacOSRoot(root, credential) || pathsShareFilesystem(root, credential) { return true diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index e710951a5..eb05e21b7 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -89,7 +89,12 @@ func selectedDaemonRemoteTokenFile() string { // 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; see the comment there for the read_file bypass that trimming -// produced. +// produced. This coexists with the case fold below: exact bytes govern +// WHICH argument strings enter the gate (no whitespace coercion), while the +// fold governs only the final lexical containment comparison inside +// pathUnderProtectedRoot. One normalizes nothing before matching; the other +// folds exactly one dimension, on platforms where a case-variant spelling +// opens the same file. // 4. Protection is NOT re-includable. AllowRead, a permission grant, and a // session profile all leave it in place, on every platform. // diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index de6ba8156..f02174cad 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -433,6 +433,74 @@ func TestSandboxManagerRejectsLinuxTokenHardLinkAlias(t *testing.T) { } } +// The macOS mirror of TestSandboxManagerRejectsLinuxTokenHardLinkAlias: an +// alias that predates the command plan needs no writable root at all, because +// Seatbelt denies the selected pathname rather than the inode and the default +// restricted profile still grants reads under /. A second directory entry for +// the token's inode is therefore readable (`cat alias`) even when no shell- +// writable root shares the token's filesystem, so plan construction must fail +// closed on the existing link count alone. +func TestSandboxManagerRejectsMacOSTokenExistingHardLinkAlias(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("hard-link inode probing 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) + } + 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) + + // No writable roots at all: only the existing-alias class can reject this. + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + if !protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { + t.Fatalf("aliased token %q must fail the macOS preflight with no writable root configured", token) + } + + 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", "cat " + alias}, Dir: workspace}, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil { + t.Fatal("BuildCommandPlan succeeded with an existing 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) + } + + // Control: a single-link token under the same read-only profile stays + // accepted here. That case is governed by the documented maintainer policy + // on ordinary file tokens and shell sessions (see the PR description), not + // by this preflight — rejecting it would be a behavior change beyond this + // fix. + single := filepath.Join(tokenDir, "single-token") + if err := os.WriteFile(single, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(daemonRemoteTokenFileEnv, single) + if protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { + t.Fatal("a single-link token with no writable-root overlap must stay governed by the maintainer policy, not auto-rejected") + } +} + // 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. @@ -558,35 +626,87 @@ func TestDisabledPolicyLeavesShellOutsideTheTokenBoundary(t *testing.T) { // case-insensitive — so `.../BRIDGE-TOKEN` missed the protected `.../bridge-token` // while the OS opened the same bearer-token file. On a case-sensitive filesystem // the variant is a genuinely different file and must stay unblocked. +// +// The expectation is pinned on runtime.GOOS rather than derived from +// protectedPathFoldsCase: a test that asks the code under test what to expect +// stays green when that predicate regresses and pins nothing. +// +// The decisive sub-case configures a token pathname WITHOUT creating it — the +// creation/replacement/rotation window. No file exists, so protectedPathDenied's +// SameFile fallback cannot fire and the case fold is the ONLY defence; this is +// also where a wrongly-broad fold shows up, because folding on case-sensitive +// Linux over-denies a genuinely different file, which is its own bug. +// +// Platform note: the fold is load-bearing on darwin alone. pathWithinRoot ends +// in filepath.Rel, whose sameWord already EqualFolds on Windows, so a Windows +// run denies the variant through the plain containment check and cannot detect +// a fold regression; on darwin sameWord is byte-exact, so the absent-token +// sub-case goes red exactly when protectedPathFoldsCase stops folding. The +// macOS CI job is what proves this test's teeth. func TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems(t *testing.T) { - ws, token := protectedTokenFixture(t) - variant := filepath.Join(filepath.Dir(token), strings.ToUpper(filepath.Base(token))) - if variant == token { - t.Fatalf("fixture token %q has no case variant", token) - } - scope, err := NewScope(ws, nil) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowRead: []string{ws}, AllowWrite: []string{ws}} - wantDenied := protectedPathFoldsCase() + wantDenied := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true} - 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) + t.Run("absent token pathname", func(t *testing.T) { + ws, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + configured := filepath.Join(ws, "bridge-token") + t.Setenv(daemonRemoteTokenEnv, "") + t.Setenv(daemonRemoteTokenFileEnv, configured) + variant := filepath.Join(filepath.Dir(configured), strings.ToUpper(filepath.Base(configured))) + if variant == configured { + t.Fatalf("fixture token %q has no case variant", configured) + } + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) } - } - 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) - } - // The exact spelling is denied on every platform regardless. - if block := validatePathWithPolicy(scope, policy, SideEffectRead, true, ws, token); block == nil { - t.Fatalf("the configured token path %q must always be denied", token) - } + 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 absent-token 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 absent-token case variant %q: excluded = %t, want %t", variant, excluded, wantDenied) + } + + // The exact spelling stays denied even though nothing exists yet: the + // lexical check protects the configured pathname through the window. + if block := validatePathWithPolicy(scope, policy, SideEffectWrite, true, ws, configured); block == nil || !strings.Contains(block.Reason, "remote bridge token") { + t.Fatalf("the configured-but-absent token %q must stay unwritable", configured) + } + }) + + t.Run("existing token file", func(t *testing.T) { + ws, token := protectedTokenFixture(t) + variant := filepath.Join(filepath.Dir(token), strings.ToUpper(filepath.Base(token))) + if variant == token { + t.Fatalf("fixture token %q has no case variant", token) + } + scope, err := NewScope(ws, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + existing := Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowRead: []string{ws}, AllowWrite: []string{ws}} + + for _, sideEffect := range []SideEffect{SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace} { + block := validatePathWithPolicy(scope, existing, sideEffect, true, ws, variant) + denied := block != nil && strings.Contains(block.Reason, "remote bridge token") + if denied != wantDenied { + t.Fatalf("%s on case variant %q of an existing token: denied = %t, want %t (block = %#v)", sideEffect, variant, denied, wantDenied, block) + } + } + // The exact spelling is denied on every platform regardless. + if block := validatePathWithPolicy(scope, existing, SideEffectRead, true, ws, token); block == nil { + t.Fatalf("the configured token path %q must always be denied", token) + } + }) } // TestProtectedCredentialsDoNotBlockUnrelatedRequests keeps the exclusion inert From b9b6de872f74363dc4766bd818abad85f956f65c Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 22 Aug 2026 13:25:33 +0200 Subject: [PATCH 11/23] fix(sandbox): preserve daemon token identity --- internal/cli/daemon.go | 15 +- internal/cli/daemon_test.go | 26 +- internal/daemon/remote/auth.go | 58 ++-- internal/daemon/remote/auth_test.go | 35 +- internal/remotetoken/source.go | 110 ++++++ internal/sandbox/manager.go | 99 +----- internal/sandbox/manager_darwin_test.go | 4 +- internal/sandbox/manager_test.go | 58 +--- internal/sandbox/pathlists.go | 201 ++++++----- .../sandbox/protected_credentials_test.go | 327 +++++++++++------- internal/sandbox/risk.go | 130 ++++--- internal/sandbox/runner.go | 13 +- internal/sandbox/runner_test.go | 1 + internal/tools/apply_patch_paths_test.go | 100 ++++++ internal/tools/daemon_token_exclusion_test.go | 1 + 15 files changed, 725 insertions(+), 453 deletions(-) create mode 100644 internal/remotetoken/source.go diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index f26a58fee..a9bd94417 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -65,11 +65,10 @@ Commands: (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. - While a token FILE is selected, sandboxed shell commands - are refused on Linux/macOS: a pathname deny cannot cover - hard-link aliases of the same inode. Prefer the inline - token, or place the file on a filesystem no shell root - shares. + 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 @@ -525,9 +524,9 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - // Pin the token file to the path this process reads BEFORE any worker - // inherits the variable, so a relative or symlinked value cannot make a - // session's sandbox profile protect a different path than the live bearer file. + // 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. if err := remote.CanonicalizeTokenFileEnv(); err != nil { return writeAppError(stderr, err.Error(), exitCrash) } diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index 83d7851df..d11368398 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -44,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) { @@ -228,18 +237,25 @@ func TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers(t *testing } 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) } - want := filepath.Join(startDir, "token") - want, err := filepath.EvalSymlinks(want) + configured, err := filepath.Abs(filepath.Join(startDir, "token")) if err != nil { - t.Fatalf("EvalSymlinks(%q): %v", want, err) + 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_DAEMON_REMOTE_TOKEN_FILE"); got != want { - t.Fatalf("ZERO_DAEMON_REMOTE_TOKEN_FILE = %q, want daemon-pinned path %q", got, want) + if got := os.Getenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED"); got != resolved { + t.Fatalf("resolved token source = %q, want %q", got, resolved) } } diff --git a/internal/daemon/remote/auth.go b/internal/daemon/remote/auth.go index b1cedd455..e70b3b6c3 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -23,16 +23,17 @@ import ( "fmt" "io" "os" - "path/filepath" "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 ) // ErrUnauthorized is returned when a token does not match. @@ -84,14 +85,15 @@ func TokenFilePathFromEnv() string { return configured } -// 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 := TokenFilePathFromEnv(); 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) } @@ -104,45 +106,27 @@ func TokenFromEnv() (string, error) { return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile) } -// CanonicalizeTokenFileEnv rewrites EnvTokenFile in this process's environment -// to the absolute, symlink-resolved path TokenFromEnv actually reads, so every -// child process — and the sandbox profile derived for it — refers to the same -// file this bridge authenticated against. `zero daemon serve-remote` calls it -// before it starts serving. -// -// Two mismatches motivate it. TokenFromEnv passes the value to os.ReadFile, so a -// relative value resolves against the STARTING process's working directory, -// while a worker inherits the same string and resolves it against its own -// session directory — the profile would then protect a path that holds no token -// while the real bearer file stays readable. Resolving symlinks up front also -// keeps a link pathname out of the derived deny rules, which matters because -// bubblewrap cannot mount over a symlink destination. +// 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 { - if strings.TrimSpace(os.Getenv(EnvToken)) != "" { - return nil - } - configured := TokenFilePathFromEnv() - if configured == "" { - return nil - } - absolute, err := filepath.Abs(configured) - if err != nil { - return fmt.Errorf("remote: resolve token file %q: %w", configured, err) - } - resolved, err := filepath.EvalSymlinks(absolute) + source, selected, err := remotetoken.ResolveSource() if err != nil { - // TokenFromEnv would fail on the same path a moment later; reporting it here - // names the resolution step that failed. - return fmt.Errorf("remote: resolve token file %q: %w", configured, err) + return fmt.Errorf("remote: %w", err) } - if resolved == configured { + if !selected { return nil } - return os.Setenv(EnvTokenFile, resolved) + if err := remotetoken.PersistSource(source); err != nil { + return fmt.Errorf("remote: persist token file source: %w", err) + } + return nil } // Attestation is an optional post-token hook (e.g. workload attestation). The diff --git a/internal/daemon/remote/auth_test.go b/internal/daemon/remote/auth_test.go index 0ba660df4..aa1792353 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -83,6 +83,7 @@ func TestTokenFilePathFromEnvPreservesFilenameWhitespace(t *testing.T) { // 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") } @@ -110,9 +111,10 @@ func TestCanonicalizeTokenFileEnvKeepsTrailingSpaceFilename(t *testing.T) { } } -// TestCanonicalizeTokenFileEnv pins the value every child process (and the -// sandbox profile derived for it) inherits to the file this process reads. +// 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) @@ -142,7 +144,7 @@ func TestCanonicalizeTokenFileEnv(t *testing.T) { } }) - t.Run("symlinked pathname is resolved", func(t *testing.T) { + 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) @@ -152,8 +154,31 @@ func TestCanonicalizeTokenFileEnv(t *testing.T) { if err := CanonicalizeTokenFileEnv(); err != nil { t.Fatalf("CanonicalizeTokenFileEnv: %v", err) } - if got := os.Getenv(EnvTokenFile); got != token { - t.Fatalf("%s = %q, want the resolved target %q", EnvTokenFile, got, token) + 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) } }) diff --git a/internal/remotetoken/source.go b/internal/remotetoken/source.go new file mode 100644 index 000000000..adb8d8684 --- /dev/null +++ b/internal/remotetoken/source.go @@ -0,0 +1,110 @@ +// 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" +) + +// 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 +} + +// 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 + } + } + 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 + } + return os.Setenv(EnvTokenFileResolved, source.Resolved) +} + +// 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/manager.go b/internal/sandbox/manager.go index fcf6e321a..0c89f7416 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -235,9 +235,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques (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" && - protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentials) { - return SandboxExecutionRequest{}, errors.New("macOS Seatbelt denies the remote token pathname, not its inode, so hard-link aliases defeat it: an existing alias is reachable without any write access and a shell-writable root sharing the token's filesystem can create one; use ZERO_DAEMON_REMOTE_TOKEN, remove aliases of the token file, or place it outside every shell-writable root") + 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 == "linux" { if credential, ok := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentials); ok { @@ -303,64 +303,21 @@ func policyHasExplicitDeny(policy Policy) bool { return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0 } -// protectedCredentialLinkableIntoWritableMacOSRoot reports a mandatory token -// whose bearer bytes a sandboxed shell could reach through a second directory -// entry, in either of two classes: -// -// - An EXISTING alias: Seatbelt denies the selected pathname, not the inode, -// so any other name for the same file defeats the denial without waiting -// for shell write access. pathHardLinkCount above one proves one exists -// somewhere the planner cannot enumerate. -// - A FUTURE alias: a writable root that contains the token or shares its -// filesystem lets the shell create the link itself. -// -// This mirrors protectedCredentialLinkableIntoLinuxShellRoot, which applies the -// same existing-link check before its root loop. -func protectedCredentialLinkableIntoWritableMacOSRoot(profile PermissionProfile, protected []string) bool { - if len(protected) == 0 { - return false - } - if profile.FileSystem.Kind == FileSystemUnrestricted { - return 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 - } - if count, ok := pathHardLinkCount(credential); ok && count > 1 { - return true - } - for _, root := range writeRoots { - if pathWithinMacOSRoot(root, credential) || pathsShareFilesystem(root, credential) { - return true - } - } - } - return false -} +// 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. // -// The bubblewrap plan binds /dev/null over each configured pathname, which hides -// that name and nothing else. A hard link is another name for the same inode, so -// an alias in any root the shell can read defeats the mask, and one that already -// exists needs neither the token-file variable nor a new link operation. Linking -// requires the alias and the target to sit on one filesystem, so sharing a device -// with a shell-visible root is what makes the class reachable; an existing link -// count above one proves an alias the planner cannot enumerate. +// 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 @@ -371,15 +328,12 @@ func protectedCredentialLinkableIntoLinuxShellRoot(profile PermissionProfile, pr if profile.FileSystem.Kind != FileSystemRestricted { return "", false } - // Read roots count as well as write roots: reading an existing alias is - // enough to recover the token, no write access required. - roots := make([]string, 0, len(profile.FileSystem.ReadRoots)+len(profile.FileSystem.WriteRoots)+len(sandboxWritableSubpaths)) - roots = append(roots, normalizeProfilePaths(profile.FileSystem.ReadRoots)...) + writeRoots := make([]string, 0, len(profile.FileSystem.WriteRoots)+len(sandboxWritableSubpaths)) for _, root := range profile.FileSystem.WriteRoots { - roots = append(roots, normalizeProfilePath(root.Root)) + writeRoots = append(writeRoots, normalizeProfilePath(root.Root)) } if profile.FileSystem.AllowTemp { - roots = append(roots, normalizeProfilePaths(sandboxWritableSubpaths)...) + writeRoots = append(writeRoots, normalizeProfilePaths(sandboxWritableSubpaths)...) } for _, credential := range protected { credential = filepath.Clean(credential) @@ -389,7 +343,7 @@ func protectedCredentialLinkableIntoLinuxShellRoot(profile PermissionProfile, pr if count, ok := pathHardLinkCount(credential); ok && count > 1 { return credential, true } - for _, root := range roots { + for _, root := range writeRoots { if root == "" { continue } @@ -401,25 +355,6 @@ func protectedCredentialLinkableIntoLinuxShellRoot(profile PermissionProfile, pr return "", false } -func pathWithinMacOSRoot(root, candidate string) bool { - if pathWithinRoot(root, candidate) || pathWithinRoot(strings.ToLower(root), strings.ToLower(candidate)) { - return true - } - rootInfo, err := os.Stat(root) - if err != nil { - return false - } - for current := candidate; ; current = filepath.Dir(current) { - if info, err := os.Stat(current); err == nil && os.SameFile(rootInfo, info) { - return true - } - parent := filepath.Dir(current) - if parent == current { - 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 index c05ae833c..7c809ddfb 100644 --- a/internal/sandbox/manager_darwin_test.go +++ b/internal/sandbox/manager_darwin_test.go @@ -41,7 +41,7 @@ func TestSandboxManagerRejectsMacOSTokenHardLinkableIntoWritableWorkspace(t *tes Preference: SandboxPreferenceAuto, ValidateExecution: true, }) - if err == nil || !strings.Contains(err.Error(), "hard-link aliases") { - t.Fatalf("BuildCommandPlan error = %v, want macOS hard-link-alias failure", err) + 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 a54e93f85..e7566239f 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -280,9 +280,9 @@ func TestSandboxManagerRejectsUnavailableBackendForProtectedToken(t *testing.T) } } -func TestSandboxManagerRejectsMacOSTokenInsideWritableWorkspace(t *testing.T) { +func TestSandboxManagerRejectsMacOSFileTokenShell(t *testing.T) { t.Setenv(daemonRemoteTokenEnv, "") - t.Setenv(daemonRemoteTokenFileEnv, "/workspace/bridge-token") + t.Setenv(daemonRemoteTokenFileEnv, "/credentials/bridge-token") workspace := "/workspace" policy := DefaultPolicy() backend := Backend{ @@ -294,54 +294,18 @@ func TestSandboxManagerRejectsMacOSTokenInsideWritableWorkspace(t *testing.T) { 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), + 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(), "hard-link aliases") { - t.Fatalf("BuildCommandPlan error = %v, want macOS hard-link-alias failure", err) - } -} - -func TestProtectedCredentialLinkableIntoWritableMacOSRoot(t *testing.T) { - restricted := PermissionProfile{FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - WriteRoots: []WritableRoot{{Root: "/Users/Test/Workspace"}}, - }} - if !protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/users/test/workspace/token")}) { - t.Fatal("case-variant token under a macOS write root should be rejected") - } - if protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/Users/Test/Credentials/token")}) { - t.Fatal("token outside every macOS write root should remain allowed") - } - restricted.FileSystem.AllowTemp = true - if !protectedCredentialLinkableIntoWritableMacOSRoot(restricted, []string{normalizeProfilePath("/private/tmp/bridge-token")}) { - t.Fatal("token under an allowed temporary root should be rejected") - } - unrestricted := PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted}} - if !protectedCredentialLinkableIntoWritableMacOSRoot(unrestricted, []string{"/credentials/token"}) { - t.Fatal("an unrestricted macOS filesystem makes every token path shell-writable") - } - - writable := t.TempDir() - targetDir := t.TempDir() - target := filepath.Join(targetDir, "token") - if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(writable, "token-link") - if err := os.Symlink(target, link); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - symlinkProfile := PermissionProfile{FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - WriteRoots: []WritableRoot{{Root: writable}}, - }} - if !protectedCredentialLinkableIntoWritableMacOSRoot(symlinkProfile, []string{link, target}) { - t.Fatal("selected symlink inside a write root must be rejected even when its target is outside") + 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/pathlists.go b/internal/sandbox/pathlists.go index eb05e21b7..e9eb94852 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -3,8 +3,9 @@ package sandbox import ( "os" "path/filepath" - "runtime" "strings" + + "github.com/Gitlawb/zero/internal/remotetoken" ) // This file implements the fine-grained AllowRead/DenyRead/AllowWrite/DenyWrite @@ -42,34 +43,14 @@ func resolvePolicyPath(entry string) (string, bool) { return resolved, true } -// These name the alternative sources of the remote bridge's bearer token. They -// are duplicated from internal/daemon/remote (which cannot be imported here) -// exactly like the copies scrubSensitiveEnv keeps. +// These aliases keep the sandbox tests and environment boundary tied to the +// shared token-source model instead of duplicating string literals. const ( - daemonRemoteTokenEnv = "ZERO_DAEMON_REMOTE_TOKEN" - daemonRemoteTokenFileEnv = "ZERO_DAEMON_REMOTE_TOKEN_FILE" + daemonRemoteTokenEnv = remotetoken.EnvToken + daemonRemoteTokenFileEnv = remotetoken.EnvTokenFile + daemonRemoteTokenFileResolvedEnv = remotetoken.EnvTokenFileResolved ) -// selectedDaemonRemoteTokenFile returns the token-file pointer only when the -// daemon would use it. TokenFromEnv gives the inline token precedence, so an -// inherited file pointer is not a credential when both variables are set. -// -// The pointer is used verbatim, mirroring remote.TokenFilePathFromEnv: the -// daemon reads whatever bytes the variable names, so trimming whitespace here -// would leave a token file whose name begins or ends with a space unprotected -// while the daemon still reads it. Only a value that is entirely whitespace -// counts as unset. -func selectedDaemonRemoteTokenFile() string { - if strings.TrimSpace(os.Getenv(daemonRemoteTokenEnv)) != "" { - return "" - } - configured := os.Getenv(daemonRemoteTokenFileEnv) - if strings.TrimSpace(configured) == "" { - return "" - } - return configured -} - // The daemon-token pathname contract // // Every layer that interprets ZERO_DAEMON_REMOTE_TOKEN_FILE must agree on the @@ -78,23 +59,16 @@ func selectedDaemonRemoteTokenFile() string { // 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 (selectedDaemonRemoteTokenFile). It is never trimmed, +// 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, so anything else protects a file the -// daemon does not read. -// 2. Both the SELECTED spelling and the target it currently resolves to are -// protected (daemonTokenDenyPaths). `serve-remote` canonicalizes what it -// selects, but an inherited symlinked value must not leave the link -// replaceable. +// 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; see the comment there for the read_file bypass that trimming -// produced. This coexists with the case fold below: exact bytes govern -// WHICH argument strings enter the gate (no whitespace coercion), while the -// fold governs only the final lexical containment comparison inside -// pathUnderProtectedRoot. One normalizes nothing before matching; the other -// folds exactly one dimension, on platforms where a case-variant spelling -// opens the same file. +// 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. // @@ -104,47 +78,17 @@ func selectedDaemonRemoteTokenFile() string { // protectedCredentialPaths returns credential files that Zero's own in-process // file tools must never read or modify, independent of Policy. // -// This is deliberately separate from credentialDenyReadPaths, which only shapes -// the OS sandbox profile for wrapped shell commands: read_file resolves scoped -// paths itself, and grep/glob build their exclusions from the policy, so a -// profile-only rule leaves the in-process tool boundary open. It is also -// separate from Policy.DenyRead, whose emptiness gates escalated (unsandboxed) -// execution and must keep reflecting user configuration alone. -// -// Entries here are NOT re-includable through AllowRead/AllowWrite or a -// session/turn permission profile: the bridge bearer token grants control of -// this daemon, so a remote-controlled agent must not be able to read it, nor -// replace it to hijack the next bridge start, even when the file sits inside -// its own session workspace. Unlike the profile list this applies on Windows -// too, where filesystem deny-read has no sandbox representation (#662). -// -// Both the selected pathname and the target it currently resolves to are -// protected: `zero daemon serve-remote` canonicalizes the value it selects (see -// remote.CanonicalizeTokenFileEnv), but a symlinked selected value inherited -// from elsewhere must not leave the link replaceable. +// 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 { - // os.ReadFile — the daemon's own reader — treats the value literally, so a - // relative path resolves against the working directory and a leading "~" is - // NOT expanded. resolvePolicyPath would expand it and protect the wrong file. - return daemonTokenDenyPaths(selectedDaemonRemoteTokenFile()) -} - -// daemonTokenDenyPaths is the shared pathname authority for both the -// in-process boundary and OS sandbox profiles. Filename whitespace is data: -// only an entirely blank setting is unset, matching the daemon reader. -func daemonTokenDenyPaths(configured string) []string { - if strings.TrimSpace(configured) == "" { + source, selected := remotetoken.SourceFromEnv() + if !selected { return nil } - absolute, err := filepath.Abs(configured) - if err != nil { - return nil - } - paths := []string{absolute} - if resolved, err := filepath.EvalSymlinks(absolute); err == nil && resolved != absolute { - paths = append(paths, resolved) - } - return dedupeStrings(paths) + return dedupeStrings(source.Paths()) } // protectedCredentialPathBlock returns the block for the first requested path @@ -238,41 +182,96 @@ func protectedPathDenied(protected []string, workspaceRoot, path string) bool { return false } -// protectedPathFoldsCase reports whether a case-variant spelling of a path opens -// the SAME file on this platform, so the protected-credential comparison must -// fold case to stay closed. -// -// pathWithinRoot ends in filepath.Rel, which ALREADY folds case on Windows -// (path_windows.go's sameWord uses strings.EqualFold) but never on Unix. macOS -// volumes are case-insensitive by default (APFS), so without folding a request -// for `.../Bridge-Token` misses a protected `.../bridge-token` while the OS -// opens the very same bearer-token file. Windows is listed too so the guarantee -// does not silently depend on a filepath.Rel implementation detail. -// -// Case-sensitive outliers (a case-sensitive APFS volume) only make this -// over-deny a genuinely different file that happens to differ by case alone, -// which is the safe direction for a credential the sandbox must never expose. -func protectedPathFoldsCase() bool { - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" +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 } // pathUnderProtectedRoot is pathUnderPolicyRoot for the automatic credential -// exclusions: identical anchoring and symlink normalization, plus the platform's -// filesystem case semantics. Only the final containment comparison folds — the -// normalization above it keeps operating on the path as spelled, so symlink -// resolution is unaffected. +// exclusions: identical anchoring and symlink normalization, plus the owning +// filesystem's case semantics. func pathUnderProtectedRoot(requestedPath, root, workspaceRoot string) bool { normalized, ok := normalizePathForPolicyRoot(requestedPath, root, workspaceRoot) if !ok { return false } - if pathWithinRoot(root, normalized) { + if pathWithinRootExact(root, normalized) { return true } - if !protectedPathFoldsCase() { + if !protectedPathFoldsCase(root) { return false } - return pathWithinRoot(strings.ToLower(root), strings.ToLower(normalized)) + 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, diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index f02174cad..f8468151b 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -119,6 +119,47 @@ func TestProtectedCredentialPathsResolveLikeTheDaemonReader(t *testing.T) { } } }) + + 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: @@ -306,9 +347,6 @@ func TestProtectedCredentialFilenameWhitespaceReachesOSSandbox(t *testing.T) { plan := mustBuildLinuxBwrapFilesystemPlan(t, profile) assertArgsContainSequence(t, plan.Args, "--ro-bind", "/dev/null", token) - if !protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { - t.Fatalf("spaced token %q under writable root %q must fail macOS preflight", token, workspace) - } } // A mandatory token named by a symlink fails the plan outright: bubblewrap @@ -433,16 +471,71 @@ func TestSandboxManagerRejectsLinuxTokenHardLinkAlias(t *testing.T) { } } -// The macOS mirror of TestSandboxManagerRejectsLinuxTokenHardLinkAlias: an -// alias that predates the command plan needs no writable root at all, because -// Seatbelt denies the selected pathname rather than the inode and the default -// restricted profile still grants reads under /. A second directory entry for -// the token's inode is therefore readable (`cat alias`) even when no shell- -// writable root shares the token's filesystem, so plan construction must fail -// closed on the existing link count alone. -func TestSandboxManagerRejectsMacOSTokenExistingHardLinkAlias(t *testing.T) { +// 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 pathsShareFilesystem(workspace, tokenDir) { + t.Skip("/dev/shm is not a 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("hard-link inode probing is exercised on darwin") + t.Skip("macOS file-token shell contract is exercised on darwin") } workspace := t.TempDir() tokenDir := t.TempDir() @@ -450,14 +543,9 @@ func TestSandboxManagerRejectsMacOSTokenExistingHardLinkAlias(t *testing.T) { 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) - // No writable roots at all: only the existing-alias class can reject this. profile := PermissionProfile{ FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, @@ -465,39 +553,18 @@ func TestSandboxManagerRejectsMacOSTokenExistingHardLinkAlias(t *testing.T) { }, Network: NetworkPolicy{Mode: NetworkDeny}, } - if !protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { - t.Fatalf("aliased token %q must fail the macOS preflight with no writable root configured", token) - } - 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", "cat " + alias}, Dir: workspace}, + Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace}, Policy: policy, Profile: profile, Preference: SandboxPreferenceAuto, ValidateExecution: true, }) - if err == nil { - t.Fatal("BuildCommandPlan succeeded with an existing 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) - } - - // Control: a single-link token under the same read-only profile stays - // accepted here. That case is governed by the documented maintainer policy - // on ordinary file tokens and shell sessions (see the PR description), not - // by this preflight — rejecting it would be a behavior change beyond this - // fix. - single := filepath.Join(tokenDir, "single-token") - if err := os.WriteFile(single, []byte("secret"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv(daemonRemoteTokenFileEnv, single) - if protectedCredentialLinkableIntoWritableMacOSRoot(profile, protectedCredentialPaths()) { - t.Fatal("a single-link token with no writable-root overlap must stay governed by the maintainer policy, not auto-rejected") + if err == nil || !strings.Contains(err.Error(), "file-backed remote token") { + t.Fatalf("BuildCommandPlan error = %v, want unconditional macOS file-token shell refusal", err) } } @@ -620,93 +687,115 @@ func TestDisabledPolicyLeavesShellOutsideTheTokenBoundary(t *testing.T) { } } -// TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems covers the -// bypass a case-variant spelling opened: pathWithinRoot ends in filepath.Rel, -// which folds case on Windows but NOT on darwin, whose default APFS volume is -// case-insensitive — so `.../BRIDGE-TOKEN` missed the protected `.../bridge-token` -// while the OS opened the same bearer-token file. On a case-sensitive filesystem -// the variant is a genuinely different file and must stay unblocked. -// -// The expectation is pinned on runtime.GOOS rather than derived from -// protectedPathFoldsCase: a test that asks the code under test what to expect -// stays green when that predicate regresses and pins nothing. -// -// The decisive sub-case configures a token pathname WITHOUT creating it — the -// creation/replacement/rotation window. No file exists, so protectedPathDenied's -// SameFile fallback cannot fire and the case fold is the ONLY defence; this is -// also where a wrongly-broad fold shows up, because folding on case-sensitive -// Linux over-denies a genuinely different file, which is its own bug. -// -// Platform note: the fold is load-bearing on darwin alone. pathWithinRoot ends -// in filepath.Rel, whose sameWord already EqualFolds on Windows, so a Windows -// run denies the variant through the plain containment check and cannot detect -// a fold regression; on darwin sameWord is byte-exact, so the absent-token -// sub-case goes red exactly when protectedPathFoldsCase stops folding. The -// macOS CI job is what proves this test's teeth. -func TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems(t *testing.T) { - wantDenied := runtime.GOOS == "windows" || runtime.GOOS == "darwin" +func TestProtectedCredentialsFollowFilesystemCaseSemantics(t *testing.T) { policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true} - t.Run("absent token pathname", func(t *testing.T) { - ws, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatalf("EvalSymlinks: %v", err) + for _, existing := range []bool{false, true} { + name := "absent token pathname" + if existing { + name = "existing token file" } - configured := filepath.Join(ws, "bridge-token") - t.Setenv(daemonRemoteTokenEnv, "") - t.Setenv(daemonRemoteTokenFileEnv, configured) - variant := filepath.Join(filepath.Dir(configured), strings.ToUpper(filepath.Base(configured))) - if variant == configured { - t.Fatalf("fixture token %q has no case variant", 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 absent-token case variant %q: denied = %t, want %t (block = %#v)", sideEffect, variant, denied, wantDenied, block) + 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) } - } - engine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: policy, Scope: scope}) - if excluded := engine.ReadExclusions().PathExcluded(variant); excluded != wantDenied { - t.Fatalf("read exclusions on absent-token case variant %q: excluded = %t, want %t", variant, excluded, wantDenied) - } - // The exact spelling stays denied even though nothing exists yet: the - // lexical check protects the configured pathname through the window. - if block := validatePathWithPolicy(scope, policy, SideEffectWrite, true, ws, configured); block == nil || !strings.Contains(block.Reason, "remote bridge token") { - t.Fatalf("the configured-but-absent token %q must stay unwritable", configured) - } - }) + 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) + } + }) + } +} - t.Run("existing token file", func(t *testing.T) { - ws, token := protectedTokenFixture(t) - variant := filepath.Join(filepath.Dir(token), strings.ToUpper(filepath.Base(token))) - if variant == token { - t.Fatalf("fixture token %q has no case variant", token) - } - scope, err := NewScope(ws, nil) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - existing := Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowRead: []string{ws}, AllowWrite: []string{ws}} +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) +} - for _, sideEffect := range []SideEffect{SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace} { - block := validatePathWithPolicy(scope, existing, sideEffect, true, ws, variant) - denied := block != nil && strings.Contains(block.Reason, "remote bridge token") - if denied != wantDenied { - t.Fatalf("%s on case variant %q of an existing token: denied = %t, want %t (block = %#v)", sideEffect, variant, denied, wantDenied, block) +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 } } - // The exact spelling is denied on every platform regardless. - if block := validatePathWithPolicy(scope, existing, SideEffectRead, true, ws, token); block == nil { - t.Fatalf("the configured token path %q must always be denied", token) - } - }) + } + 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 diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index b3857dde6..dc42ede78 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -376,6 +376,8 @@ func patchHeaderPaths(patch string) ([]string, error) { inHunk := false type diffSection struct { rawDiff string + parsedSource string + parsedDestination string diffParsed bool extendedSource string extendedDestination string @@ -397,24 +399,26 @@ func patchHeaderPaths(patch string) ([]string, error) { if section.hasUnifiedSource != section.hasUnifiedDestination { return fmt.Errorf("incomplete unified file path headers") } - if section.diffParsed { - return nil - } + var source, destination string switch { - case section.hasExtendedSource && section.hasExtendedDest: - source = "a/" + section.extendedSource - destination = "b/" + section.extendedDestination - case section.hasUnifiedSource && section.hasUnifiedDestination && section.unifiedSource != "/dev/null" && section.unifiedDestination != "/dev/null": - source = section.unifiedSource - destination = section.unifiedDestination + case section.hasExtendedSource: + source, destination = section.extendedSource, section.extendedDestination + if !diffGitLineMatchesChange(section.rawDiff, section.parsedSource, section.parsedDestination, section.diffParsed, source, destination) { + return fmt.Errorf("diff --git paths disagree with the copy or rename headers") + } + case section.hasUnifiedSource && section.unifiedSource != "/dev/null" && section.unifiedDestination != "/dev/null": + var ok bool + source, destination, ok = normalizeUnifiedDiffPaths(section.rawDiff, section.parsedSource, section.parsedDestination, section.diffParsed, section.unifiedSource, section.unifiedDestination) + if !ok { + return fmt.Errorf("diff --git paths disagree with the unified file path headers") + } + case section.diffParsed: + source, destination = normalizeDiffGitPaths(section.parsedSource, section.parsedDestination) default: return fmt.Errorf("ambiguous diff --git path operands") } - if !diffGitLineMatchesPaths(section.rawDiff, source, destination) { - return fmt.Errorf("diff --git paths disagree with the file path headers") - } - paths = append(paths, stripPatchPrefix(source), stripPatchPrefix(destination)) + paths = append(paths, filepath.ToSlash(source), filepath.ToSlash(destination)) return nil } setExtendedPath := func(source bool, path string) error { @@ -429,7 +433,6 @@ func patchHeaderPaths(patch string) ([]string, error) { } section.extendedDestination, section.hasExtendedDest = path, true } - paths = append(paths, filepath.ToSlash(path)) return nil } for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") { @@ -453,10 +456,7 @@ func patchHeaderPaths(patch string) ([]string, error) { return nil, err } section = diffSection{rawDiff: line[len("diff --git "):]} - if source, destination, ok := parseDiffGitPaths(line[len("diff --git "):]); ok { - paths = append(paths, stripPatchPrefix(source), stripPatchPrefix(destination)) - section.diffParsed = true - } + section.parsedSource, section.parsedDestination, section.diffParsed = parseDiffGitPaths(section.rawDiff) case strings.HasPrefix(line, "copy from "): path, ok := parseExtendedGitPath(line[len("copy from "):]) if !ok || section.rawDiff == "" { @@ -509,19 +509,28 @@ func patchHeaderPaths(patch string) ([]string, error) { oldRemaining, newRemaining = parsePatchHunkCounts(line) inHunk = oldRemaining > 0 || newRemaining > 0 case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "): + if strings.HasPrefix(line, "--- ") && section.rawDiff != "" && + section.hasUnifiedSource && section.hasUnifiedDestination { + if err := flushSection(); err != nil { + return nil, err + } + section = diffSection{} + } path, ok := patchFileHeaderPath(line) if !ok { return nil, fmt.Errorf("invalid unified file path header") } - if path != "" { + if path == "" { + continue + } + if section.rawDiff == "" { paths = append(paths, stripPatchPrefix(path)) - if section.rawDiff != "" { - if strings.HasPrefix(line, "--- ") { - section.unifiedSource, section.hasUnifiedSource = path, true - } else { - section.unifiedDestination, section.hasUnifiedDestination = path, true - } - } + continue + } + if strings.HasPrefix(line, "--- ") { + section.unifiedSource, section.hasUnifiedSource = path, true + } else { + section.unifiedDestination, section.hasUnifiedDestination = path, true } } } @@ -530,7 +539,6 @@ func patchHeaderPaths(patch string) ([]string, error) { } return paths, nil } - func parseDiffGitPaths(line string) (string, string, bool) { if line == "" { return "", "", false @@ -544,8 +552,8 @@ func parseDiffGitPaths(line string) (string, string, bool) { return source, destination, ok && validDiffGitPaths(source, destination) } - // When only the destination is quoted, its opening quote uniquely separates - // the two operands even if the unquoted source contains ordinary spaces. + // 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] @@ -560,13 +568,10 @@ func parseDiffGitPaths(line string) (string, string, bool) { separator += next + 1 } - // For unquoted operands, only a space immediately followed by the synthetic - // b/ prefix can be the separator. Preserve exactly one separator byte: any - // preceding or trailing spaces remain filename data. type candidate struct{ source, destination string } var candidates []candidate - for separator := 0; separator+1 < len(line); separator++ { - if line[separator] != ' ' || !strings.HasPrefix(line[separator+1:], "b/") { + for separator := range len(line) { + if line[separator] != ' ' { continue } source, destination := line[:separator], line[separator+1:] @@ -574,18 +579,27 @@ func parseDiffGitPaths(line string) (string, string, bool) { candidates = append(candidates, candidate{source: source, destination: destination}) } } - if len(candidates) == 1 { - return candidates[0].source, candidates[0].destination, true + 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[2:] == candidate.destination[2:] { + 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 } @@ -601,11 +615,49 @@ func parseWholeGitPath(input string) (string, bool) { } func validDiffGitPaths(source, destination string) bool { - return len(source) > 2 && len(destination) > 2 && strings.HasPrefix(source, "a/") && strings.HasPrefix(destination, "b/") + 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 +} + +func diffGitLineMatchesChange(raw, parsedSource, parsedDestination string, parsed bool, source, destination string) bool { + if parsed { + if parsedSource == source && parsedDestination == destination { + return true + } + if parsedSource == "a/"+source && parsedDestination == "b/"+destination { + return true + } + } + return diffGitLineMatchesPaths(raw, source, destination) || + diffGitLineMatchesPaths(raw, "a/"+source, "b/"+destination) +} + +func normalizeUnifiedDiffPaths(raw, parsedSource, parsedDestination string, parsed bool, source, destination string) (string, string, bool) { + if hasDefaultGitPrefixes(source, destination) { + normalizedSource, normalizedDestination := source[2:], destination[2:] + if diffGitLineMatchesChange(raw, parsedSource, parsedDestination, parsed, normalizedSource, normalizedDestination) { + return normalizedSource, normalizedDestination, true + } + } + if diffGitLineMatchesChange(raw, parsedSource, parsedDestination, parsed, source, destination) { + return source, destination, true + } + return "", "", false } func diffGitLineMatchesPaths(line, source, destination string) bool { - for separator := 0; separator < len(line); separator++ { + for separator := range len(line) { if line[separator] == ' ' && line[:separator] == source && line[separator+1:] == destination { return true } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index c6eeeac76..c1358efaf 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -1151,14 +1151,11 @@ 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, } for _, descriptor := range providercatalog.All() { for _, key := range descriptor.AuthEnvVars { diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index c41da3b2d..30d4af13c 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -871,6 +871,7 @@ 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", "COMPANY_LLM_SECRET=custom-secret", "ZERO_OAUTH_MY_SVC_CLIENT_SECRET=oauth-secret", "zero_oauth_second_client_secret=case-insensitive-secret", diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index 853d4b3f6..0af3a5af8 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -1,7 +1,11 @@ package tools import ( + "os" + "os/exec" + "path/filepath" "slices" + "strings" "testing" "github.com/Gitlawb/zero/internal/sandbox" @@ -84,3 +88,99 @@ func TestPatchHeaderPathsRejectsAmbiguousDiffOperands(t *testing.T) { t.Fatalf("PatchHeaderPaths = %q, want ambiguous-path error", paths) } } + +func TestPatchHeaderPathsParsesGitDefaultAndNoPrefixOutput(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 := mustPatchHeaderPaths(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 TestPatchHeaderPathsRejectsMismatchedNoPrefixRenameMetadata(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 paths, err := sandbox.PatchHeaderPaths(patch); err == nil { + t.Fatalf("PatchHeaderPaths = %q, want mismatched-header error", paths) + } +} + +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 + 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/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index f0b63c05e..f5032bbbd 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -400,6 +400,7 @@ func TestApplyPatchDeniesTrailingSpaceDaemonTokenPath(t *testing.T) { // CanonicalizeTokenFileEnv is the production daemon boundary and preserves // filename whitespace while converting the selected path to an absolute one. t.Setenv(remote.EnvTokenFile, filepath.Join(ws, ".", "bridge-token ")) + t.Setenv(remote.EnvTokenFileResolved, "") if err := remote.CanonicalizeTokenFileEnv(); err != nil { t.Fatalf("CanonicalizeTokenFileEnv: %v", err) } From 476d706c3b61b272345ca9fadd370102a118cfe0 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 23 Aug 2026 16:28:04 +0200 Subject: [PATCH 12/23] fix(sandbox): address CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven unresolved threads from the 2026-08-22 review. Fail closed where the answer is unknown. pathsShareFilesystem mapped a stat failure to "separate filesystem", so an uninspectable writable root read as safe placement for the token; it now returns an explicit known result and the Linux planner refuses on !known || shared. pathHardLinkCount returns an error instead of a bare ok, so only a missing token (fs.ErrNotExist — no inode to alias, and the lexical rule is what reserves the pathname through rotation) is tolerated; any other inspection failure refuses. Windows joins macOS in refusing a sandboxed shell while a file-backed token is selected. credentialDenyReadPaths returns nothing on Windows (the ACL model has no read-deny rule, #662), so native execution was wrapping a shell with the token readable under every pathname. The in-process tool gate is unchanged there and still covers read_file and friends. Bind the MCP resources/read exclusion to the handle it opened. The check ran against a pathname that os.Stat and os.ReadFile then reopened, so a rename or repointed symlink could swap the object after the check, and a small checked file could be replaced by an unbounded one after the size check. It now opens once, stats the handle, tests the exclusion against that FileInfo (ReadExclusions.FileExcluded), and reads through a maxResourceBytes+1 limit. Stop overstating the in-process inode closure. The comment claimed inode-level closure; it closes aliases that exist at check time and stay put, not a concurrent swap, because the tool layer opens the path itself. Say that, and point at FileExcluded as the handle-bound form. Cache the protected set's per-run constants. PathExcluded paid EvalSymlinks, a stat per protected entry, and an ancestor-walking case probe for every walked file. protectedPathCache resolves the case decision and each entry's FileInfo once per exclusions object, and the inode branch is skipped for non-regular entries. Size/Mode were not added as a SameFile pre-filter: a stale cached size would skip the comparison on a token modified mid-walk. Drop TokenFilePathFromEnv, which had no production caller and was failing the dead-code checks; remotetoken.SelectedFilePath is the live selector and its test now covers the whitespace rule plus inline-token precedence. Make the trailing-space apply_patch row prove what refused it. Both patch header parsers trim identically, so that row was passing on the executor failing over a nonexistent trimmed path. It now asserts the refusal came from the sandbox, pins the parser agreement for the untrimmable spelling, and requires the credential gate specifically for the relative one. Validation: go build ./..., go vet ./... (also GOOS=linux and GOOS=darwin), gofmt clean, deadcode clean, go test ./internal/... (internal/cli provider-config failures are pre-existing on this branch and unrelated). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgWC2FnDp5Jjdvc6cqEfEQ --- internal/daemon/remote/auth.go | 17 -- internal/daemon/remote/auth_test.go | 25 ++- internal/mcp/daemon_token_test.go | 38 +++++ internal/mcp/resources.go | 24 ++- internal/sandbox/engine.go | 18 ++- internal/sandbox/filesystem_other.go | 13 +- internal/sandbox/filesystem_unix.go | 34 ++-- internal/sandbox/manager.go | 28 +++- internal/sandbox/pathlists.go | 153 ++++++++++++++++-- .../sandbox/protected_credentials_test.go | 91 ++++++++++- internal/tools/daemon_token_matrix_test.go | 33 ++++ 11 files changed, 406 insertions(+), 68 deletions(-) diff --git a/internal/daemon/remote/auth.go b/internal/daemon/remote/auth.go index e70b3b6c3..2a8208f26 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -68,23 +68,6 @@ func (a *TokenAuthenticator) Authenticate(token string) error { return ErrUnauthorized } -// TokenFilePathFromEnv returns the configured token-file pointer exactly as the -// operator set it, or "" when the variable is unset or holds only whitespace. -// -// The value is a PATHNAME, and every consumer of it — os.ReadFile here, the -// canonicalization below, the sandbox's protected-credential list — must agree -// on which bytes name the file. A filename may legitimately begin or end with a -// space, so trimming the value would make this boundary read one file while the -// deny rules protect another. A value that is only whitespace still reads as -// unset, which is what a blank variable means. -func TokenFilePathFromEnv() string { - configured := os.Getenv(EnvTokenFile) - if strings.TrimSpace(configured) == "" { - return "" - } - return configured -} - // 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. diff --git a/internal/daemon/remote/auth_test.go b/internal/daemon/remote/auth_test.go index aa1792353..1410805e1 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "runtime" "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" ) func TestTokenAuthenticator(t *testing.T) { @@ -58,25 +60,36 @@ func TestTokenFromEnv(t *testing.T) { } } -// TestTokenFilePathFromEnvPreservesFilenameWhitespace pins the pointer as a +// 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. -func TestTokenFilePathFromEnvPreservesFilenameWhitespace(t *testing.T) { +// +// 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 := TokenFilePathFromEnv(); got != configured { - t.Fatalf("TokenFilePathFromEnv() = %q, want the configured pathname %q", got, 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 := TokenFilePathFromEnv(); got != "" { - t.Fatalf("TokenFilePathFromEnv() = %q for a blank value, want unset", got) + 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 diff --git a/internal/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go index cbb4ba6fd..7f4dd7d08 100644 --- a/internal/mcp/daemon_token_test.go +++ b/internal/mcp/daemon_token_test.go @@ -116,3 +116,41 @@ func TestServeMCPExcludesDaemonTokenFromResources(t *testing.T) { 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) + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 26398c5a9..8cbfd0f11 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" @@ -149,14 +150,25 @@ func (server toolServer) readResource(rawParams json.RawMessage) ([]ResourceCont // outside the granted roots. return nil, jsonRPCResourceNotFound, err } - if server.credentialGuard.ReadExclusions().PathExcluded(absolute) { + // 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().FileExcluded(absolute, 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) } @@ -164,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/sandbox/engine.go b/internal/sandbox/engine.go index f2b4d781c..7d299572e 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -159,14 +159,16 @@ func (engine *Engine) ReadExclusions() *ReadExclusions { if len(protected) == 0 { return nil } - return &ReadExclusions{workspaceRoot: engine.workspaceRoot, protectedRoots: protected} - } - return &ReadExclusions{ - workspaceRoot: engine.workspaceRoot, - denyRoots: resolvePolicyPaths(policy.DenyRead), - allowRoots: resolvePolicyPaths(policy.AllowRead), - protectedRoots: protectedCredentialPaths(), - } + exclusions := newReadExclusions(engine.workspaceRoot, nil, nil, protected) + 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 diff --git a/internal/sandbox/filesystem_other.go b/internal/sandbox/filesystem_other.go index fbd39b790..85758395c 100644 --- a/internal/sandbox/filesystem_other.go +++ b/internal/sandbox/filesystem_other.go @@ -2,10 +2,15 @@ package sandbox -func pathsShareFilesystem(_, _ string) bool { - return false +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, bool) { - return 0, false +func pathHardLinkCount(_ string) (uint64, error) { + return 0, errors.ErrUnsupported } diff --git a/internal/sandbox/filesystem_unix.go b/internal/sandbox/filesystem_unix.go index 0f41b81f7..decfcc41e 100644 --- a/internal/sandbox/filesystem_unix.go +++ b/internal/sandbox/filesystem_unix.go @@ -2,30 +2,44 @@ package sandbox -import "golang.org/x/sys/unix" +import ( + "os" -func pathsShareFilesystem(left, right string) bool { + "golang.org/x/sys/unix" +) + +// pathsShareFilesystem reports whether two paths live on the same filesystem. +// The second result is false when either path cannot be inspected, because an +// uninspectable root is not evidence of separation: callers reason about where a +// hard link COULD be created, so an unknown answer must be treated as "could". +func pathsShareFilesystem(left, right string) (shared bool, known bool) { var leftStat, rightStat unix.Stat_t if err := unix.Stat(left, &leftStat); err != nil { - return false + return false, false } if err := unix.Stat(right, &rightStat); err != nil { - return false + return false, false } - return leftStat.Dev == rightStat.Dev + return leftStat.Dev == rightStat.Dev, true } // 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. -// The second result is false when the count cannot be determined. -func pathHardLinkCount(path string) (uint64, bool) { +// +// 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, false + return 0, &os.PathError{Op: "lstat", Path: path, Err: err} } if stat.Mode&unix.S_IFMT != unix.S_IFREG { - return 0, false + return 0, nil } - return uint64(stat.Nlink), true + return uint64(stat.Nlink), nil } diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 0c89f7416..e1555d10e 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -3,6 +3,7 @@ package sandbox import ( "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -239,6 +240,10 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques 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) @@ -303,6 +308,12 @@ 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. @@ -340,14 +351,27 @@ func protectedCredentialLinkableIntoLinuxShellRoot(profile PermissionProfile, pr if credential == "." || credential == "" { continue } - if count, ok := pathHardLinkCount(credential); ok && count > 1 { + // 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) || pathsShareFilesystem(root, credential) { + 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 } } diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index e9eb94852..e9959afa3 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -130,11 +130,51 @@ func protectedCredentialPathBlock(request Request, workspaceRoot string) *pathBl // 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 { - if len(protected) == 0 { + 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 +} + +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)), + } + 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 _, entry := range protected { - if pathUnderProtectedRoot(path, entry, workspaceRoot) { + for index, root := range cache.roots { + if pathUnderProtectedRootFolding(path, root, workspaceRoot, cache.folds[index]) { return true } } @@ -146,8 +186,18 @@ func protectedPathDenied(protected []string, workspaceRoot, path string) bool { // catches symbolic links, while SameFile catches hard links (and any other // platform-specific names for the same file). // - // This inode-level closure is specific to Zero's in-process tools, which see - // every requested path before opening it. The OS layer is pathname-based and + // 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 @@ -173,9 +223,37 @@ func protectedPathDenied(protected []string, workspaceRoot, path string) bool { 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 + } + 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, info os.FileInfo) bool { + if info == nil { + return false + } for _, entry := range protected { protectedInfo, err := os.Stat(entry) - if err == nil && os.SameFile(requestInfo, protectedInfo) { + if err == nil && os.SameFile(info, protectedInfo) { return true } } @@ -245,10 +323,12 @@ func caseVariant(name string) (string, bool) { return "", false } -// pathUnderProtectedRoot is pathUnderPolicyRoot for the automatic credential -// exclusions: identical anchoring and symlink normalization, plus the owning -// filesystem's case semantics. -func pathUnderProtectedRoot(requestedPath, root, workspaceRoot string) bool { +// 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 @@ -256,7 +336,7 @@ func pathUnderProtectedRoot(requestedPath, root, workspaceRoot string) bool { if pathWithinRootExact(root, normalized) { return true } - if !protectedPathFoldsCase(root) { + if !folds { return false } return pathWithinRootExact(strings.ToLower(root), strings.ToLower(normalized)) @@ -400,9 +480,20 @@ func readDeniedResolved(workspaceRoot string, denyRoots, allowRoots []string, pa // 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, - protectedRoots: protectedCredentialPaths(), + denyRoots: denyRoots, + allowRoots: allowRoots, + protectedRoots: protectedRoots, + protected: newProtectedPathCache(protectedRoots), } } @@ -417,6 +508,9 @@ type ReadExclusions struct { // 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 anything is excluded: a configured DenyRead root or an @@ -433,12 +527,43 @@ func (rx *ReadExclusions) PathExcluded(path string) bool { if !rx.Active() { return false } - if protectedPathDenied(rx.protectedRoots, rx.workspaceRoot, path) { + 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 { + if !rx.Active() { + return false + } + if rx.protectedDenied(path) { + return true + } + if protectedInfoDenied(rx.protectedRoots, 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 @@ -449,7 +574,7 @@ func (rx *ReadExclusions) DirExcluded(path string) bool { } // 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 protectedPathDenied(rx.protectedRoots, rx.workspaceRoot, path) { + if rx.protectedDenied(path) { return true } if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) { diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index f8468151b..6e2a7a9ea 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -443,8 +443,8 @@ func TestSandboxManagerRejectsLinuxTokenHardLinkAlias(t *testing.T) { t.Setenv(daemonRemoteTokenEnv, "") t.Setenv(daemonRemoteTokenFileEnv, token) - if _, ok := pathHardLinkCount(token); !ok { - t.Fatal("pathHardLinkCount could not inspect the token fixture") + 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), @@ -484,8 +484,8 @@ func TestSandboxManagerAllowsLinuxTokenOnSeparateFilesystem(t *testing.T) { t.Skipf("distinct tmpfs unavailable: %v", err) } t.Cleanup(func() { _ = os.RemoveAll(tokenDir) }) - if pathsShareFilesystem(workspace, tokenDir) { - t.Skip("/dev/shm is not a distinct filesystem on this host") + 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 { @@ -568,6 +568,89 @@ func TestSandboxManagerRejectsMacOSFileTokenRegardlessOfLinkLayout(t *testing.T) } } +// 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) + } +} + +// An uninspectable write root is not evidence that the token lives on a separate +// filesystem — it is a root a hard link might still be creatable in. The Linux +// planner must read the unknown answer as "could" and refuse. +func TestLinuxTokenPlannerFailsClosedOnUninspectableWriteRoot(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, "") + + missing := filepath.Join(workspace, "not-created-yet") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: missing}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + if credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentialPaths()); !linkable || credential != token { + t.Fatalf("linkable = %t credential = %q, want %q refused behind an uninspectable write root", linkable, credential, token) + } +} + // 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. diff --git a/internal/tools/daemon_token_matrix_test.go b/internal/tools/daemon_token_matrix_test.go index a0ea2c054..352371161 100644 --- a/internal/tools/daemon_token_matrix_test.go +++ b/internal/tools/daemon_token_matrix_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Gitlawb/zero/internal/sandbox" ) // The gate and the tool must resolve a path argument to the SAME bytes. @@ -174,6 +176,37 @@ func TestDaemonTokenProtectionMatrix(t *testing.T) { if err != nil || string(contents) != "bridge-secret\n" { t.Fatalf("token changed after a denied patch: contents=%q err=%v", contents, err) } + // WHICH layer refused is the point of the row. "The executor + // tripped over a path that does not exist" is not protection, + // so require the refusal to come from the sandbox. + if !strings.HasPrefix(result.Output, "Sandbox block") { + t.Fatalf("apply_patch was not refused by the sandbox gate: output=%q", result.Output) + } + headerPaths, err := sandbox.PatchHeaderPaths(patch) + if err != nil { + t.Fatalf("PatchHeaderPaths: %v", err) + } + switch { + case strings.TrimSpace(target) != target: + // A structured patch cannot name THIS token at all: the + // sandbox's header parser and the executor's both trim the + // header, so the patch describes "bridge-token" — a file + // that does not exist — and the token is unreachable rather + // than gate-denied. Pin that agreement explicitly, because + // if either parser stopped trimming, the gate would inspect + // a different name than the executor opens, which is the + // exact divergence this branch was opened for. + if len(headerPaths) != 1 || headerPaths[0] != filepath.ToSlash(strings.TrimSpace(target)) { + t.Fatalf("patch header paths = %q, want only the trimmed spelling the executor opens", headerPaths) + } + case !filepath.IsAbs(target): + // An in-workspace relative spelling reaches the credential + // gate, which is the layer that must refuse it. (An absolute + // spelling is refused one step earlier, as out-of-workspace.) + 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) + } + } }) }) } From df7d1df890a3b967ac48dab5041b9c4b0fc27783 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 23 Aug 2026 17:01:33 +0200 Subject: [PATCH 13/23] fix(sandbox): answer an uncreated write root from its parent filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestSandboxManagerAllowsLinuxTokenOnSeparateFilesystem failed on Linux CI (Smoke ubuntu-latest and Zero Review, both from this one test). The fail-closed change in 476d706c read every stat failure in pathsShareFilesystem as "cannot tell", and the permission profile carries write roots for every platform: /private/tmp, /private/var/tmp, and /var/folders are macOS spellings with no Linux counterpart. Each one became an unknown, and unknown refuses — so a token on a genuinely separate filesystem was reported linkable through a directory that does not exist on the host. A path that has not been created yet is not unknown: it lands on whichever filesystem its parent is on. pathFilesystemID walks to the nearest existing ancestor, so an uncreated root under the workspace still answers "same filesystem as the token" (and is still refused), while a platform-foreign root answers from / and is correctly separate. known stays false only when the whole ancestor chain is uninspectable, which is the case the fail-closed rule was actually for. The regression test is renamed to what it now pins — that an uncreated root is read from its parent — and gained the platform-foreign half, so the shape that broke CI fails here rather than three jobs later. Verified on real Linux (WSL, go1.26.6): the four Linux/Windows token planner tests pass, and internal/{sandbox,mcp,tools,daemon,daemon/remote} are green apart from TestSelectBackendChoosesPlatformAdapterWithFallback, which fails under WSL on the unmodified branch too because backend detection reports the wsl adapter there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgWC2FnDp5Jjdvc6cqEfEQ --- internal/sandbox/filesystem_unix.go | 43 ++++++++++++++--- .../sandbox/protected_credentials_test.go | 48 ++++++++++++++----- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/internal/sandbox/filesystem_unix.go b/internal/sandbox/filesystem_unix.go index decfcc41e..be08cc12f 100644 --- a/internal/sandbox/filesystem_unix.go +++ b/internal/sandbox/filesystem_unix.go @@ -4,23 +4,52 @@ package sandbox import ( "os" + "path/filepath" "golang.org/x/sys/unix" ) // pathsShareFilesystem reports whether two paths live on the same filesystem. -// The second result is false when either path cannot be inspected, because an -// uninspectable root is not evidence of separation: callers reason about where a -// hard link COULD be created, so an unknown answer must be treated as "could". +// +// 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) { - var leftStat, rightStat unix.Stat_t - if err := unix.Stat(left, &leftStat); err != nil { + leftDevice, leftKnown := pathFilesystemID(left) + if !leftKnown { return false, false } - if err := unix.Stat(right, &rightStat); err != nil { + rightDevice, rightKnown := pathFilesystemID(right) + if !rightKnown { return false, false } - return leftStat.Dev == rightStat.Dev, true + 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 diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index 6e2a7a9ea..dd38ec5a9 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -614,10 +614,13 @@ func TestSandboxManagerRejectsWindowsFileBackedTokenShell(t *testing.T) { } } -// An uninspectable write root is not evidence that the token lives on a separate -// filesystem — it is a root a hard link might still be creatable in. The Linux -// planner must read the unknown answer as "could" and refuse. -func TestLinuxTokenPlannerFailsClosedOnUninspectableWriteRoot(t *testing.T) { +// 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") } @@ -637,17 +640,36 @@ func TestLinuxTokenPlannerFailsClosedOnUninspectableWriteRoot(t *testing.T) { 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") - profile := PermissionProfile{ - FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - ReadRoots: []string{string(filepath.Separator)}, - WriteRoots: []WritableRoot{{Root: missing}}, - }, - Network: NetworkPolicy{Mode: NetworkDeny}, + 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 credential, linkable := protectedCredentialLinkableIntoLinuxShellRoot(profile, protectedCredentialPaths()); !linkable || credential != token { - t.Fatalf("linkable = %t credential = %q, want %q refused behind an uninspectable write root", linkable, credential, token) + if _, known := pathsShareFilesystem("/private/tmp", token); !known { + t.Fatal(`pathsShareFilesystem("/private/tmp", token) reported unknown, want the answer resolved from an existing ancestor`) } } From 6e716f0446c183fd79127766403f4255c79bacd8 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 23 Aug 2026 20:17:17 +0200 Subject: [PATCH 14/23] fix(sandbox): mandate credential protection independent of the sandbox engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the latest review round. The overriding theme across all of them: Registry.RunWithOptions only asks the sandbox engine about the protected daemon token when options.Sandbox is non-nil, so every layer that depended on the engine running at all — not on what it decided — silently lost coverage the moment a caller reached a tool through the plain registry API instead. That is a real, not hypothetical, gap: read_file, confirmed by a direct reproduction before this fix, served the bridge token's exact bytes through registry.Run() with no engine supplied. ## Mandatory, engine-independent protection for every path-naming tool (P1) New internal/tools/protected_credentials.go adds two engine-independent primitives built on sandbox.ProtectedCredentialExclusions (the same exported function list_directory's engine-less fallback already used): - protectedReadOpen opens a file and checks it against the protected set from the SAME handle any content is subsequently read through. Checking a resolved pathname and then opening it separately — what every direct file tool did before this, and what Engine.Evaluate's pre-tool-dispatch check still does even WITH an engine present — leaves a window where a concurrent writer can repoint a symlink between the two. Binding to the handle removes it: every open independently re-verifies identity from its own freshly-obtained os.FileInfo, so there is no separate "check" step to race. This is the same pattern internal/mcp/resources.go's readResource already established for MCP resource reads. - protectedMutationDenied is the pathname+inode check for mutations that cannot be handle-bound without a larger rewrite: write_file/edit_file currently perform a single os.WriteFile call each, and apply_patch's unified-diff path shells out to `git apply`, an external process this package cannot bind a Go handle to. This closes the P1 engine-less gap for mutations completely and keeps them at least at the SAME protection level the engine-present path already had — not a regression — but does not fully close the P2 TOCTOU window for writes the way it does for reads. Flagged as a known, deliberate scope boundary rather than silently partial. Wired into read_file (all three of its internal os.Open call sites), read_minified_file (previously had NO protection at all — a direct os.ReadFile with nothing upstream ever checking the path), write_file, edit_file (both its read and its write), and apply_patch (both the unified and structured patch paths — resolveStructuredPatchTarget is the single funnel every add/delete/update/move target resolves through, so one check covers all of them). grep and glob now fall back to sandboxReadExcluderWithin instead of a bare no-op excluder when no engine is supplied, matching the fallback list_directory already had. Verified against a matrix (TestEngineLessRegistryMatrix) driving every one of these tools through Registry.Run — no sandbox engine — with a protected token selected, plus a "no token configured" row proving the guard is not a permanent deny. TestProtectedReadOpenClosesTheCheckToUseWindow is the deterministic swap-race regression for reads (P2): an ordinary file served successfully once is re-verified, not cached, on a second read through the same path, so a file that becomes the protected token between two calls is caught exactly as if it always had been. ## Nil ReadExclusions defensive hardening (P1, resources.go) The reported panic did not reproduce: Active() already checks rx != nil before touching any field, so PathExcluded/FileExcluded are nil-receiver-safe today, and TestServeMCPResourcesWorkWithoutADaemonToken (added here, wrapped in a recover()) passes against the pre-existing code on this branch. Engine.ReadExclusions() now returns a real, inactive matcher for every non-nil engine regardless — never nil except for a literally nil engine — removing the landmine outright rather than leaving every future method on ReadExclusions responsible for staying nil-safe by convention. ## Stale resolved-marker override on client entry points (P2) remotetoken.SourceFromEnv binds ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED to whatever ZERO_DAEMON_REMOTE_TOKEN_FILE is currently set to, without proving the marker is actually that value's resolved identity — it is a daemon-worker handoff value written by CanonicalizeTokenFileEnv at serve-remote startup, not an independently trustworthy selector. `zero daemon link` and dialForCLI (remote run/attach) never call CanonicalizeTokenFileEnv; both called TokenFromEnv directly and could therefore authenticate against a resolved marker left over from an unrelated prior daemon in the same shell after the operator repointed EnvTokenFile at a different file. New TokenFromFreshEnv ignores the marker entirely via remotetoken.ResolveSource (always resolves fresh), and both client call sites now use it. Inline-token precedence and serve-remote's own worker-pinning path (CanonicalizeTokenFileEnv then TokenFromEnv) are unchanged. TestTokenFromEnvTrustsAStaleInheritedResolvedMarker pins the vulnerable baseline; TestTokenFromFreshEnvIgnoresAStaleResolvedMarker pins the fix. ## Explicitly not done in this pass - Full handle-bound protection for write_file/edit_file's WRITE (not read) path and for apply_patch's git-apply execution. Both still get the pathname+inode check, closing the P1 engine-less gap, but the P2 TOCTOU window narrows rather than fully closes for mutations — see the comments in protected_credentials.go for exactly why each is scoped this way. - The broader architectural request (one authoritative token-source implementation end to end, object identity threaded through every mutation path, a full lifecycle test matrix covering restart/rotation/hard-link/ concurrent-replacement across every consumer). This pass closes the four concrete findings and the P1 disclosure gap they share a root cause with; it does not attempt the larger redesign the review's overall guidance describes. Validation: go build ./..., go vet ./... (also on native Linux via WSL, go1.26.6), gofmt clean, deadcode unchanged from baseline, go test ./internal/{tools,sandbox,mcp,daemon,daemon/remote}/... green on both Windows and Linux (one unrelated WSL-environment-specific failure in internal/sandbox, confirmed identical on the unmodified branch). The internal/cli provider-config failures are pre-existing on this branch, confirmed byte-for-byte identical against a clean stash of this same branch, and unrelated to this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgWC2FnDp5Jjdvc6cqEfEQ --- internal/cli/daemon.go | 8 +- internal/daemon/remote/auth.go | 38 ++++ internal/daemon/remote/auth_test.go | 77 +++++++ internal/mcp/daemon_token_test.go | 59 +++++ internal/sandbox/engine.go | 20 +- internal/tools/apply_patch.go | 19 +- internal/tools/edit_file.go | 14 +- internal/tools/glob.go | 11 +- internal/tools/grep.go | 15 +- internal/tools/protected_credentials.go | 83 +++++++ internal/tools/protected_credentials_test.go | 215 +++++++++++++++++++ internal/tools/read_file.go | 22 +- internal/tools/read_minified_file.go | 16 +- internal/tools/structured_patch.go | 9 + internal/tools/write_file.go | 6 + 15 files changed, 575 insertions(+), 37 deletions(-) create mode 100644 internal/tools/protected_credentials.go create mode 100644 internal/tools/protected_credentials_test.go diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index a9bd94417..38f7eb0a1 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -640,7 +640,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, @@ -683,7 +685,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/daemon/remote/auth.go b/internal/daemon/remote/auth.go index 2a8208f26..093c4a4f9 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -89,6 +89,44 @@ 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 diff --git a/internal/daemon/remote/auth_test.go b/internal/daemon/remote/auth_test.go index 1410805e1..668a3d38f 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -60,6 +60,83 @@ 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 " — diff --git a/internal/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go index 7f4dd7d08..82d31cd4c 100644 --- a/internal/mcp/daemon_token_test.go +++ b/internal/mcp/daemon_token_test.go @@ -154,3 +154,62 @@ func TestResourcesReadRefusesHardLinkedToken(t *testing.T) { t.Fatalf("resources/read error disclosed token bytes: %q", read.Error.Message) } } + +// 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/sandbox/engine.go b/internal/sandbox/engine.go index 7d299572e..a08533427 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -141,8 +141,18 @@ 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 the USER configured, so it must not // filter search results by DenyRead either (Evaluate likewise allows every @@ -155,11 +165,7 @@ func (engine *Engine) ReadExclusions() *ReadExclusions { } policy := engine.effectivePolicy(engine.policy) if policy.Mode == ModeDisabled { - protected := protectedCredentialPaths() - if len(protected) == 0 { - return nil - } - exclusions := newReadExclusions(engine.workspaceRoot, nil, nil, protected) + exclusions := newReadExclusions(engine.workspaceRoot, nil, nil, protectedCredentialPaths()) return &exclusions } exclusions := newReadExclusions( diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index a35281c2c..9dbdd4edf 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -274,7 +274,16 @@ func validatePatchPaths(root string, patchPaths []string) error { if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") { return fmt.Errorf("patch path %q must stay inside the workspace", path) } - if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil { + absolute, _, err := resolveWorkspaceTargetPath(root, path) + if err != nil { + return err + } + // Engine-independent: the git-apply flow below shells out to an external + // process this package cannot bind a handle to, so unlike write_file/ + // edit_file this is pathname-level protection only — see + // internal/tools/protected_credentials.go. It is also the ONLY protection + // this tool has when called through the plain registry API. + if err := protectedMutationDenied(absolute, root); err != nil { return err } } @@ -289,6 +298,14 @@ func recheckPatchWriteTargets(root string, patchPaths []string) error { if err := recheckWorkspaceWriteTarget(root, path); err != nil { return err } + // Re-checked immediately before git apply runs, narrowing (not closing — + // git apply is an external process) the window between the initial + // validatePatchPaths check and the actual write. + if absolute, _, err := resolveWorkspaceTargetPath(root, path); err == nil { + if err := protectedMutationDenied(absolute, root); err != nil { + return err + } + } } return nil } diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index a5b0ac308..18ab39aee 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "strings" ) @@ -64,7 +65,15 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any if err != nil { return errorResult("Error reading " + requestedPath + ": " + err.Error()) } - contentBytes, err := os.ReadFile(absolutePath) + // protectedReadOpen binds the daemon-token check to the handle the content is + // actually read from — see internal/tools/protected_credentials.go. Also the + // ONLY protection this read has when called through the plain registry API. + readFile, _, err := protectedReadOpen(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()) } @@ -154,6 +163,9 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } + if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { + return errorResult("Error writing " + relativePath + ": " + err.Error()) + } if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } 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..48e5d3a0d 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 diff --git a/internal/tools/protected_credentials.go b/internal/tools/protected_credentials.go new file mode 100644 index 000000000..3e33f533f --- /dev/null +++ b/internal/tools/protected_credentials.go @@ -0,0 +1,83 @@ +package tools + +import ( + "fmt" + "os" + + "github.com/Gitlawb/zero/internal/sandbox" +) + +// This file is the mandatory, engine-INDEPENDENT half of the daemon-token +// boundary for tools that name a single file directly, rather than walking a +// tree (grep/glob/list_directory use sandboxReadExcluderWithin instead). +// +// Two problems motivate it, not one: +// +// 1. Registry.RunWithOptions only asks the sandbox engine about a protected +// path when options.Sandbox is non-nil. A caller of the plain registry API +// — Registry.Run, or RunWithOptions with no engine — bypasses that check +// entirely, so read_file/read_minified_file/write_file/edit_file/apply_patch +// had no protection at all on that path. sandbox.ProtectedCredentialExclusions +// is engine-independent (it reads the protected set from this process's own +// environment, not from a policy), so it works whether or not an engine was +// supplied. +// 2. Even WITH an engine, the sandbox's check runs once, before the tool's own +// Run/RunWithOptions is called, against a resolved pathname — and the tool +// then opens that path completely independently. A concurrent writer with +// workspace access can replace an ordinary file with a symlink to the token +// between those two steps. protectedReadOpen closes that window for reads by +// deciding from the SAME handle the content is read through, exactly like +// internal/mcp/resources.go's readResource already does. +// +// Mutation tools (write_file, edit_file) get the pathname-level check only: +// protectedMutationDenied. A fully handle-bound write — open without truncating, +// check identity, THEN truncate and write through that handle — is possible and +// would close the same window for mutations; it is not done here because it +// would mean restructuring how those tools perform their write (currently a +// single os.WriteFile call each) without disturbing their existing staleness/ +// conflict-detection logic. The pathname check still closes the P1 gap (no +// protection at all without an engine) and keeps mutations at the SAME +// protection level the engine-present path already had — no regression, and +// still narrower coverage than reads. Left as a known gap; see the PR for +// which finding this corresponds to. + +// protectedReadOpen opens path for reading and verifies — from the SAME handle +// any content is read through — that it does not target the automatic daemon +// bridge-token exclusion. Checking a resolved pathname and then opening it +// separately would leave a window where a concurrent writer repoints a symlink +// between the two; deciding from the just-opened handle's own os.FileInfo +// removes that window, because the identity comparison and every subsequent +// read describe the identical object. +func protectedReadOpen(path, workspaceRoot string) (*os.File, os.FileInfo, error) { + file, err := os.Open(path) + 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.FileExcluded(path, info) { + file.Close() + return nil, nil, protectedCredentialErr(path, "readable") + } + return file, info, nil +} + +// protectedMutationDenied reports whether path names the automatic daemon +// bridge-token exclusion, for a mutation tool to check immediately before it +// writes. Engine-independent — see the file doc for what this does and does +// not close. +func protectedMutationDenied(path, workspaceRoot string) error { + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + if exclusions.PathExcluded(path) { + return protectedCredentialErr(path, "writable") + } + return 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..4404a863b --- /dev/null +++ b/internal/tools/protected_credentials_test.go @@ -0,0 +1,215 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// 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) + } +} diff --git a/internal/tools/read_file.go b/internal/tools/read_file.go index b6ef1d9da..6afa7b318 100644 --- a/internal/tools/read_file.go +++ b/internal/tools/read_file.go @@ -126,7 +126,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()) } @@ -136,7 +136,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{ @@ -156,7 +156,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 { @@ -192,7 +192,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)) } @@ -244,7 +244,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st budgetedOutput.WriteString("\n") } budgetedOutput.WriteString("\n") - if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil { + if err := appendReadFileRange(budgetedOutput, absolutePath, workspaceRoot, startLine, selectedLines, width); err != nil { return errorResult("Error reading file " + relativePath + ": " + err.Error()) } if truncated { @@ -284,8 +284,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 } @@ -316,11 +316,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 } @@ -357,8 +357,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, width int) error { - file, err := os.Open(path) +func appendReadFileRange(output *outputBudgetBuilder, path string, workspaceRoot string, startLine int, selectedLines int, width 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 a443acf84..8ac53439e 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/structured_patch.go b/internal/tools/structured_patch.go index c4ca17359..e472c679a 100644 --- a/internal/tools/structured_patch.go +++ b/internal/tools/structured_patch.go @@ -360,6 +360,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 } diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index 558530211..298a787f7 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -107,6 +107,12 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } + // Engine-independent: this is the ONLY protection write_file has when called + // through the plain registry API (Registry.Run, or RunWithOptions with no + // sandbox engine) — see internal/tools/protected_credentials.go. + if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { + return errorResult("Error writing file " + relativePath + ": " + err.Error()) + } if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } From 61500700fd520ef910355b1bc09a4de04f04f237 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 24 Aug 2026 20:27:20 +0200 Subject: [PATCH 15/23] fix(sandbox): close protected token file races --- internal/sandbox/engine.go | 5 +- internal/sandbox/linux_helper_test.go | 2 +- internal/sandbox/manager_darwin_test.go | 1 + internal/sandbox/runner_test.go | 3 +- internal/tools/apply_patch.go | 223 +++++++++++++++++-- internal/tools/daemon_token_matrix_test.go | 49 ++-- internal/tools/edit_file.go | 39 ++-- internal/tools/protected_credentials.go | 134 +++++++---- internal/tools/protected_credentials_test.go | 141 ++++++++++++ internal/tools/structured_patch.go | 70 ++---- internal/tools/workspace.go | 82 +++++-- internal/tools/write_file.go | 80 +++---- 12 files changed, 609 insertions(+), 220 deletions(-) diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index a08533427..1ce8e8cfc 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -342,8 +342,9 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision { scope := engine.scopeFor(request.WorkspaceRoot) risk := classifyWithScope(request, scope) // Patch targets must be established before every policy short-circuit, - // including ModeDisabled: the automatic daemon-token boundary still applies - // to in-process tools when user sandboxing is off. + // 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) } diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index f8dc6e390..0d30a72a8 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -366,7 +366,7 @@ func TestLinuxBwrapMandatoryPathRotationToUnprotectedSymlinkFailsClosed(t *testi t.Fatal(err) } if err := os.Symlink(outside, mandatory); err != nil { - t.Fatal(err) + t.Skipf("symlink unavailable: %v", err) } plan, err := buildLinuxBwrapFilesystemPlan(profile) diff --git a/internal/sandbox/manager_darwin_test.go b/internal/sandbox/manager_darwin_test.go index 7c809ddfb..a4a45e3e5 100644 --- a/internal/sandbox/manager_darwin_test.go +++ b/internal/sandbox/manager_darwin_test.go @@ -24,6 +24,7 @@ func TestSandboxManagerRejectsMacOSTokenHardLinkableIntoWritableWorkspace(t *tes t.Setenv(daemonRemoteTokenEnv, "") t.Setenv(daemonRemoteTokenFileEnv, token) + t.Setenv(daemonRemoteTokenFileResolvedEnv, "") policy := DefaultPolicy() backend := Backend{ Name: BackendMacOSSeatbelt, diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index 30d4af13c..0b971d5b6 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -503,7 +503,8 @@ func TestSeatbeltProfileProtectsMetadataAndDenyOrdering(t *testing.T) { // 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+`"))`) { + 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*") diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index 9dbdd4edf..f44175a36 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -2,7 +2,11 @@ package tools import ( "context" + "crypto/rand" + "encoding/hex" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -104,15 +108,8 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a } } - command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath) - command.Dir = applyRoot - output, err := command.CombinedOutput() - if err != nil { - message := strings.TrimSpace(string(output)) - if message == "" { - message = err.Error() - } - return errorResult("Error applying patch: " + message) + if err := applyUnifiedPatchStaged(ctx, applyRoot, patchPath, patchPaths); err != nil { + return errorResult("Error applying patch: " + err.Error()) } summary := "Patch applied successfully." @@ -131,25 +128,207 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a // input determines the whole output, so a follow-up edit needs no wasted read. // Partial observations remain conservative and are cleared by Record. for _, changed := range result.ChangedFiles { - if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil { - content, readErr := os.ReadFile(absolute) - if readErr != nil { - options.FileTracker.Forget(absolute) - continue - } - info, _ := os.Stat(absolute) - wasWhole := wholeBefore[absolute] || fullySupplied[absolute] - options.FileTracker.Record(absolute, content, info) - if wasWhole { - lines := lineCount(string(content)) - options.FileTracker.RecordSeenRange(absolute, 1, lines, lines) - } + target, rerr := resolveScopedReadTarget(tool.workspaceRoot, tool.scope, changed) + if rerr != nil { + continue + } + root, openErr := os.OpenRoot(target.root) + if openErr != nil { + options.FileTracker.Forget(target.absolute) + continue + } + file, info, readErr := protectedRootRead(root, target.relative, target.absolute, tool.workspaceRoot) + _ = root.Close() + if readErr != nil { + options.FileTracker.Forget(target.absolute) + continue + } + content, contentErr := io.ReadAll(file) + closeErr := file.Close() + if contentErr != nil || closeErr != nil { + options.FileTracker.Forget(target.absolute) + continue + } + wasWhole := wholeBefore[target.absolute] || fullySupplied[target.absolute] + options.FileTracker.Record(target.absolute, content, info) + if wasWhole { + lines := lineCount(string(content)) + options.FileTracker.RecordSeenRange(target.absolute, 1, lines, lines) } } recordCreatedPatchTargets(options.FileTracker, createdTargets) return result } +type stagedPatchFile struct { + exists bool + mode os.FileMode + content []byte + link string +} + +func applyUnifiedPatchStaged(ctx context.Context, rootPath, patchPath string, patchPaths []string) error { + root, err := os.OpenRoot(rootPath) + if err != nil { + return err + } + defer root.Close() + stageDir, err := os.MkdirTemp("", "zero-patch-stage-*") + if err != nil { + return err + } + defer os.RemoveAll(stageDir) + stage, err := os.OpenRoot(stageDir) + if err != nil { + return err + } + defer stage.Close() + + targets := make([]structuredPatchTarget, 0, len(patchPaths)) + before := make(map[string]stagedPatchFile, len(patchPaths)) + seen := make(map[string]bool, len(patchPaths)) + for _, path := range patchPaths { + if path == "" || path == "/dev/null" || seen[path] { + continue + } + seen[path] = true + target, err := resolveStructuredPatchTarget(rootPath, path) + if err != nil { + return err + } + snapshot, err := captureRootedPatchFile(root, target, rootPath) + if err != nil { + return err + } + if snapshot.exists { + if err := materializeStagedPatchFile(stage, target.relative, snapshot, true); err != nil { + return err + } + } + targets = append(targets, target) + before[target.relative] = snapshot + } + + command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath) + command.Dir = stageDir + output, err := command.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return errors.New(message) + } + + after := make(map[string]stagedPatchFile, len(targets)) + for _, target := range targets { + snapshot, err := captureRootedPatchFile(stage, target, stageDir) + if err != nil { + return err + } + after[target.relative] = snapshot + } + // Publish complete outputs first, then removals. A rename/copy destination + // that raced into existence fails before its source is removed. + for _, target := range targets { + snapshot := after[target.relative] + if !snapshot.exists { + continue + } + if err := recheckWorkspaceWriteTarget(rootPath, target.relative); err != nil { + return err + } + if err := protectedMutationDenied(target.absolute, rootPath); err != nil { + return err + } + if err := materializeStagedPatchFile(root, target.relative, snapshot, !before[target.relative].exists); err != nil { + return err + } + } + for _, target := range targets { + if !before[target.relative].exists || after[target.relative].exists { + continue + } + if err := recheckWorkspaceWriteTarget(rootPath, target.relative); err != nil { + return err + } + if err := protectedMutationDenied(target.absolute, rootPath); err != nil { + return err + } + if err := root.Remove(target.relative); err != nil { + return err + } + } + return nil +} + +func captureRootedPatchFile(root *os.Root, target structuredPatchTarget, workspaceRoot string) (stagedPatchFile, error) { + info, err := root.Lstat(target.relative) + if os.IsNotExist(err) { + return stagedPatchFile{}, nil + } + if err != nil { + return stagedPatchFile{}, err + } + if info.Mode()&os.ModeSymlink != 0 { + link, err := root.Readlink(target.relative) + if err != nil { + return stagedPatchFile{}, err + } + return stagedPatchFile{exists: true, mode: info.Mode(), link: link}, nil + } + if !info.Mode().IsRegular() { + return stagedPatchFile{}, fmt.Errorf("patch target %q is not a regular file or symlink", target.relative) + } + file, openedInfo, err := protectedRootRead(root, target.relative, target.absolute, workspaceRoot) + if err != nil { + return stagedPatchFile{}, err + } + content, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + return stagedPatchFile{}, errors.Join(readErr, closeErr) + } + return stagedPatchFile{exists: true, mode: openedInfo.Mode(), content: content}, nil +} + +func materializeStagedPatchFile(root *os.Root, relative string, file stagedPatchFile, createOnly bool) error { + if file.mode&os.ModeSymlink != 0 { + return publishRootedSymlink(root, relative, file.link, createOnly) + } + _, err := writeRootedFile(root, relative, file.content, file.mode, createOnly) + return err +} + +func publishRootedSymlink(root *os.Root, relative, target string, createOnly bool) error { + parent := filepath.Dir(relative) + if err := root.MkdirAll(parent, 0o755); err != nil { + return err + } + if createOnly { + return root.Symlink(target, relative) + } + for range 10 { + noise := make([]byte, 8) + if _, err := rand.Read(noise); err != nil { + return err + } + temp := filepath.Join(parent, ".zero-link."+hex.EncodeToString(noise)) + if err := root.Symlink(target, temp); err != nil { + if errors.Is(err, os.ErrExist) { + continue + } + return err + } + if err := root.Rename(temp, relative); err != nil { + _ = root.Remove(temp) + return err + } + return nil + } + return errors.New("could not create a temporary symlink with an unused name") +} + func missingPatchTargets(root string, patchPaths []string) []string { seen := map[string]bool{} var missing []string diff --git a/internal/tools/daemon_token_matrix_test.go b/internal/tools/daemon_token_matrix_test.go index 352371161..7acb9a19b 100644 --- a/internal/tools/daemon_token_matrix_test.go +++ b/internal/tools/daemon_token_matrix_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strconv" "strings" "testing" @@ -114,10 +115,13 @@ func TestDaemonTokenProtectionMatrix(t *testing.T) { 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}) + 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" { @@ -165,10 +169,13 @@ func TestDaemonTokenProtectionMatrix(t *testing.T) { registry := NewRegistry() registry.Register(NewScopedApplyPatchTool(ws, nil)) target := spelling.arg(ws, token) - patch := "*** Begin Patch\n*** Update File: " + target + - "\n@@\n-bridge-secret\n+attacker\n*** End Patch\n" + 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}) + 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) } @@ -176,36 +183,20 @@ func TestDaemonTokenProtectionMatrix(t *testing.T) { if err != nil || string(contents) != "bridge-secret\n" { t.Fatalf("token changed after a denied patch: contents=%q err=%v", contents, err) } - // WHICH layer refused is the point of the row. "The executor - // tripped over a path that does not exist" is not protection, - // so require the refusal to come from the sandbox. - if !strings.HasPrefix(result.Output, "Sandbox block") { - t.Fatalf("apply_patch was not refused by the sandbox gate: output=%q", result.Output) - } headerPaths, err := sandbox.PatchHeaderPaths(patch) if err != nil { t.Fatalf("PatchHeaderPaths: %v", err) } - switch { - case strings.TrimSpace(target) != target: - // A structured patch cannot name THIS token at all: the - // sandbox's header parser and the executor's both trim the - // header, so the patch describes "bridge-token" — a file - // that does not exist — and the token is unreachable rather - // than gate-denied. Pin that agreement explicitly, because - // if either parser stopped trimming, the gate would inspect - // a different name than the executor opens, which is the - // exact divergence this branch was opened for. - if len(headerPaths) != 1 || headerPaths[0] != filepath.ToSlash(strings.TrimSpace(target)) { - t.Fatalf("patch header paths = %q, want only the trimmed spelling the executor opens", headerPaths) - } - case !filepath.IsAbs(target): - // An in-workspace relative spelling reaches the credential - // gate, which is the layer that must refuse it. (An absolute - // spelling is refused one step earlier, as out-of-workspace.) - 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) + if len(headerPaths) != 2 || headerPaths[0] != filepath.ToSlash(target) || + headerPaths[1] != filepath.ToSlash(target) { + t.Fatalf("patch header paths = %q, want exact target spelling twice", headerPaths) + } + if filepath.IsAbs(target) { + if !strings.Contains(result.Output, "must stay inside the workspace") { + t.Fatalf("absolute apply_patch refusal did not come from workspace containment: output=%q", result.Output) } + } else 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) } }) }) diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index 18ab39aee..21b6edf27 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -61,14 +61,19 @@ 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()) } - // protectedReadOpen binds the daemon-token check to the handle the content is - // actually read from — see internal/tools/protected_credentials.go. Also the - // ONLY protection this read has when called through the plain registry API. - readFile, _, err := protectedReadOpen(absolutePath, tool.workspaceRoot) + 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()) } @@ -160,23 +165,25 @@ 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 { - return errorResult("Error writing " + relativePath + ": " + err.Error()) - } 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 { + // Publish a complete replacement through the same root. A raced swap to the + // token is replaced as a directory entry, never opened for truncation. + if _, err := writeRootedFile(root, target.relative, []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) + // Pathname-based formatters and diagnostics reopen the published name. Skip + // them while a protected token is selected, or they would reintroduce the + // same swap window the rooted atomic write closes. + credentialsActive := protectedCredentialsActive(tool.workspaceRoot) + if !credentialsActive { + updated = maybeFormatWrittenFile(ctx, absolutePath, updated) + } // 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) options.FileTracker.Record(absolutePath, []byte(updated), newInfo) if updated == modelKnownContent { if previouslySeenWhole { @@ -193,7 +200,9 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any suffix = "s" } summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix) - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) + if !credentialsActive { + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) + } result := okResult(summary) result.ChangedFiles = []string{relativePath} // Card-only preview (Display.Preview): the model's Output stays the one-line diff --git a/internal/tools/protected_credentials.go b/internal/tools/protected_credentials.go index 3e33f533f..45087c7b7 100644 --- a/internal/tools/protected_credentials.go +++ b/internal/tools/protected_credentials.go @@ -3,56 +3,48 @@ package tools import ( "fmt" "os" + "path/filepath" + "github.com/Gitlawb/zero/internal/pathjail" "github.com/Gitlawb/zero/internal/sandbox" ) -// This file is the mandatory, engine-INDEPENDENT half of the daemon-token -// boundary for tools that name a single file directly, rather than walking a -// tree (grep/glob/list_directory use sandboxReadExcluderWithin instead). +// 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. // -// Two problems motivate it, not one: -// -// 1. Registry.RunWithOptions only asks the sandbox engine about a protected -// path when options.Sandbox is non-nil. A caller of the plain registry API -// — Registry.Run, or RunWithOptions with no engine — bypasses that check -// entirely, so read_file/read_minified_file/write_file/edit_file/apply_patch -// had no protection at all on that path. sandbox.ProtectedCredentialExclusions -// is engine-independent (it reads the protected set from this process's own -// environment, not from a policy), so it works whether or not an engine was -// supplied. -// 2. Even WITH an engine, the sandbox's check runs once, before the tool's own -// Run/RunWithOptions is called, against a resolved pathname — and the tool -// then opens that path completely independently. A concurrent writer with -// workspace access can replace an ordinary file with a symlink to the token -// between those two steps. protectedReadOpen closes that window for reads by -// deciding from the SAME handle the content is read through, exactly like -// internal/mcp/resources.go's readResource already does. -// -// Mutation tools (write_file, edit_file) get the pathname-level check only: -// protectedMutationDenied. A fully handle-bound write — open without truncating, -// check identity, THEN truncate and write through that handle — is possible and -// would close the same window for mutations; it is not done here because it -// would mean restructuring how those tools perform their write (currently a -// single os.WriteFile call each) without disturbing their existing staleness/ -// conflict-detection logic. The pathname check still closes the P1 gap (no -// protection at all without an engine) and keeps mutations at the SAME -// protection level the engine-present path already had — no regression, and -// still narrower coverage than reads. Left as a known gap; see the PR for -// which finding this corresponds to. +// 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 build a complete temporary file under the same root and atomically +// publish it by rename (or an exclusive no-replace operation for creates). +// Consequently neither path resolution nor a later truncating open can be +// redirected to the token between authorization and use. -// protectedReadOpen opens path for reading and verifies — from the SAME handle -// any content is read through — that it does not target the automatic daemon -// bridge-token exclusion. Checking a resolved pathname and then opening it -// separately would leave a window where a concurrent writer repoints a symlink -// between the two; deciding from the just-opened handle's own os.FileInfo -// removes that window, because the identity comparison and every subsequent -// read describe the identical object. +// 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) { - file, err := os.Open(path) + 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() @@ -66,10 +58,29 @@ func protectedReadOpen(path, workspaceRoot string) (*os.File, os.FileInfo, error return file, info, nil } -// protectedMutationDenied reports whether path names the automatic daemon -// bridge-token exclusion, for a mutation tool to check immediately before it -// writes. Engine-independent — see the file doc for what this does and does -// not close. +// 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.FileExcluded(absolute, info) { + file.Close() + return nil, nil, protectedCredentialErr(absolute, "readable") + } + return file, info, nil +} + +// protectedMutationDenied is the early, lexical refusal. The actual mutation +// must still use writeRootedFile so a raced hard-link or symlink replacement is +// replaced atomically rather than opened and modified. func protectedMutationDenied(path, workspaceRoot string) error { exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) if exclusions.PathExcluded(path) { @@ -78,6 +89,41 @@ func protectedMutationDenied(path, workspaceRoot string) error { return nil } +func protectedCredentialsActive(workspaceRoot string) bool { + exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) + return exclusions.Active() +} + +func writeRootedFile(root *os.Root, relative 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 + } + temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-write", filepath.Ext(relative)+".tmp") + if err != nil { + return false, err + } + defer func() { _ = removeStructuredPatchTemp(root, tempName) }() + if _, err := temp.Write(content); err != nil { + _ = temp.Close() + return false, err + } + if err := temp.Chmod(mode.Perm()); err != nil { + _ = temp.Close() + return false, err + } + if err := temp.Close(); err != nil { + return false, err + } + if createOnly { + return publishStructuredPatchNoReplace(root, tempName, relative, mode) + } + if err := root.Rename(tempName, relative); err != nil { + return false, err + } + 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 index 4404a863b..3fa01b425 100644 --- a/internal/tools/protected_credentials_test.go +++ b/internal/tools/protected_credentials_test.go @@ -213,3 +213,144 @@ func TestProtectedReadOpenClosesTheCheckToUseWindow(t *testing.T) { t.Fatalf("read_file served the token through a path swapped after an earlier successful read: %+v", after) } } + +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": + err = materializeStagedPatchFile(root, "notes.txt", stagedPatchFile{ + exists: true, mode: 0o644, content: []byte("patched\n"), + }, false) + default: + _, err = writeRootedFile(root, "notes.txt", []byte("updated\n"), 0o644, false) + } + if err != nil { + 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) + } + if err := applyStructuredPatchChanges(root, changes, nil); err != nil { + 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) + } +} diff --git a/internal/tools/structured_patch.go b/internal/tools/structured_patch.go index e472c679a..c2a43b0f7 100644 --- a/internal/tools/structured_patch.go +++ b/internal/tools/structured_patch.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "github.com/Gitlawb/zero/internal/pathjail" ) const ( @@ -98,11 +96,11 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str 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) } } @@ -319,14 +317,15 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t } change.after = operation.contents case structuredPatchDelete, structuredPatchUpdate: - 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)) @@ -560,10 +559,10 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bo } return true, nil case structuredPatchAdd: - return writeStructuredPatchFile(root, change.to, change.after, change.mode, true) + return writeRootedFile(root, change.to.relative, []byte(change.after), change.mode, true) case structuredPatchUpdate: moving := change.from.absolute != change.to.absolute - committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving) + committed, err := writeRootedFile(root, change.to.relative, []byte(change.after), change.mode, moving) if err != nil { return committed, err } @@ -577,36 +576,6 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bo return false, fmt.Errorf("unsupported structured patch operation") } -func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) { - parent := filepath.Dir(target.relative) - if err := root.MkdirAll(parent, 0o755); err != nil { - return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err) - } - temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp") - if err != nil { - return false, fmt.Errorf("writing %s: %w", target.relative, err) - } - defer func() { _ = removeStructuredPatchTemp(root, tempName) }() - if _, err := temp.WriteString(content); err != nil { - _ = temp.Close() - return false, fmt.Errorf("writing %s: %w", target.relative, err) - } - if err := temp.Chmod(mode.Perm()); err != nil { - _ = temp.Close() - return false, fmt.Errorf("writing %s: %w", target.relative, err) - } - if err := temp.Close(); err != nil { - return false, fmt.Errorf("writing %s: %w", target.relative, err) - } - if createOnly { - return publishStructuredPatchNoReplace(root, tempName, target.relative, mode) - } - if err := root.Rename(tempName, target.relative); err != nil { - return false, fmt.Errorf("writing %s: %w", target.relative, err) - } - return true, nil -} - func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) { return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link) } @@ -703,23 +672,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 := lineCount(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/workspace.go b/internal/tools/workspace.go index 8b8322b32..8a592d2f3 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 } @@ -286,6 +296,29 @@ func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath return "", "", firstErr } +func resolveScopedReadTarget(workspaceRoot string, scope PathScope, requestedPath string) (rootedScopedPath, error) { + absolute, display, err := resolveScopedReadPath(workspaceRoot, scope, requestedPath) + if err != nil { + return rootedScopedPath{}, err + } + if spillPath, ok := resolveSpillReadPath(requestedPath); ok && spillPath == absolute { + root, relative, err := rootedPathWithin([]string{spillRootPath()}, absolute) + if err != nil { + return rootedScopedPath{}, err + } + return rootedScopedPath{absolute: absolute, display: display, root: root, relative: relative}, nil + } + roots, err := scopedReadRoots(workspaceRoot, scope) + if err != nil { + return rootedScopedPath{}, err + } + root, relative, err := rootedPathWithin(roots, absolute) + if err != nil { + return rootedScopedPath{}, err + } + return rootedScopedPath{absolute: absolute, display: display, root: root, relative: relative}, nil +} + // resolveScopedPath is resolveWorkspacePath generalized to a scope: relative // paths resolve against the workspace root only; an absolute path resolves // against the first root that contains it. The workspace root's error is @@ -372,30 +405,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 298a787f7..b15995c33 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" ) @@ -55,14 +56,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,60 +79,51 @@ 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 { - 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()) - } - // Engine-independent: this is the ONLY protection write_file has when called - // through the plain registry API (Registry.Run, or RunWithOptions with no - // sandbox engine) — see internal/tools/protected_credentials.go. + // Engine-independent lexical refusal, followed by an atomic rooted publish. + // If a concurrent writer swaps this name to a token hard link after the + // check, Rename replaces that directory entry; it never truncates the token + // inode. A create uses an exclusive no-replace publish for the same reason. if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); 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, []byte(content), writeMode, !overwrite); 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) + // Pathname-based formatters and diagnostics reopen the published name. Skip + // them while a protected token is selected, or they would reintroduce the + // same swap window the rooted atomic write closes. + credentialsActive := protectedCredentialsActive(tool.workspaceRoot) + if !credentialsActive { + content = maybeFormatWrittenFile(ctx, absolutePath, content) + } // 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, lineCount(content), lineCount(content)) @@ -143,7 +143,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an lines++ } summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines) - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) + if !credentialsActive { + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) + } result := okResult(summary) result.ChangedFiles = []string{relativePath} // Card-only preview: a real unified diff (all-green for a create, red/green for From d2e41d6321cd9d6d8f00ccfcba67c7ec318bfbdc Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 25 Aug 2026 21:21:57 +0200 Subject: [PATCH 16/23] fix(tools): bind grep's credential decision to the opened file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep excluded protected credentials while walkGrepFiles visited a PATHNAME, then scanGrepFile opened that name again with os.Open. A process that can write the workspace could replace an ordinary candidate with a hard link to the daemon token file in between, and grep would scan and emit the bearer-token bytes under the ordinary name. Path confinement cannot catch this: the alias is a real file inside the root, reached by a name that never leaves it. The existing alias regressions only cover aliases that already exist when the walk checks them, so none of them exercised the window. scanGrepFile now takes the FileInfo from its own handle and re-asks the same protected-credential question through ReadExclusions.FileExcluded — the binding protectedReadOpen and MCP resources/read already use. The walk-time check stays, but only as pruning, not as the authorization boundary. readExcluder grows a handle predicate alongside its pathname ones so both constructors supply one decision authority rather than each caller remembering to re-check. openedFileExcluded falls back to the pathname predicate, so an excluder built without a handle func (tests, the no-op zero value) behaves exactly as before. TestGrepDoesNotScanTokenAliasSwappedInAfterExclusion closes the window deterministically: the swap happens inside the pathname check itself, so there are no scheduling assumptions. Verified to fail against the unfixed scan — "grep scanned the token alias swapped in after the exclusion: {file:notes.txt line:1 text:bridge-secret hits:1}" — and to pass with it. It also asserts ordinary matches survive, so the handle check can only ever remove the protected object. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq --- internal/tools/daemon_token_exclusion_test.go | 68 +++++++++++++++++++ internal/tools/grep.go | 15 +++- internal/tools/read_exclusions.go | 25 ++++++- internal/tools/search_cancellation_test.go | 2 +- 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/internal/tools/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index e3fde29ca..3fce8d190 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -555,3 +556,70 @@ func TestDaemonTokenAliasesDeniedEndToEnd(t *testing.T) { }) } } + +// 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/grep.go b/internal/tools/grep.go index 48e5d3a0d..e5640c0ba 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -353,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 @@ -361,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) @@ -388,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, info) { + return nil + } + reader := bufio.NewReader(handle) lineNumber := 1 sawLine := false diff --git a/internal/tools/read_exclusions.go b/internal/tools/read_exclusions.go index a8746c383..43ed4432f 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.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, info os.FileInfo) bool { + if e.handle != nil { + return e.handle(path, 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,7 +46,7 @@ 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.FileExcluded} } // sandboxReadExcluderWithin is sandboxReadExcluder for callers that can name @@ -48,5 +67,5 @@ func sandboxReadExcluderWithin(engine *sandbox.Engine, workspaceRoot string) rea if !rx.Active() { return readExcluder{} } - return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded} + return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileExcluded} } 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 }) From 278c9a717635546268c794aaf1a8926cf33d6c86 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 27 Aug 2026 17:18:36 +0200 Subject: [PATCH 17/23] fix(tools): apply a patch to the exact path its authorization saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_patch authorized a unified diff with sandbox.PatchHeaderPaths, whose contract is that every unquoted byte after "--- ", "+++ ", "rename from " and friends is pathname data. The executor then re-read the same headers through its own parser, which trimmed surrounding whitespace and unquoted differently. The two layers could therefore name different files: a patch whose authorization headers say `bridge-token ` cleared the gate as an unprotected sibling, while the executor resolved the trimmed `bridge-token` — the selected remote bridge token beside it. Remove the second interpretation instead of adding another targeted deny. The parser that defines the authorization target now also supplies the executor's source and destination for every supported header form — `diff --git`, `---`/`+++`, copy and rename — through an exported surface carrying one byte-preservation contract, and the executor's own trimming parsers are gone. A header form the parser cannot interpret exactly is a patch refusal, matching the gate's fail-closed behavior. The end-to-end regressions cover unquoted and C-quoted leading- and trailing-space names across update, copy and rename. Each proves both halves: the whitespace-bearing name is a real, patchable file whose control effect lands byte for byte, and the protected token one byte away is unchanged. The inverse suite makes the same names the token and asserts refusal before any read, rename or write. Restoring this fidelity also fixes the leading-space copy that previously trimmed itself into a name that does not exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RT1ZzPYPy1jzKXSMPYTKaZ --- internal/sandbox/risk.go | 47 ++++ internal/tools/apply_patch.go | 41 +-- internal/tools/daemon_token_exclusion_test.go | 9 +- internal/tools/patch_header_bytes_test.go | 233 ++++++++++++++++++ internal/tools/unified_patch.go | 59 ++--- 5 files changed, 325 insertions(+), 64 deletions(-) create mode 100644 internal/tools/patch_header_bytes_test.go diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index 57ed968a2..6058ab6c7 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -760,6 +760,9 @@ func consumeGitPath(input string) (string, string, bool) { // 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] @@ -774,6 +777,50 @@ func patchFileHeaderPath(line string) (string, bool) { return rest, rest != "" } +// The parser above defines the authorization target of a patch. The executor +// that applies the patch must select its files from the same bytes, or a header +// the gate reads as `bridge-token ` can reach `os.Root` as `bridge-token`. The +// exported wrappers below are that shared surface, and 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, which is the same refusal PatchHeaderPaths makes. +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 parsePatchHunkCounts(line string) (int, int) { _, rest, ok := strings.Cut(line, "@@") if !ok { diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index b4c294e01..273b48219 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" @@ -246,36 +245,18 @@ func validatePatchPaths(root string, patchPaths []string) error { return nil } -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)) -} - -// 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/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index 3fce8d190..28729119d 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -295,12 +295,11 @@ func TestApplyPatchDeniesHeaderOnlyAndBinaryDaemonTokenPatches(t *testing.T) { "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, - // The in-process rename/copy header parser trims the extracted path, - // so a leading-space filename resolves to a name that does not exist. - // That costs fidelity for such a file; it cannot reach the token, - // whose exact spelling the credential gate refuses first. - controlUnsupported: "opening bridge-token", }, { name: "binary modification", diff --git a/internal/tools/patch_header_bytes_test.go b/internal/tools/patch_header_bytes_test.go new file mode 100644 index 000000000..7b1be0481 --- /dev/null +++ b/internal/tools/patch_header_bytes_test.go @@ -0,0 +1,233 @@ +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. +// +// sandbox.PatchHeaderPaths decides whether a patch may run by reading every +// unquoted byte after "--- ", "+++ ", "rename from " and friends as pathname +// data. If the executor re-read those headers with its own trimming, a patch +// naming the unprotected sibling "bridge-token " would clear the gate and then +// mutate the protected "bridge-token" beside it — the gate's check would be +// authorizing a different file than the one os.Root opens. +// +// 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/unified_patch.go b/internal/tools/unified_patch.go index ab09b3f1d..66dcd3289 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 @@ -216,7 +219,11 @@ 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 "))) + 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 = from pendingKind = structuredPatchUpdate if strings.HasPrefix(raw, "copy from ") { pendingKind = structuredPatchCopy @@ -225,7 +232,10 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber) } case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "): - to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to "))) + 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) + } if pendingFrom == "" || to == "" { return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber) } @@ -237,13 +247,21 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { 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) } @@ -305,34 +323,17 @@ func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) { } // 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. +// line; "" when the operands cannot be split into two pathnames unambiguously. +// The split is delegated to the parser that authorized the patch so a +// header-only add or delete mutates exactly the file the gate inspected. 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 { + _, destination, ok := sandbox.DiffGitPaths(strings.TrimPrefix(line, "diff --git ")) + if !ok { return "" } - last := fields[len(fields)-1] - if strings.HasPrefix(last, "\"") { - return stripPatchPrefix(unquoteGitPath(last)) - } - return stripPatchPrefix(last) + // DiffGitPaths has already removed a matching a/ b/ pair; stripping a lone + // "b/" here would name a file the gate never saw among the patch's paths. + return filepath.ToSlash(destination) } // parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing From 71e951733287ffa6169949b81db615fc269e391e Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 2 Sep 2026 22:25:11 +0200 Subject: [PATCH 18/23] fix(sandbox): align patch authorization with execution Amp-Thread-ID: https://ampcode.com/threads/T-01a063ab-ba5f-7319-bbb6-3dfca232439e Co-authored-by: Amp --- internal/sandbox/apply_patch_paths_test.go | 28 +- internal/sandbox/engine_test.go | 21 +- internal/sandbox/risk.go | 308 +----------------- internal/sandbox/types.go | 8 +- internal/tools/apply_patch.go | 90 +++-- internal/tools/apply_patch_paths_test.go | 59 ++-- internal/tools/daemon_token_exclusion_test.go | 19 +- internal/tools/daemon_token_matrix_test.go | 11 +- internal/tools/edit_file.go | 19 +- internal/tools/format_on_write_test.go | 27 ++ internal/tools/inline_diagnostics_test.go | 28 ++ internal/tools/mutation_targets.go | 25 +- internal/tools/mutation_targets_test.go | 70 ++++ internal/tools/patch_header_bytes_test.go | 10 +- internal/tools/protected_credentials.go | 70 ++-- internal/tools/protected_credentials_test.go | 59 +++- internal/tools/registry.go | 12 + internal/tools/structured_patch.go | 43 ++- internal/tools/workspace.go | 23 -- internal/tools/write_file.go | 21 +- 20 files changed, 399 insertions(+), 552 deletions(-) diff --git a/internal/sandbox/apply_patch_paths_test.go b/internal/sandbox/apply_patch_paths_test.go index bef2f98d7..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,22 +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) } - paths, err := PatchHeaderPaths(patch) - if err != nil { - t.Fatalf("%q: PatchHeaderPaths: %v", header, err) - } - if 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") { @@ -78,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) } @@ -89,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_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/risk.go b/internal/sandbox/risk.go index 6058ab6c7..03395ecb5 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -277,25 +277,20 @@ func requestPaths(request Request) []string { } } 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 := firstExactStringArg(args, "patch", "diff") - if patch == "" { +func applyPatchRequestPaths(request Request) []string { + if request.PatchPaths == nil { return nil } // apply_patch consumes cwd as exact pathname data. Trimming here would make - // the gate derive targets under a different directory than git applies them. - cwd := firstExactStringArg(args, "cwd") + // the gate derive targets under a different directory than the tool applies them. + cwd := firstExactStringArg(request.Args, "cwd") var paths []string - parsed, err := PatchHeaderPaths(patch) - if err != nil { - return nil - } - for _, path := range parsed { + for _, path := range request.PatchPaths { if path == "" || path == "/dev/null" { continue } @@ -315,18 +310,17 @@ func applyPatchPathBlock(request Request) *pathBlock { if patch == "" { return nil } - paths, err := PatchHeaderPaths(patch) - if err != nil { + if request.PatchPaths == nil { return &pathBlock{ Code: BlockDenied, - Reason: "patch paths cannot be established safely: " + err.Error(), + 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 paths { + for _, path := range request.PatchPaths { if path == "" || path == "/dev/null" { continue } @@ -368,208 +362,6 @@ func IsStructuredPatch(patch string) bool { return StructuredPatchMarker(first) == "begin" } -// PatchHeaderPaths returns every source and destination path declared by a -// patch. Keeping this parser shared with apply_patch ensures the sandbox gate -// and the tool's own path validation agree on what the executor will read or write. A -// path-bearing git header that cannot be interpreted exactly rejects the patch: -// silently omitting it would let git operate on a path the policy never saw. -func PatchHeaderPaths(patch string) ([]string, error) { - if IsStructuredPatch(patch) { - return structuredPatchHeaderPaths(patch), nil - } - 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 - } - } - } - return paths -} - -func patchHeaderPaths(patch string) ([]string, error) { - var paths []string - oldRemaining, newRemaining := 0, 0 - inHunk := false - type diffSection struct { - rawDiff string - parsedSource string - parsedDestination string - diffParsed bool - extendedSource string - extendedDestination string - hasExtendedSource bool - hasExtendedDest bool - unifiedSource string - unifiedDestination string - hasUnifiedSource bool - hasUnifiedDestination bool - } - var section diffSection - flushSection := func() error { - if section.rawDiff == "" { - return nil - } - if section.hasExtendedSource != section.hasExtendedDest { - return fmt.Errorf("incomplete copy or rename path headers") - } - if section.hasUnifiedSource != section.hasUnifiedDestination { - return fmt.Errorf("incomplete unified file path headers") - } - - var source, destination string - switch { - case section.hasExtendedSource: - source, destination = section.extendedSource, section.extendedDestination - if !diffGitLineMatchesChange(section.rawDiff, section.parsedSource, section.parsedDestination, section.diffParsed, source, destination) { - return fmt.Errorf("diff --git paths disagree with the copy or rename headers") - } - case section.hasUnifiedSource && section.unifiedSource != "/dev/null" && section.unifiedDestination != "/dev/null": - var ok bool - source, destination, ok = normalizeUnifiedDiffPaths(section.rawDiff, section.parsedSource, section.parsedDestination, section.diffParsed, section.unifiedSource, section.unifiedDestination) - if !ok { - return fmt.Errorf("diff --git paths disagree with the unified file path headers") - } - case section.diffParsed: - source, destination = normalizeDiffGitPaths(section.parsedSource, section.parsedDestination) - default: - return fmt.Errorf("ambiguous diff --git path operands") - } - paths = append(paths, filepath.ToSlash(source), filepath.ToSlash(destination)) - return nil - } - setExtendedPath := func(source bool, path string) error { - if source { - if section.hasExtendedSource && section.extendedSource != path { - return fmt.Errorf("conflicting copy or rename source paths") - } - section.extendedSource, section.hasExtendedSource = path, true - } else { - if section.hasExtendedDest && section.extendedDestination != path { - return fmt.Errorf("conflicting copy or rename destination paths") - } - section.extendedDestination, section.hasExtendedDest = path, true - } - return nil - } - 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-- - } - continue - } - inHunk = false - switch { - case strings.HasPrefix(line, "diff --git "): - if err := flushSection(); err != nil { - return nil, err - } - section = diffSection{rawDiff: line[len("diff --git "):]} - section.parsedSource, section.parsedDestination, section.diffParsed = parseDiffGitPaths(section.rawDiff) - case strings.HasPrefix(line, "copy from "): - path, ok := parseExtendedGitPath(line[len("copy from "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid copy source path header") - } - if err := setExtendedPath(true, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "copy to "): - path, ok := parseExtendedGitPath(line[len("copy to "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid copy destination path header") - } - if err := setExtendedPath(false, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "rename from "): - path, ok := parseExtendedGitPath(line[len("rename from "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid rename source path header") - } - if err := setExtendedPath(true, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "rename to "): - path, ok := parseExtendedGitPath(line[len("rename to "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid rename destination path header") - } - if err := setExtendedPath(false, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "rename old "): - path, ok := parseExtendedGitPath(line[len("rename old "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid rename source path header") - } - if err := setExtendedPath(true, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "rename new "): - path, ok := parseExtendedGitPath(line[len("rename new "):]) - if !ok || section.rawDiff == "" { - return nil, fmt.Errorf("invalid rename destination path header") - } - if err := setExtendedPath(false, path); err != nil { - return nil, err - } - case strings.HasPrefix(line, "@@"): - oldRemaining, newRemaining = parsePatchHunkCounts(line) - inHunk = oldRemaining > 0 || newRemaining > 0 - case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "): - if strings.HasPrefix(line, "--- ") && section.rawDiff != "" && - section.hasUnifiedSource && section.hasUnifiedDestination { - if err := flushSection(); err != nil { - return nil, err - } - section = diffSection{} - } - path, ok := patchFileHeaderPath(line) - if !ok { - return nil, fmt.Errorf("invalid unified file path header") - } - if path == "" { - continue - } - if section.rawDiff == "" { - paths = append(paths, stripPatchPrefix(path)) - continue - } - if strings.HasPrefix(line, "--- ") { - section.unifiedSource, section.hasUnifiedSource = path, true - } else { - section.unifiedDestination, section.hasUnifiedDestination = path, true - } - } - } - if err := flushSection(); err != nil { - return nil, err - } - return paths, nil -} func parseDiffGitPaths(line string) (string, string, bool) { if line == "" { return "", "", false @@ -661,49 +453,6 @@ func normalizeDiffGitPaths(source, destination string) (string, string) { return source, destination } -func diffGitLineMatchesChange(raw, parsedSource, parsedDestination string, parsed bool, source, destination string) bool { - // Compare separator-normalized spellings. A patch may name the same file - // with "/" in its `diff --git` operands and the host separator in its - // `---`/`+++` headers; treating those as disagreeing would reject a patch - // the executor applies to one file, which is a fail-closed refusal of valid - // input rather than a security boundary. filepath.ToSlash is a no-op on - // Unix, where a backslash is an ordinary filename byte. - source, destination = filepath.ToSlash(source), filepath.ToSlash(destination) - if parsed { - parsedSource, parsedDestination := filepath.ToSlash(parsedSource), filepath.ToSlash(parsedDestination) - if parsedSource == source && parsedDestination == destination { - return true - } - if parsedSource == "a/"+source && parsedDestination == "b/"+destination { - return true - } - } - return diffGitLineMatchesPaths(raw, source, destination) || - diffGitLineMatchesPaths(raw, "a/"+source, "b/"+destination) -} - -func normalizeUnifiedDiffPaths(raw, parsedSource, parsedDestination string, parsed bool, source, destination string) (string, string, bool) { - if hasDefaultGitPrefixes(source, destination) { - normalizedSource, normalizedDestination := source[2:], destination[2:] - if diffGitLineMatchesChange(raw, parsedSource, parsedDestination, parsed, normalizedSource, normalizedDestination) { - return normalizedSource, normalizedDestination, true - } - } - if diffGitLineMatchesChange(raw, parsedSource, parsedDestination, parsed, source, destination) { - return source, destination, true - } - return "", "", false -} - -func diffGitLineMatchesPaths(line, source, destination string) bool { - for separator := range len(line) { - if line[separator] == ' ' && line[:separator] == source && line[separator+1:] == destination { - return true - } - } - return false -} - // 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. @@ -777,10 +526,8 @@ func patchFileHeaderPath(line string) (string, bool) { return rest, rest != "" } -// The parser above defines the authorization target of a patch. The executor -// that applies the patch must select its files from the same bytes, or a header -// the gate reads as `bridge-token ` can reach `os.Root` as `bridge-token`. The -// exported wrappers below are that shared surface, and they carry one contract: +// 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, @@ -805,7 +552,7 @@ func ExtendedGitHeaderPath(operand string) (string, bool) { // 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, which is the same refusal PatchHeaderPaths makes. +// two pathnames is ambiguous. func DiffGitPaths(operands string) (string, string, bool) { source, destination, ok := parseDiffGitPaths(operands) if !ok { @@ -821,37 +568,6 @@ func StripPatchPrefix(path string) string { return stripPatchPrefix(path) } -func parsePatchHunkCounts(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 - } - old, next := 0, 0 - for _, field := range strings.Fields(rangeSection) { - switch { - case strings.HasPrefix(field, "-"): - old = patchHunkCount(field[1:]) - case strings.HasPrefix(field, "+"): - next = patchHunkCount(field[1:]) - } - } - return old, next -} - -func patchHunkCount(spec string) int { - if _, count, ok := strings.Cut(spec, ","); ok { - if n, err := strconv.Atoi(count); err == nil { - return n - } - return 0 - } - return 1 -} - func stripPatchPrefix(path string) string { if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { path = path[2:] 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 273b48219..66b0c3784 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -15,6 +15,40 @@ 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 +} + func (applyPatchTool) isBuiltInApplyPatch() {} // PrepareFreeformApplyPatchArguments converts native structured-patch input @@ -150,57 +184,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) - } - // Fail closed on a path-bearing git header this process cannot interpret - // exactly, using the SAME parser the sandbox gate uses: a target the two - // disagree about is one the policy never saw. This is also the only - // path-level refusal apply_patch has when reached through the plain - // registry API, with no sandbox engine to consult. - patchPaths, err := sandbox.PatchHeaderPaths(patch) - if err != nil { - return errorResult("Error applying patch: patch paths cannot be established safely: " + err.Error()) - } - if err := validatePatchPaths(applyRoot, patchPaths); err != nil { - return errorResult("Error applying patch: " + err.Error()) + 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, patchPaths []string) []string { - seen := map[string]bool{} - var paths []string - for _, path := range patchPaths { - 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 -> diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index 0af3a5af8..acf1fbe4b 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -7,20 +7,18 @@ import ( "slices" "strings" "testing" - - "github.com/Gitlawb/zero/internal/sandbox" ) -func mustPatchHeaderPaths(t *testing.T, patch string) []string { +func mustApplyPatchPaths(t *testing.T, patch string) []string { t.Helper() - paths, err := sandbox.PatchHeaderPaths(patch) + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) if err != nil { - t.Fatalf("PatchHeaderPaths: %v", err) + t.Fatalf("prepareApplyPatchArguments: %v", err) } - return paths + return prepared.paths } -func TestPatchHeaderPathsHandlesQuotedAndSpacedNames(t *testing.T) { +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 @@ -28,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 := mustPatchHeaderPaths(t, 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 := mustPatchHeaderPaths(t, patch) + got := mustApplyPatchPaths(t, patch) if !slices.Contains(got, "x.go") { t.Fatalf("plain path not extracted: %v", got) } @@ -50,11 +48,11 @@ func TestPatchHeaderPathsUnspacedStillWorks(t *testing.T) { // 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 TestPatchHeaderPathsPreservesSurroundingSpacesInNames(t *testing.T) { +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 := mustPatchHeaderPaths(t, patch) + got := mustApplyPatchPaths(t, patch) if !slices.Contains(got, "bridge-token ") { t.Fatalf("trailing-space path not preserved: %q", got) } @@ -63,33 +61,14 @@ func TestPatchHeaderPathsPreservesSurroundingSpacesInNames(t *testing.T) { } } -func TestPatchHeaderPathsHandlesCQuotedDiffGitOperands(t *testing.T) { - patch := "diff --git \"a/bridge\\040token\" \"b/exposed\\tcopy\"\n" - got := mustPatchHeaderPaths(t, patch) - want := []string{"bridge token", "exposed\tcopy"} - if !slices.Equal(got, want) { - t.Fatalf("C-quoted diff --git operands = %q, want %q", got, want) - } -} - -func TestPatchHeaderPathsPreservesTrailingSpaceInBinaryDiffOperands(t *testing.T) { - patch := "diff --git a/bridge-token b/bridge-token \n" + - "GIT binary patch\n" - got := mustPatchHeaderPaths(t, patch) - want := []string{"bridge-token ", "bridge-token "} - if !slices.Equal(got, want) { - t.Fatalf("binary diff paths = %q, want %q", got, want) - } -} - -func TestPatchHeaderPathsRejectsAmbiguousDiffOperands(t *testing.T) { - patch := "diff --git a/source b/part b/destination\nGIT binary patch\n" - if paths, err := sandbox.PatchHeaderPaths(patch); err == nil { - t.Fatalf("PatchHeaderPaths = %q, want ambiguous-path error", paths) +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 TestPatchHeaderPathsParsesGitDefaultAndNoPrefixOutput(t *testing.T) { +func TestApplyPatchPathsParseGitDefaultAndNoPrefixOutput(t *testing.T) { for _, operation := range []string{"rename", "copy", "modify"} { for _, noPrefix := range []bool{false, true} { name := operation + "/default-prefix" @@ -98,7 +77,7 @@ func TestPatchHeaderPathsParsesGitDefaultAndNoPrefixOutput(t *testing.T) { } t.Run(name, func(t *testing.T) { patch, source, destination := gitGeneratedPatch(t, operation, noPrefix) - got := mustPatchHeaderPaths(t, patch) + 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) @@ -109,13 +88,13 @@ func TestPatchHeaderPathsParsesGitDefaultAndNoPrefixOutput(t *testing.T) { } } -func TestPatchHeaderPathsRejectsMismatchedNoPrefixRenameMetadata(t *testing.T) { +func TestApplyPatchPathsUseExecutorRenameMetadata(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 paths, err := sandbox.PatchHeaderPaths(patch); err == nil { - t.Fatalf("PatchHeaderPaths = %q, want mismatched-header error", paths) + if got, want := mustApplyPatchPaths(t, patch), []string{"other.txt", "destination.txt"}; !slices.Equal(got, want) { + t.Fatalf("executor paths = %q, want %q", got, want) } } diff --git a/internal/tools/daemon_token_exclusion_test.go b/internal/tools/daemon_token_exclusion_test.go index 28729119d..db80be46e 100644 --- a/internal/tools/daemon_token_exclusion_test.go +++ b/internal/tools/daemon_token_exclusion_test.go @@ -398,9 +398,15 @@ func TestApplyPatchDeniesHeaderOnlyAndBinaryDaemonTokenPatches(t *testing.T) { 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") { + if result.Status == StatusOK { + t.Fatalf("apply_patch unexpectedly accepted the protected patch: output=%q", result.Output) + } + if tc.controlUnsupported == "" && !strings.Contains(result.Output, "remote bridge token") { t.Fatalf("apply_patch: status=%s output=%q, want bridge-token denial", result.Status, result.Output) } + if tc.controlUnsupported != "" && !strings.Contains(result.Output, tc.controlUnsupported) { + t.Fatalf("unsupported protected patch refusal = %q, want %q", result.Output, tc.controlUnsupported) + } contents, err := os.ReadFile(token) if err != nil || string(contents) != string(original) { @@ -463,14 +469,7 @@ func TestApplyPatchDeniesTrailingSpaceDaemonTokenPath(t *testing.T) { // on whether the installed git version happens to reject the same ambiguity. func TestAmbiguousGitHeaderFailsClosedBeforeApply(t *testing.T) { patch := "diff --git a/bridge b/token b/exposed-token\n" + - "GIT binary patch\n" + - "literal 5\n" + - "LcmZQzU|<4=0Rj{Q\n\n" + - "literal 0\n" + - "HcmV?d00001\n\n" - if paths, err := sandbox.PatchHeaderPaths(patch); err == nil { - t.Fatalf("PatchHeaderPaths = %q, want ambiguous-path error", paths) - } + "new file mode 100644\n" workspace := t.TempDir() registry := NewRegistry() @@ -485,7 +484,7 @@ func TestAmbiguousGitHeaderFailsClosedBeforeApply(t *testing.T) { result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{ "patch": patch, }, options) - if result.Status == StatusOK || !strings.Contains(result.Output, "cannot be established safely") { + if result.Status == StatusOK || !strings.Contains(result.Output, "file mode header without a diff --git path") { t.Fatalf("ambiguous patch: status=%s output=%q, want pre-apply parse denial", result.Status, result.Output) } } diff --git a/internal/tools/daemon_token_matrix_test.go b/internal/tools/daemon_token_matrix_test.go index f172b9475..1f30ef0d7 100644 --- a/internal/tools/daemon_token_matrix_test.go +++ b/internal/tools/daemon_token_matrix_test.go @@ -7,8 +7,6 @@ import ( "strconv" "strings" "testing" - - "github.com/Gitlawb/zero/internal/sandbox" ) // The gate and the tool must resolve a path argument to the SAME bytes. @@ -183,13 +181,12 @@ func TestDaemonTokenProtectionMatrix(t *testing.T) { if err != nil || string(contents) != "bridge-secret\n" { t.Fatalf("token changed after a denied patch: contents=%q err=%v", contents, err) } - headerPaths, err := sandbox.PatchHeaderPaths(patch) + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) if err != nil { - t.Fatalf("PatchHeaderPaths: %v", err) + t.Fatalf("prepareApplyPatchArguments: %v", err) } - if len(headerPaths) != 2 || headerPaths[0] != filepath.ToSlash(target) || - headerPaths[1] != filepath.ToSlash(target) { - t.Fatalf("patch header paths = %q, want exact target spelling twice", headerPaths) + 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 diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index 683f70318..0ee82e728 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -167,19 +167,14 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any if err := protectedMutationDenied(absolutePath, tool.workspaceRoot); err != nil { return errorResult("Error writing " + relativePath + ": " + err.Error()) } - // Publish a complete replacement through the same root. A raced swap to the - // token is replaced as a directory entry, never opened for truncation. - if _, err := writeRootedFile(root, target.relative, []byte(updated), readInfo.Mode(), false); 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 - // Pathname-based formatters and diagnostics reopen the published name. Skip - // them while a protected token is selected, or they would reintroduce the - // same swap window the rooted atomic write closes. - credentialsActive := protectedCredentialsActive(tool.workspaceRoot) - if !credentialsActive { - updated = maybeFormatWrittenFile(ctx, absolutePath, updated) - } + updated = maybeFormatWrittenFile(ctx, absolutePath, updated) // 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, _ := root.Stat(target.relative) @@ -212,9 +207,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any suffix = "s" } summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix) - if !credentialsActive { - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) - } + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) result := okResult(summary) result.ChangedFiles = []string{relativePath} // Card-only preview (Display.Preview): the model's Output stays the one-line diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index acca3e868..e76157753 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -95,6 +95,33 @@ 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") 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/mutation_targets.go b/internal/tools/mutation_targets.go index 2ccf310e5..9a07b48ae 100644 --- a/internal/tools/mutation_targets.go +++ b/internal/tools/mutation_targets.go @@ -2,8 +2,6 @@ package tools import ( "path/filepath" - - "github.com/Gitlawb/zero/internal/sandbox" ) // MutationTargets returns the workspace-relative paths a tool call will write to, @@ -31,7 +29,7 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s if err != nil { return nil } - patchPaths, err := sandbox.PatchHeaderPaths(patch) + prepared, err := prepareApplyPatchArguments(map[string]any{"patch": patch}) if err != nil { return nil } @@ -46,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, patchPaths); err != nil { - return nil - } - paths := changedFilesFromPatch(relativeRoot, patchPaths) - 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..bd5b6bf0c 100644 --- a/internal/tools/mutation_targets_test.go +++ b/internal/tools/mutation_targets_test.go @@ -1,10 +1,14 @@ package tools import ( + "context" "os" "path/filepath" "reflect" + "strings" "testing" + + "github.com/Gitlawb/zero/internal/sandbox" ) func TestMutationTargets(t *testing.T) { @@ -116,3 +120,69 @@ func TestStripPatchPrefixStripsOnlyOne(t *testing.T) { t.Fatalf("expected [b/foo.txt], got %v", got) } } + +func TestApplyPatchGateAndRewindUseExecutorOperationPaths(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", err) + } + if got, want := prepared.paths, []string{"secret.txt"}; !reflect.DeepEqual(got, want) { + t.Fatalf("executor paths = %v, want %v", got, want) + } + + ws := t.TempDir() + secret := filepath.Join(ws, "secret.txt") + if err := os.WriteFile(secret, []byte("secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, want := MutationTargets(ws, "apply_patch", map[string]any{"patch": tc.patch}), []string{"secret.txt"}; !reflect.DeepEqual(got, want) { + t.Fatalf("rewind targets = %v, want %v", got, want) + } + + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: ws, + Policy: sandbox.Policy{ + Mode: sandbox.ModeEnforce, + EnforceWorkspace: true, + DenyWrite: []string{secret}, + }, + }) + 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, "denied") { + t.Fatalf("DenyWrite did not block executor target: status=%s output=%q", result.Status, result.Output) + } + if content, err := os.ReadFile(secret); err != nil || string(content) != "secret\n" { + t.Fatalf("denied target changed: content=%q err=%v", content, err) + } + }) + } +} diff --git a/internal/tools/patch_header_bytes_test.go b/internal/tools/patch_header_bytes_test.go index 7b1be0481..345f8d64c 100644 --- a/internal/tools/patch_header_bytes_test.go +++ b/internal/tools/patch_header_bytes_test.go @@ -15,12 +15,10 @@ import ( // TestApplyPatchExecutesHeaderPathBytesVerbatim closes the parser/consumer gap // between the layer that authorizes a patch and the layer that applies it. // -// sandbox.PatchHeaderPaths decides whether a patch may run by reading every -// unquoted byte after "--- ", "+++ ", "rename from " and friends as pathname -// data. If the executor re-read those headers with its own trimming, a patch -// naming the unprotected sibling "bridge-token " would clear the gate and then -// mutate the protected "bridge-token" beside it — the gate's check would be -// authorizing a different file than the one os.Root opens. +// 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), diff --git a/internal/tools/protected_credentials.go b/internal/tools/protected_credentials.go index 45087c7b7..eb20fe259 100644 --- a/internal/tools/protected_credentials.go +++ b/internal/tools/protected_credentials.go @@ -1,11 +1,13 @@ package tools import ( + "bytes" + "errors" "fmt" + "io" "os" "path/filepath" - "github.com/Gitlawb/zero/internal/pathjail" "github.com/Gitlawb/zero/internal/sandbox" ) @@ -16,10 +18,10 @@ import ( // // 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 build a complete temporary file under the same root and atomically -// publish it by rename (or an exclusive no-replace operation for creates). -// Consequently neither path resolution nor a later truncating open can be -// redirected to the token between authorization and use. +// 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. @@ -78,9 +80,9 @@ func protectedRootRead(root *os.Root, relative, absolute, workspaceRoot string) return file, info, nil } -// protectedMutationDenied is the early, lexical refusal. The actual mutation -// must still use writeRootedFile so a raced hard-link or symlink replacement is -// replaced atomically rather than opened and modified. +// 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) { @@ -89,38 +91,42 @@ func protectedMutationDenied(path, workspaceRoot string) error { return nil } -func protectedCredentialsActive(workspaceRoot string) bool { - exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) - return exclusions.Active() -} - -func writeRootedFile(root *os.Root, relative string, content []byte, mode os.FileMode, createOnly bool) (bool, error) { +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 } - temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-write", filepath.Ext(relative)+".tmp") - if err != nil { - return false, err - } - defer func() { _ = removeStructuredPatchTemp(root, tempName) }() - if _, err := temp.Write(content); err != nil { - _ = temp.Close() - return false, err - } - if err := temp.Chmod(mode.Perm()); err != nil { - _ = temp.Close() - return false, err - } - if err := temp.Close(); err != nil { - return false, err - } + flags := os.O_WRONLY if createOnly { - return publishStructuredPatchNoReplace(root, tempName, relative, mode) + flags |= os.O_CREATE | os.O_EXCL } - if err := root.Rename(tempName, relative); err != nil { + 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.FileExcluded(absolute, 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 } diff --git a/internal/tools/protected_credentials_test.go b/internal/tools/protected_credentials_test.go index dc832960b..2d197de10 100644 --- a/internal/tools/protected_credentials_test.go +++ b/internal/tools/protected_credentials_test.go @@ -273,7 +273,7 @@ func TestRootedMutationPublishDoesNotClobberRacedTokenAlias(t *testing.T) { 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 a pathname-level open. + // 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) @@ -284,11 +284,10 @@ func TestRootedMutationPublishDoesNotClobberRacedTokenAlias(t *testing.T) { err = applyStructuredPatchChanges(root, changes, nil) } default: - _, err = writeRootedFile(root, "notes.txt", []byte("updated\n"), 0o644, false) + _, err = writeRootedFile(root, "notes.txt", path, ws, []byte("updated\n"), 0o644, false) } - // Planning refuses the raced alias outright; publishing, when it does - // run, must not reach the token either. Either outcome is acceptable, - // an altered token is not. + // 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) } @@ -371,3 +370,53 @@ func TestStructuredPatchPlanningRejectsProtectedAliasHandle(t *testing.T) { 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/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/structured_patch.go b/internal/tools/structured_patch.go index 3da9054fb..7d4bdc3f1 100644 --- a/internal/tools/structured_patch.go +++ b/internal/tools/structured_patch.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" + "github.com/Gitlawb/zero/internal/pathjail" "github.com/Gitlawb/zero/internal/sandbox" ) @@ -125,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 @@ -756,10 +749,10 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bo } return true, nil case structuredPatchAdd, structuredPatchCopy: - return writeRootedFile(root, change.to.relative, []byte(change.after), change.mode, true) + return writeStructuredPatchFile(root, change.to, change.after, change.mode, true) case structuredPatchUpdate: moving := change.from.absolute != change.to.absolute - committed, err := writeRootedFile(root, change.to.relative, []byte(change.after), change.mode, moving) + committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving) if err != nil { return committed, err } @@ -773,6 +766,36 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bo return false, fmt.Errorf("unsupported structured patch operation") } +func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) { + parent := filepath.Dir(target.relative) + if err := root.MkdirAll(parent, 0o755); err != nil { + return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err) + } + temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp") + if err != nil { + return false, fmt.Errorf("writing %s: %w", target.relative, err) + } + defer func() { _ = removeStructuredPatchTemp(root, tempName) }() + if _, err := temp.WriteString(content); err != nil { + _ = temp.Close() + return false, fmt.Errorf("writing %s: %w", target.relative, err) + } + if err := temp.Chmod(mode.Perm()); err != nil { + _ = temp.Close() + return false, fmt.Errorf("writing %s: %w", target.relative, err) + } + if err := temp.Close(); err != nil { + return false, fmt.Errorf("writing %s: %w", target.relative, err) + } + if createOnly { + return publishStructuredPatchNoReplace(root, tempName, target.relative, mode) + } + if err := root.Rename(tempName, target.relative); err != nil { + return false, fmt.Errorf("writing %s: %w", target.relative, err) + } + return true, nil +} + func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) { return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link) } diff --git a/internal/tools/workspace.go b/internal/tools/workspace.go index 8a592d2f3..1b667ebb1 100644 --- a/internal/tools/workspace.go +++ b/internal/tools/workspace.go @@ -296,29 +296,6 @@ func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath return "", "", firstErr } -func resolveScopedReadTarget(workspaceRoot string, scope PathScope, requestedPath string) (rootedScopedPath, error) { - absolute, display, err := resolveScopedReadPath(workspaceRoot, scope, requestedPath) - if err != nil { - return rootedScopedPath{}, err - } - if spillPath, ok := resolveSpillReadPath(requestedPath); ok && spillPath == absolute { - root, relative, err := rootedPathWithin([]string{spillRootPath()}, absolute) - if err != nil { - return rootedScopedPath{}, err - } - return rootedScopedPath{absolute: absolute, display: display, root: root, relative: relative}, nil - } - roots, err := scopedReadRoots(workspaceRoot, scope) - if err != nil { - return rootedScopedPath{}, err - } - root, relative, err := rootedPathWithin(roots, absolute) - if err != nil { - return rootedScopedPath{}, err - } - return rootedScopedPath{absolute: absolute, display: display, root: root, relative: relative}, nil -} - // resolveScopedPath is resolveWorkspacePath generalized to a scope: relative // paths resolve against the workspace root only; an absolute path resolves // against the first root that contains it. The workspace root's error is diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index 7541a4291..77de239b3 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -103,24 +103,17 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an } } - // Engine-independent lexical refusal, followed by an atomic rooted publish. - // If a concurrent writer swaps this name to a token hard link after the - // check, Rename replaces that directory entry; it never truncates the token - // inode. A create uses an exclusive no-replace publish for the same reason. + // 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 := writeRootedFile(root, target.relative, []byte(content), writeMode, !overwrite); 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 - // Pathname-based formatters and diagnostics reopen the published name. Skip - // them while a protected token is selected, or they would reintroduce the - // same swap window the rooted atomic write closes. - credentialsActive := protectedCredentialsActive(tool.workspaceRoot) - if !credentialsActive { - content = maybeFormatWrittenFile(ctx, absolutePath, content) - } + content = maybeFormatWrittenFile(ctx, absolutePath, content) // Baseline the freshly written content so a later edit/overwrite in this // session compares against what is now on disk. newInfo, _ := root.Stat(target.relative) @@ -143,9 +136,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an lines++ } summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines) - if !credentialsActive { - summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) - } + summary += inlineDiagnostics(ctx, options, absolutePath, relativePath) result := okResult(summary) result.ChangedFiles = []string{relativePath} // Card-only preview: a real unified diff (all-green for a create, red/green for From 682e23ffbce39915508ef4b655f64674aee9572e Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 7 Sep 2026 19:45:19 +0200 Subject: [PATCH 19/23] fix(sandbox): close daemon token review gaps Amp-Thread-ID: https://ampcode.com/threads/T-01a07cd4-e4b2-73ad-b785-5a58906a3b0a Co-authored-by: Amp --- internal/agent/file_diagnostics.go | 24 +- internal/agent/file_diagnostics_test.go | 81 +++++++ internal/agent/loop.go | 5 + internal/agent/loop_test.go | 32 +++ internal/cli/daemon.go | 9 +- internal/daemon/remote/auth.go | 48 +++- internal/daemon/remote/auth_test.go | 59 +++++ internal/mcp/daemon_token_test.go | 38 +++ internal/mcp/resources.go | 2 +- internal/remotetoken/identity_unix.go | 28 +++ internal/remotetoken/identity_windows.go | 23 ++ internal/remotetoken/source.go | 19 +- internal/sandbox/pathlists.go | 33 ++- .../sandbox/protected_credentials_test.go | 53 +++++ internal/sandbox/runner.go | 9 + internal/sandbox/runner_test.go | 1 + internal/tools/apply_patch.go | 11 + internal/tools/apply_patch_paths_test.go | 47 +++- internal/tools/edit_file.go | 8 +- internal/tools/format_on_write.go | 85 +++++-- internal/tools/format_on_write_test.go | 220 +++++++++++++++++- internal/tools/grep.go | 2 +- internal/tools/mutation_targets_test.go | 43 +--- internal/tools/protected_credentials.go | 13 +- internal/tools/protected_credentials_test.go | 45 ++++ internal/tools/read_exclusions.go | 10 +- internal/tools/unified_patch.go | 79 +++++-- internal/tools/write_file.go | 8 +- 28 files changed, 929 insertions(+), 106 deletions(-) create mode 100644 internal/remotetoken/identity_unix.go create mode 100644 internal/remotetoken/identity_windows.go 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 38f7eb0a1..5b76e642a 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -527,14 +527,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int // 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. - if err := remote.CanonicalizeTokenFileEnv(); 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) + auth, err := remote.NewAuthenticatorFromEnv() if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } diff --git a/internal/daemon/remote/auth.go b/internal/daemon/remote/auth.go index 093c4a4f9..5cd893089 100644 --- a/internal/daemon/remote/auth.go +++ b/internal/daemon/remote/auth.go @@ -34,6 +34,7 @@ const ( EnvToken = remotetoken.EnvToken EnvTokenFile = remotetoken.EnvTokenFile EnvTokenFileResolved = remotetoken.EnvTokenFileResolved + EnvTokenFileIdentity = remotetoken.EnvTokenFileIdentity ) // ErrUnauthorized is returned when a token does not match. @@ -47,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 @@ -150,6 +152,50 @@ func CanonicalizeTokenFileEnv() error { 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 668a3d38f..4f0386329 100644 --- a/internal/daemon/remote/auth_test.go +++ b/internal/daemon/remote/auth_test.go @@ -29,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, "") diff --git a/internal/mcp/daemon_token_test.go b/internal/mcp/daemon_token_test.go index 82d31cd4c..5b6a2d8ea 100644 --- a/internal/mcp/daemon_token_test.go +++ b/internal/mcp/daemon_token_test.go @@ -155,6 +155,44 @@ func TestResourcesReadRefusesHardLinkedToken(t *testing.T) { } } +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 diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 8cbfd0f11..59a03899a 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -166,7 +166,7 @@ func (server toolServer) readResource(rawParams json.RawMessage) ([]ResourceCont if err != nil { return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) } - if server.credentialGuard.ReadExclusions().FileExcluded(absolute, info) { + if server.credentialGuard.ReadExclusions().FileHandleExcluded(absolute, file, info) { return nil, jsonRPCResourceNotFound, fmt.Errorf("resource not found: %s", uri) } if info.IsDir() { 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 index adb8d8684..2818238d6 100644 --- a/internal/remotetoken/source.go +++ b/internal/remotetoken/source.go @@ -14,6 +14,9 @@ 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. @@ -22,6 +25,7 @@ const ( type FileSource struct { Configured string Resolved string + Identity string } // SelectedFilePath returns the configured token-file pathname exactly as the @@ -61,6 +65,7 @@ func SourceFromEnv() (FileSource, bool) { source.Resolved = resolved } } + source.Identity = os.Getenv(EnvTokenFileIdentity) return source, true } @@ -86,9 +91,21 @@ func PersistSource(source FileSource) error { if err := os.Setenv(EnvTokenFile, source.Configured); err != nil { return err } - return os.Setenv(EnvTokenFileResolved, source.Resolved) + 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 == "" { diff --git a/internal/sandbox/pathlists.go b/internal/sandbox/pathlists.go index e9959afa3..82677ed0b 100644 --- a/internal/sandbox/pathlists.go +++ b/internal/sandbox/pathlists.go @@ -49,6 +49,7 @@ const ( daemonRemoteTokenEnv = remotetoken.EnvToken daemonRemoteTokenFileEnv = remotetoken.EnvTokenFile daemonRemoteTokenFileResolvedEnv = remotetoken.EnvTokenFileResolved + daemonRemoteTokenFileIdentityEnv = remotetoken.EnvTokenFileIdentity ) // The daemon-token pathname contract @@ -144,9 +145,10 @@ func protectedPathDenied(protected []string, workspaceRoot, path string) bool { // 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 + roots []string + folds []bool + infos []os.FileInfo + identity string } func newProtectedPathCache(roots []string) *protectedPathCache { @@ -158,6 +160,7 @@ func newProtectedPathCache(roots []string) *protectedPathCache { 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 { @@ -230,6 +233,9 @@ func (cache *protectedPathCache) denied(workspaceRoot, path string) bool { 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 @@ -247,10 +253,21 @@ func (cache *protectedPathCache) denied(workspaceRoot, path string) bool { // 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, info os.FileInfo) bool { +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) { @@ -543,13 +560,19 @@ func (rx *ReadExclusions) PathExcluded(path string) bool { // 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, info) { + if protectedInfoDenied(rx.protectedRoots, file, info) { return true } return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) diff --git a/internal/sandbox/protected_credentials_test.go b/internal/sandbox/protected_credentials_test.go index dd38ec5a9..e0900b522 100644 --- a/internal/sandbox/protected_credentials_test.go +++ b/internal/sandbox/protected_credentials_test.go @@ -7,6 +7,8 @@ import ( "runtime" "strings" "testing" + + "github.com/Gitlawb/zero/internal/remotetoken" ) // protectedTokenFixture writes a bridge token inside the workspace and points @@ -929,3 +931,54 @@ func TestProtectedCredentialsDoNotBlockUnrelatedRequests(t *testing.T) { 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/runner.go b/internal/sandbox/runner.go index c1358efaf..4eb17e1f1 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -1156,6 +1156,7 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string { // authority pointers. Neither belongs in an agent-controlled child. daemonRemoteTokenFileEnv, daemonRemoteTokenFileResolvedEnv, + daemonRemoteTokenFileIdentityEnv, } for _, descriptor := range providercatalog.All() { for _, key := range descriptor.AuthEnvVars { @@ -1195,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 0b971d5b6..118317547 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -873,6 +873,7 @@ func TestScrubSensitiveEnv(t *testing.T) { "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", diff --git a/internal/tools/apply_patch.go b/internal/tools/apply_patch.go index 66b0c3784..60f0828e9 100644 --- a/internal/tools/apply_patch.go +++ b/internal/tools/apply_patch.go @@ -49,6 +49,17 @@ func preparedPatchPaths(prepared *applyPatchPreparation) []string { 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 diff --git a/internal/tools/apply_patch_paths_test.go b/internal/tools/apply_patch_paths_test.go index acf1fbe4b..b8f7a2fbe 100644 --- a/internal/tools/apply_patch_paths_test.go +++ b/internal/tools/apply_patch_paths_test.go @@ -88,13 +88,45 @@ func TestApplyPatchPathsParseGitDefaultAndNoPrefixOutput(t *testing.T) { } } -func TestApplyPatchPathsUseExecutorRenameMetadata(t *testing.T) { +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 got, want := mustApplyPatchPaths(t, patch), []string{"other.txt", "destination.txt"}; !slices.Equal(got, want) { - t.Fatalf("executor paths = %q, want %q", got, want) + 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) + }) } } @@ -108,6 +140,15 @@ func gitGeneratedPatch(t *testing.T, operation string, noPrefix bool) (string, s 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) diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index 0ee82e728..9049b7983 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -13,6 +13,7 @@ type editFileTool struct { baseTool workspaceRoot string scope PathScope + formatter writtenFileFormatter } func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool { @@ -36,6 +37,7 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, + formatter: maybeFormatWrittenFile, } } @@ -174,7 +176,11 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any return errorResult("Error writing " + relativePath + ": " + err.Error()) } modelKnownContent := updated - 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, _ := root.Stat(target.relative) 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 e76157753..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" ) @@ -124,7 +125,13 @@ func TestProtectedCredentialDoesNotDisableFormatOnWriteForOtherFiles(t *testing. 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) } @@ -138,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/grep.go b/internal/tools/grep.go index e5640c0ba..98a2ba5f1 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -395,7 +395,7 @@ func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool, // 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, info) { + if err != nil || exclude.openedFileExcluded(resolvedPath, handle, info) { return nil } diff --git a/internal/tools/mutation_targets_test.go b/internal/tools/mutation_targets_test.go index bd5b6bf0c..bbec8a1ee 100644 --- a/internal/tools/mutation_targets_test.go +++ b/internal/tools/mutation_targets_test.go @@ -1,14 +1,10 @@ package tools import ( - "context" "os" "path/filepath" "reflect" - "strings" "testing" - - "github.com/Gitlawb/zero/internal/sandbox" ) func TestMutationTargets(t *testing.T) { @@ -121,7 +117,7 @@ func TestStripPatchPrefixStripsOnlyOne(t *testing.T) { } } -func TestApplyPatchGateAndRewindUseExecutorOperationPaths(t *testing.T) { +func TestApplyPatchRejectsDisagreeingExecutorOperationPaths(t *testing.T) { for _, tc := range []struct { name string patch string @@ -147,41 +143,8 @@ func TestApplyPatchGateAndRewindUseExecutorOperationPaths(t *testing.T) { } { 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", err) - } - if got, want := prepared.paths, []string{"secret.txt"}; !reflect.DeepEqual(got, want) { - t.Fatalf("executor paths = %v, want %v", got, want) - } - - ws := t.TempDir() - secret := filepath.Join(ws, "secret.txt") - if err := os.WriteFile(secret, []byte("secret\n"), 0o600); err != nil { - t.Fatal(err) - } - if got, want := MutationTargets(ws, "apply_patch", map[string]any{"patch": tc.patch}), []string{"secret.txt"}; !reflect.DeepEqual(got, want) { - t.Fatalf("rewind targets = %v, want %v", got, want) - } - - engine := sandbox.NewEngine(sandbox.EngineOptions{ - WorkspaceRoot: ws, - Policy: sandbox.Policy{ - Mode: sandbox.ModeEnforce, - EnforceWorkspace: true, - DenyWrite: []string{secret}, - }, - }) - 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, "denied") { - t.Fatalf("DenyWrite did not block executor target: status=%s output=%q", result.Status, result.Output) - } - if content, err := os.ReadFile(secret); err != nil || string(content) != "secret\n" { - t.Fatalf("denied target changed: content=%q err=%v", content, err) + if err == nil { + t.Fatalf("prepare patch = %#v, want disagreement error", prepared) } }) } diff --git a/internal/tools/protected_credentials.go b/internal/tools/protected_credentials.go index eb20fe259..9b870d984 100644 --- a/internal/tools/protected_credentials.go +++ b/internal/tools/protected_credentials.go @@ -53,13 +53,20 @@ func protectedReadOpen(path, workspaceRoot string) (*os.File, os.FileInfo, error return nil, nil, err } exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) - if exclusions.FileExcluded(path, info) { + 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) { @@ -73,7 +80,7 @@ func protectedRootRead(root *os.Root, relative, absolute, workspaceRoot string) return nil, nil, err } exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) - if exclusions.FileExcluded(absolute, info) { + if exclusions.FileHandleExcluded(absolute, file, info) { file.Close() return nil, nil, protectedCredentialErr(absolute, "readable") } @@ -112,7 +119,7 @@ func writeRootedFile(root *os.Root, relative, absolute, workspaceRoot string, co return false, statErr } exclusions := sandbox.ProtectedCredentialExclusions(workspaceRoot) - if exclusions.FileExcluded(absolute, info) { + if exclusions.FileHandleExcluded(absolute, file, info) { file.Close() return false, protectedCredentialErr(absolute, "writable") } diff --git a/internal/tools/protected_credentials_test.go b/internal/tools/protected_credentials_test.go index 2d197de10..60d403e1f 100644 --- a/internal/tools/protected_credentials_test.go +++ b/internal/tools/protected_credentials_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Gitlawb/zero/internal/daemon/remote" ) // TestEngineLessRegistryMatrix drives every registry-dispatched tool that @@ -214,6 +216,49 @@ func TestProtectedReadOpenClosesTheCheckToUseWindow(t *testing.T) { } } +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") diff --git a/internal/tools/read_exclusions.go b/internal/tools/read_exclusions.go index 43ed4432f..513835f23 100644 --- a/internal/tools/read_exclusions.go +++ b/internal/tools/read_exclusions.go @@ -19,16 +19,16 @@ type readExcluder struct { // 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.FileInfo) bool + 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, info os.FileInfo) bool { +func (e readExcluder) openedFileExcluded(path string, file *os.File, info os.FileInfo) bool { if e.handle != nil { - return e.handle(path, info) + return e.handle(path, file, info) } return e.fileExcluded(path) } @@ -46,7 +46,7 @@ func sandboxReadExcluder(engine *sandbox.Engine) readExcluder { if !rx.Active() { return readExcluder{} } - return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileExcluded} + return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileHandleExcluded} } // sandboxReadExcluderWithin is sandboxReadExcluder for callers that can name @@ -67,5 +67,5 @@ func sandboxReadExcluderWithin(engine *sandbox.Engine, workspaceRoot string) rea if !rx.Active() { return readExcluder{} } - return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileExcluded} + return readExcluder{file: rx.PathExcluded, dir: rx.DirExcluded, handle: rx.FileHandleExcluded} } diff --git a/internal/tools/unified_patch.go b/internal/tools/unified_patch.go index 66dcd3289..1e3280967 100644 --- a/internal/tools/unified_patch.go +++ b/internal/tools/unified_patch.go @@ -34,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) @@ -95,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) { @@ -206,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' @@ -219,31 +247,58 @@ 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 "): + 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 = from + pendingFrom = filepath.ToSlash(from) pendingKind = structuredPatchUpdate - if strings.HasPrefix(raw, "copy from ") { + 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 "): + 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, "--- "): @@ -322,20 +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; "" when the operands cannot be split into two pathnames unambiguously. -// The split is delegated to the parser that authorized the patch so a -// header-only add or delete mutates exactly the file the gate inspected. -func diffGitNewPath(line string) string { - _, destination, ok := sandbox.DiffGitPaths(strings.TrimPrefix(line, "diff --git ")) - if !ok { - return "" - } - // DiffGitPaths has already removed a matching a/ b/ pair; stripping a lone - // "b/" here would name a file the gate never saw among the patch's paths. - return filepath.ToSlash(destination) -} - // 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/write_file.go b/internal/tools/write_file.go index 77de239b3..2d5d4ab57 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -13,6 +13,7 @@ type writeFileTool struct { baseTool workspaceRoot string scope PathScope + formatter writtenFileFormatter } func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { @@ -35,6 +36,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool { }, workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope, + formatter: maybeFormatWrittenFile, } } @@ -113,7 +115,11 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an return errorResult("Error writing file " + relativePath + ": " + err.Error()) } modelKnownContent := content - 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, _ := root.Stat(target.relative) From 9e05cf05fbf583d1aceee6c5be0ec319cc56953c Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 13 Sep 2026 00:23:43 +0200 Subject: [PATCH 20/23] test(sandbox): use the error-aware bwrap planner Keep the worktree-pointer regression test aligned with the planner API so vet and test builds exercise the hardened filesystem plan. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/sandbox/profile_worktree_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/sandbox/profile_worktree_test.go b/internal/sandbox/profile_worktree_test.go index e89d23953..c86dc1bce 100644 --- a/internal/sandbox/profile_worktree_test.go +++ b/internal/sandbox/profile_worktree_test.go @@ -50,7 +50,7 @@ func TestLinuxBwrapPlanDoesNotMountBelowWorktreePointer(t *testing.T) { ReadOnlySubpaths: gitMetadataWriteCarveouts(root), }}, }} - args := linuxBwrapFilesystemArgs(profile) + args := mustBuildLinuxBwrapFilesystemPlan(t, profile).Args assertArgsContainSequence(t, args, "--ro-bind", gitPath, gitPath) for _, child := range []string{"hooks", "config"} { bogus := filepath.Join(gitPath, child) From 373596bf6b2240f4cb1858103aacd869b63feafe Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 12 Sep 2026 22:52:06 +0000 Subject: [PATCH 21/23] fix(tools): correct Kotlin and Dart stdin formatter contracts Use ktlint formatting mode with the real stdin path and suppress stdout logging. Let Dart select stdin by omitting positional paths. Add deterministic production-argv contract tests. Both cases fail before the fix: Kotlin publishes empty stdout and Dart leaves source unformatted. Amp-Thread-ID: https://ampcode.com/threads/T-01a097c8-4873-7527-baa8-4cc463d3d65f Co-authored-by: Pierre Bruno --- internal/tools/format_on_write.go | 4 +- internal/tools/format_on_write_test.go | 69 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/tools/format_on_write.go b/internal/tools/format_on_write.go index c8e033b3b..0df302beb 100644 --- a/internal/tools/format_on_write.go +++ b/internal/tools/format_on_write.go @@ -90,7 +90,7 @@ var formatterCommands = map[string][]string{ ".yaml": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, ".yml": {"prettier", "--log-level", "silent", "--stdin-filepath", formatterPathPlaceholder}, ".zig": {"zig", "fmt", "--stdin"}, - ".dart": {"dart", "format", "--output=show", "--stdin-name", formatterPathPlaceholder, "-"}, + ".dart": {"dart", "format", "--output=show", "--stdin-name", formatterPathPlaceholder}, ".tf": {"terraform", "fmt", "-"}, ".gleam": {"gleam", "format", "--stdin"}, ".sh": {"shfmt", "--filename", formatterPathPlaceholder}, @@ -100,7 +100,7 @@ var formatterCommands = map[string][]string{ ".cpp": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, ".hpp": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, ".cc": {"clang-format", "--assume-filename=" + formatterPathPlaceholder}, - ".kt": {"ktlint", "--stdin"}, + ".kt": {"ktlint", "--format", "--stdin", "--stdin-path", formatterPathPlaceholder, "--log-level=none"}, ".swift": {"swiftformat", "--stdinpath", formatterPathPlaceholder}, ".lua": {"stylua", "--stdin-filepath", formatterPathPlaceholder, "-"}, } diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index 2005b4af3..26e16bcd6 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "testing" ) @@ -217,6 +218,74 @@ printf 'formatted\n' } } +// Keep the real argv and replace only the executable with a deterministic CLI +// contract fixture. Replacing the entire command would hide broken production +// flags (lint-only Kotlin can succeed with empty stdout; Dart rejects a dash). +func TestFormatOnWriteStdinCommandContracts(t *testing.T) { + for _, extension := range []string{".kt", ".dart"} { + t.Run(extension, func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "source file"+extension) + command := formatterCommands[extension] + fixture := []string{os.Args[0], "-test.run=^TestFormatOnWriteStdinContractHelper$", "--", command[0]} + formatterCommands[extension] = append(fixture, command[1:]...) + t.Cleanup(func() { formatterCommands[extension] = command }) + t.Setenv("ZERO_FORMATTER_CONTRACT_TARGET", target) + t.Setenv("ZERO_FORMAT_ON_WRITE", "1") + result := NewScopedWriteFileTool(dir, nil).Run(context.Background(), map[string]any{ + "path": filepath.Base(target), "content": "unformatted source\n", + }) + if result.Status != StatusOK { + t.Fatalf("write failed: %q", result.Output) + } + content, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(content) != "formatted source\n" { + t.Fatalf("stdin contract did not publish non-empty formatted source: got %q", content) + } + }) + } +} + +func TestFormatOnWriteStdinContractHelper(t *testing.T) { + target := os.Getenv("ZERO_FORMATTER_CONTRACT_TARGET") + if target == "" { + return + } + separator := slices.Index(os.Args, "--") + if separator < 0 || separator+1 >= len(os.Args) { + os.Exit(2) + } + arguments := os.Args[separator+1:] + var want []string + switch arguments[0] { + case "ktlint": + // Lint-only mode succeeds without emitting source for valid Kotlin. + if slices.Equal(arguments, []string{"ktlint", "--stdin"}) { + os.Exit(0) + } + want = []string{"ktlint", "--format", "--stdin", "--stdin-path", target, "--log-level=none"} + case "dart": + // Dart uses stdin only when there are no positional paths. + want = []string{"dart", "format", "--output=show", "--stdin-name", target} + default: + os.Exit(3) + } + if !slices.Equal(arguments, want) { + os.Exit(4) + } + content, err := io.ReadAll(os.Stdin) + if err != nil || string(content) != "unformatted source\n" { + os.Exit(5) + } + if _, err := io.WriteString(os.Stdout, "formatted source\n"); err != nil { + os.Exit(6) + } + os.Exit(0) +} + func TestFormatOnWriteUsesDestinationForProjectConfiguration(t *testing.T) { dir := t.TempDir() sourceDir := filepath.Join(dir, "src") From c410fdc5a8cb320ada0a0105c34db335ba4b1287 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 12 Sep 2026 22:56:57 +0000 Subject: [PATCH 22/23] test(tools): canonicalize stdin formatter fixture paths Compare the formatter filename hint against the physical destination rather than a symlinked temporary-directory spelling. Reproduced the macOS failure with a symlinked TMPDIR on Linux and verified the regression and focused race suite pass with that layout. Amp-Thread-ID: https://ampcode.com/threads/T-01a097c8-4873-7527-baa8-4cc463d3d65f Co-authored-by: Pierre Bruno --- internal/tools/format_on_write_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/tools/format_on_write_test.go b/internal/tools/format_on_write_test.go index 26e16bcd6..8441ed79c 100644 --- a/internal/tools/format_on_write_test.go +++ b/internal/tools/format_on_write_test.go @@ -224,7 +224,12 @@ printf 'formatted\n' func TestFormatOnWriteStdinCommandContracts(t *testing.T) { for _, extension := range []string{".kt", ".dart"} { t.Run(extension, func(t *testing.T) { - dir := t.TempDir() + // Tools pass the physical destination, including when macOS's temp + // directory (or the caller's TMPDIR) contains a symlink prefix. + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } target := filepath.Join(dir, "source file"+extension) command := formatterCommands[extension] fixture := []string{os.Args[0], "-test.run=^TestFormatOnWriteStdinContractHelper$", "--", command[0]} From 8c01bb9b35c252ad89add0ba74a9293836e34e31 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 12 Sep 2026 23:04:28 +0000 Subject: [PATCH 23/23] fix(cli): join local daemon serve before remote startup returns Wait for local Serve cleanup and final logging after shutdown before releasing caller-owned writers and runtime fixtures. Preserve the original TLS bind error and unregister signal delivery on return. Add a synchronized regression that fails without the join: serve-remote returned 1 while local Serve was still logging. Restore the token identity environment in the startup fixture. Validation: full tests, vet, formatting, release build/smoke, govulncheck, and focused CLI/security race suites passed. Advisory static lint retains four unrelated upstream findings. Amp-Thread-ID: https://ampcode.com/threads/T-01a097c8-4873-7527-baa8-4cc463d3d65f Co-authored-by: Pierre Bruno --- internal/cli/daemon.go | 8 +++++ internal/cli/daemon_test.go | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index be703ec13..f002b4935 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -555,7 +555,9 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int } // Serve the local control socket too, so local clients keep working. + localServeDone := make(chan struct{}) go func() { + defer close(localServeDone) if serveErr := srv.Serve(); serveErr != nil { logf("local serve error: " + serveErr.Error()) } @@ -566,6 +568,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr) select { @@ -573,10 +576,15 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int srv.Shutdown() _ = bridge.Close() <-serveErr // wait for the accept loop to unwind + <-localServeDone return exitSuccess case err := <-serveErr: // Bind/serve failed before any signal (e.g. address in use). srv.Shutdown() + // Shutdown interrupts Serve even during startup. Join its cleanup and + // final logging before writing the terminal error or returning ownership + // of the writers/runtime directory to the caller. + <-localServeDone return writeAppError(stderr, err.Error(), exitCrash) } } diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index d11368398..044e16dfe 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509/pkix" "encoding/pem" "errors" + "io" "math/big" "net" "os" @@ -238,6 +239,7 @@ func TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers(t *testing t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "") t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", "token") t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "") + t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_IDENTITY", "") code, _, _ := runDaemonCLI(t, "serve-remote", "--addr", "127.0.0.1:not-a-port", "--tls-cert", certFile, "--tls-key", keyFile) if code != exitCrash { @@ -259,6 +261,76 @@ func TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers(t *testing } } +type daemonTestWriter func([]byte) (int, error) + +func (write daemonTestWriter) Write(data []byte) (int, error) { return write(data) } + +func TestDaemonServeRemoteJoinsLocalServeBeforeReturningBindError(t *testing.T) { + certFile, keyFile := writeDaemonTestCertificate(t) + // Make local Serve fail without opening a socket on any platform. + runtimeFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(runtimeFile, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_RUNTIME_DIR", runtimeFile) + t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "test-token") + localLogging := make(chan struct{}) + releaseLocal := make(chan struct{}) + localFinished := make(chan struct{}) + returned := make(chan int, 1) + var terminalError string + stderr := daemonTestWriter(func(data []byte) (int, error) { + if strings.Contains(string(data), "local serve error:") { + close(localLogging) + <-releaseLocal + close(localFinished) + } else { + terminalError += string(data) + } + return len(data), nil + }) + // Do not let the main goroutine reach the TLS failure select until its + // local server goroutine is demonstrably still inside the logging callback. + stdout := daemonTestWriter(func(data []byte) (int, error) { + select { + case <-localLogging: + return len(data), nil + case <-time.After(5 * time.Second): + return 0, io.ErrNoProgress + } + }) + go func() { + returned <- runDaemonServeRemote([]string{"--addr", "127.0.0.1:not-a-port", "--tls-cert", certFile, "--tls-key", keyFile}, stdout, stderr) + }() + select { + case <-localLogging: + case <-time.After(5 * time.Second): + close(releaseLocal) + t.Fatal("local Serve did not reach the synchronized failure") + } + select { + case code := <-returned: + close(releaseLocal) + <-localFinished + t.Fatalf("serve-remote returned %d while local Serve was still logging", code) + case <-time.After(100 * time.Millisecond): + } + close(releaseLocal) + select { + case code := <-returned: + if code != exitCrash || !strings.Contains(terminalError, "not-a-port") { + t.Fatalf("original TLS bind error was lost: code=%d stderr=%q", code, terminalError) + } + select { + case <-localFinished: + default: + t.Fatal("local Serve callback outlived serve-remote") + } + case <-time.After(5 * time.Second): + t.Fatal("serve-remote did not finish after releasing local Serve") + } +} + func writeDaemonTestCertificate(t *testing.T) (string, string) { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)