Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/logs/engineering-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -6160,3 +6160,77 @@ Skipped creating separate issues for Op/EventMsg protocol (already covered by SS
(`TestSandboxWorkspaceScopeNetworkPolicyLiveCurl`) curls
`https://proxy.golang.org` through the actual seatbelt sandbox and asserts
success under allow, failure under deny.

# 2026-09-06 (Issue #1399 sandbox toolchain cache dirs)

- Prior behavior: `SandboxScopeWorkspace` confined writes to the workspace
root alone (plus a handful of device nodes on darwin). Any language
toolchain invoked via `bash` under workspace scope — `go build`/`go test`,
`npm install`, `cargo build` — writes to its build cache and a scratch
directory under the process temp dir or a per-user cache dir, neither of
which is the workspace, so every such command failed with
`failed to initialize build cache ... operation not permitted` (darwin
seatbelt) or the equivalent bwrap "Operation not permitted" (Linux,
since `/tmp` was only ever read-only bound there).
- Change: `toolchainWritableDirs()` (`internal/harness/tools/toolchain_dirs.go`)
computes, fresh on every call, the set of per-user temp/cache roots a
toolchain needs: `os.TempDir()`, `os.UserCacheDir()`, `~/.cache` (created
if missing), `$GOCACHE` (if set and existing), the Go module cache
(`$GOMODCACHE`, else `$GOPATH/pkg`, else `~/go/pkg`, created if missing),
`~/.npm`, `~/.cargo/registry`, `~/.cargo/git`. Every entry is
symlink-canonicalized (`filepath.EvalSymlinks`) the same way the workspace
root already is, so it lines up with the kernel-resolved paths darwin's
seatbelt `(subpath ...)` predicate and Linux's bwrap binds actually match
against. `$HOME` itself is never opened up — only these specific narrow
subdirectories, and only the two noted above are created rather than
merely detected.
- `seatbeltProfile` (darwin) now emits an additional
`(allow file-write* (subpath ...))` line per directory for
`SandboxScopeWorkspace` only (`SandboxScopeLocal` already has a blanket
`(allow file-write*)`, so it does not need these). `buildSandboxedCommand`
(Linux) now `--bind`s each directory read-write, after the read-only root
bind and before the workspace bind; `/tmp` is deliberately no longer
explicitly `--ro-bind`ed there, since `os.TempDir()` is frequently exactly
`/tmp` (TMPDIR unset) and a read-only bind of the same path would shadow
the later read-write bind for that directory. `/var/tmp` stays read-only
unless it happens to be one of the toolchain writable dirs.
- `checkWorkspaceScopeCommand`'s string heuristic (defense-in-depth, not the
primary OS-level enforcement) now treats an absolute-path token as
in-scope if it falls under the workspace OR any `toolchainWritableDirs()`
entry, reusing `canonicalizePathAllowingMissing`/`pathWithinRoot` from
`common_paths.go` rather than re-deriving symlink-safe containment
checking. It also now expands a leading `~/` token against the home
directory before the containment check, so `~/.ssh/id_rsa` is still
correctly rejected (previously `~`-prefixed tokens were silently skipped
entirely, since `filepath.IsAbs` does not recognize `~` as absolute).
- `SandboxExecResult` gains a `WritableDirs []string` field so the bash tool
result map carries a `sandbox_writable_dirs` key (populated only for
workspace scope, where it's meaningful); the "Permissions for this run:
..." notice injected into the model's context (issue #1397's line) now
appends "For this run, temp and per-user cache directories are writable."
when `sandbox=workspace`, so the model does not misdiagnose a legitimate
cache-dir write as a permissions problem it must route around.
- Existing test fallout, both expected consequences of legitimately widening
what workspace scope permits: `TestSandboxWorkspaceScopeBlocksWriteOutsideWorkspaceAtOSLevel`
and `TestSandboxWorkspaceScopeEnforcesFilePaths` both used to prove an
"outside the workspace" write was rejected by writing under `os.TempDir()`
or a sibling of the workspace's `t.TempDir()` parent (itself under
`os.TempDir()`) — both are now legitimately writable, so both tests were
repointed at `/var/tmp` (never a toolchain writable dir) to keep proving a
real boundary exists. `TestCheckSandboxCommandWorkspaceScope`'s
cross-platform-ambiguous `"ls /tmp"` case (would flip from rejected to
accepted on any host where `TMPDIR` is unset, since `os.TempDir()` then
equals literal `/tmp`) was replaced with `"ls /usr/local/bin/x"`, one of
the contract's explicit still-rejected examples.
- Regression/integration: `TestSandboxWorkspaceScopeToolchainCanBuildAndTest`
creates a throwaway Go module inside the workspace and runs
`go env GOCACHE && go build ./... && go test ./...` through the real
darwin seatbelt sandbox with no env var overrides — confirmed to fail with
the pre-fix code (`operation not permitted` creating the build work dir)
and pass after; `TestSandboxWorkspaceScopeAllowsMktempDir` does the same
for `mktemp -d`. Both are skipped when no OS-level sandbox mechanism is
available; the Linux bwrap bind-flag test
(`TestBuildSandboxedCommandLinuxIncludesToolchainWritableDirs`) cannot
execute on this darwin worktree and is verified by
`GOOS=linux go vet ./internal/harness/tools/` for syntax/type correctness
only — it needs a real Linux CI run to prove behavior.
20 changes: 16 additions & 4 deletions internal/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -7460,16 +7460,28 @@ func buildContinuationPolicyNotice(srcAllowed, currentAllowed []string, srcPerms

// permissionsNoticeLines renders the permissions statement injected into the
// model's context: one line reporting the current sandbox/approval/network
// axes, plus (when network is denied) a warning that dependency installs
// will fail rather than letting the model silently substitute a different
// design (issue #1397). Used both for the first-turn notice (always present)
// and the continuation notice (present only when permissions changed).
// axes, plus (when sandbox is "workspace") a note that temp and per-user
// cache directories are writable, plus (when network is denied) a warning
// that dependency installs will fail rather than letting the model silently
// substitute a different design (issue #1397). Used both for the
// first-turn notice (always present) and the continuation notice (present
// only when permissions changed).
func permissionsNoticeLines(perms PermissionConfig) []string {
network := perms.Network
if network == "" {
network = NetworkPolicyAllow
}
lines := []string{fmt.Sprintf("Permissions for this run: sandbox=%s, approval=%s, network=%s.", perms.Sandbox, perms.Approval, network)}
if perms.Sandbox == SandboxScopeWorkspace {
// Issue #1399: workspace scope confines writes to the workspace
// plus a handful of per-user temp/cache directories a language
// toolchain needs (go build/test, npm, cargo). Told explicitly so
// the model does not misdiagnose a legitimate cache-dir write as a
// permissions problem it must route around. "local"/"unrestricted"
// already permit unrestricted filesystem writes, so this line
// would be noise there and is omitted.
lines = append(lines, "For this run, temp and per-user cache directories are writable.")
}
if network == NetworkPolicyDeny {
lines = append(lines, "Outbound network is blocked for this run: dependency installs will fail; report the blocker instead of substituting a different design.")
}
Expand Down
74 changes: 74 additions & 0 deletions internal/harness/runner_writable_dirs_notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package harness

import "testing"

// TestRunnerFirstTurnPermissionsNoticeIncludesWritableCacheDirsForWorkspace
// verifies that under SandboxScopeWorkspace the permissions notice tells the
// model that temp and per-user cache directories are writable (issue
// #1399), so it does not misdiagnose a `go build`/`npm install` cache
// failure as a permissions problem it must additionally route around.
func TestRunnerFirstTurnPermissionsNoticeIncludesWritableCacheDirsForWorkspace(t *testing.T) {
t.Parallel()

provider := &capturingProvider{turns: []CompletionResult{{Content: "done"}}}
runner := NewRunner(provider, NewRegistry(), RunnerConfig{
DefaultModel: "gpt-5-nano",
MaxSteps: 2,
DefaultAgentIntent: "general",
})

run, err := runner.StartRun(RunRequest{
Prompt: "hello",
Permissions: &PermissionConfig{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: NetworkPolicyAllow},
})
if err != nil {
t.Fatalf("start run: %v", err)
}
if _, err := collectRunEvents(t, runner, run.ID); err != nil {
t.Fatalf("collect events: %v", err)
}

if len(provider.calls) != 1 {
t.Fatalf("expected one provider call, got %d", len(provider.calls))
}
if !anyMessageContains(provider.calls[0].Messages, "temp and per-user cache directories are writable") {
t.Fatalf("expected first-turn messages to mention writable temp/cache dirs for workspace scope, got %+v", provider.calls[0].Messages)
}
}

// TestRunnerFirstTurnPermissionsNoticeOmitsWritableCacheDirsForUnrestricted
// is a regression test for issue #1399, guarding a different angle than the
// positive test above: "local" and "unrestricted" sandbox scope already
// permit unrestricted filesystem writes, so calling out temp/cache dirs
// specifically there would be noise (and would misleadingly suggest those
// scopes are MORE restricted than they actually are). If a future change
// stopped gating the new sentence on Sandbox == SandboxScopeWorkspace, this
// test would fail by finding the sentence present for unrestricted scope.
func TestRunnerFirstTurnPermissionsNoticeOmitsWritableCacheDirsForUnrestricted(t *testing.T) {
t.Parallel()

provider := &capturingProvider{turns: []CompletionResult{{Content: "done"}}}
runner := NewRunner(provider, NewRegistry(), RunnerConfig{
DefaultModel: "gpt-5-nano",
MaxSteps: 2,
DefaultAgentIntent: "general",
})

run, err := runner.StartRun(RunRequest{
Prompt: "hello",
Permissions: &PermissionConfig{Sandbox: SandboxScopeUnrestricted, Approval: ApprovalPolicyNone},
})
if err != nil {
t.Fatalf("start run: %v", err)
}
if _, err := collectRunEvents(t, runner, run.ID); err != nil {
t.Fatalf("collect events: %v", err)
}

if len(provider.calls) != 1 {
t.Fatalf("expected one provider call, got %d", len(provider.calls))
}
if anyMessageContains(provider.calls[0].Messages, "temp and per-user cache directories are writable") {
t.Fatalf("expected unrestricted-scope notice to omit the writable-cache-dirs sentence, got %+v", provider.calls[0].Messages)
}
}
6 changes: 6 additions & 0 deletions internal/harness/tools/bash_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,9 @@ func (m *JobManager) runForeground(ctx context.Context, command string, timeoutS
if sbResult.NetworkPolicy != "" {
result["sandbox_network"] = string(sbResult.NetworkPolicy)
}
if len(sbResult.WritableDirs) > 0 {
result["sandbox_writable_dirs"] = sbResult.WritableDirs
}
return result, nil
}

Expand Down Expand Up @@ -448,6 +451,9 @@ func (m *JobManager) runBackground(ctx context.Context, command string, timeoutS
if sbResult.NetworkPolicy != "" {
result["sandbox_network"] = string(sbResult.NetworkPolicy)
}
if len(sbResult.WritableDirs) > 0 {
result["sandbox_writable_dirs"] = sbResult.WritableDirs
}
return result, nil
}

Expand Down
64 changes: 51 additions & 13 deletions internal/harness/tools/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,38 +53,58 @@ func CheckSandboxCommand(scope SandboxScope, network NetworkPolicy, workspaceRoo
}

// checkWorkspaceScopeCommand blocks bash commands that appear to target paths
// outside the workspace. It inspects:
// - Absolute paths embedded in the command.
// outside the workspace and outside toolchainWritableDirs() (issue #1399).
// It inspects:
// - Absolute paths embedded in the command (including "~/..." tokens,
// expanded against the home directory before the containment check).
// - "cd .." or "cd ../../" style path escapes.
// - /etc, /tmp, /var, /usr, /home, /root usage (paths outside workspace).
// - /etc, /tmp, /var, /usr, /home, /root usage, EXCEPT when the token
// falls under one of toolchainWritableDirs() — e.g. a GOTMPDIR or
// GOCACHE override pointed at the process temp dir or a per-user cache
// dir, which language toolchains legitimately need under workspace
// scope.
func checkWorkspaceScopeCommand(workspaceRoot, command string) error {
// Resolve workspace root for comparison.
// Resolve workspace root for comparison, canonicalizing symlinks the
// same way the extra writable roots below already are (and the same
// way the OS-level sandbox builders already resolve the workspace
// root) so a token pointing at the same location through a symlink
// (e.g. macOS's /var -> /private/var) is not falsely rejected.
absRoot, err := filepath.Abs(workspaceRoot)
if err != nil {
absRoot = workspaceRoot
}
absRoot = filepath.Clean(absRoot)
if resolved, err := filepath.EvalSymlinks(absRoot); err == nil {
absRoot = resolved
}

roots := append([]string{absRoot}, toolchainWritableDirs()...)

home, homeErr := os.UserHomeDir()

// Detect absolute paths in the command that escape the workspace.
// We look for patterns like /something where /something is NOT under absRoot.
// Simple heuristic: split on whitespace and check each token that looks like
// an absolute path.
// Detect absolute paths in the command that escape the workspace and
// every toolchain writable root. Simple heuristic: split on whitespace
// and check each token that looks like an absolute (or home-relative
// "~/...") path.
tokens := strings.Fields(command)
for _, tok := range tokens {
// Strip leading quotes and common shell metacharacters.
cleaned := strings.TrimLeft(tok, `"'`)
cleaned = strings.TrimRight(cleaned, `"';`)
if homeErr == nil && (cleaned == "~" || strings.HasPrefix(cleaned, "~/")) {
cleaned = filepath.Join(home, strings.TrimPrefix(cleaned, "~"))
}
if !filepath.IsAbs(cleaned) {
continue
}
candidate := filepath.Clean(cleaned)
rel, relErr := filepath.Rel(absRoot, candidate)
if relErr != nil {
continue
if resolved, err := canonicalizePathAllowingMissing(candidate); err == nil {
candidate = resolved
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("sandbox violation: absolute path %q escapes workspace %q", cleaned, absRoot)
if pathUnderAnyRoot(candidate, roots) {
continue
}
return fmt.Errorf("sandbox violation: absolute path %q escapes workspace %q", cleaned, absRoot)
}

// Detect "cd .." patterns that escape the workspace.
Expand All @@ -96,6 +116,17 @@ func checkWorkspaceScopeCommand(workspaceRoot, command string) error {
return nil
}

// pathUnderAnyRoot reports whether candidate lies within any of roots,
// reusing pathWithinRoot's component-wise (not string-prefix) comparison.
func pathUnderAnyRoot(candidate string, roots []string) bool {
for _, root := range roots {
if pathWithinRoot(candidate, root) {
return true
}
}
return false
}

// checkLocalScopeCommand blocks outbound network commands.
func checkLocalScopeCommand(command string) error {
for _, pattern := range networkRestrictedPatterns {
Expand Down Expand Up @@ -143,6 +174,13 @@ type SandboxExecResult struct {
// output always reflects the effective policy rather than leaving the
// caller to infer it.
NetworkPolicy NetworkPolicy
// WritableDirs lists the extra per-user temp/cache roots (beyond the
// workspace itself) that were opened up for writes under
// SandboxScopeWorkspace (issue #1399), e.g. os.TempDir(), the Go build
// and module caches. Empty for scopes where it is not meaningful
// ("local"/"unrestricted" already permit unrestricted filesystem
// writes).
WritableDirs []string
}

// resolveSandboxUnavailable is called by the platform-specific
Expand Down
19 changes: 15 additions & 4 deletions internal/harness/tools/sandbox_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ func buildSandboxedCommand(ctx context.Context, scope SandboxScope, workspaceRoo
cleanup := func() { os.Remove(f.Name()) }

cmd := exec.CommandContext(ctx, sandboxExecBinary, "-f", f.Name(), "/bin/bash", "-lc", command)
return cmd, cleanup, SandboxExecResult{Applied: true, Mechanism: "seatbelt", NetworkPolicy: network}, nil
var writableDirs []string
if scope == SandboxScopeWorkspace {
writableDirs = toolchainWritableDirs()
}
return cmd, cleanup, SandboxExecResult{Applied: true, Mechanism: "seatbelt", NetworkPolicy: network, WritableDirs: writableDirs}, nil
default:
return nil, nil, SandboxExecResult{}, fmt.Errorf("unknown sandbox scope %q", scope)
}
Expand All @@ -90,10 +94,14 @@ func buildSandboxedCommand(ctx context.Context, scope SandboxScope, workspaceRoo
// SandboxScopeWorkspace: reads are allowed broadly (needed for coreutils,
// dynamic linking, terminfo, locale data, etc. without hand-maintaining an
// allowlist of every system path a shell invocation might touch); writes are
// confined to workspaceRoot plus the handful of device nodes a non-interactive
// bash needs (/dev/null, /dev/tty, /dev/zero, /dev/dtracehelper).
// confined to workspaceRoot, toolchainWritableDirs() (issue #1399 — the
// per-user temp dir and cache directories a language toolchain writes to
// even for a workspace-scoped build/test), plus the handful of device nodes
// a non-interactive bash needs (/dev/null, /dev/tty, /dev/zero,
// /dev/dtracehelper).
//
// SandboxScopeLocal: filesystem access (read and write) is unconfined.
// SandboxScopeLocal: filesystem access (read and write) is unconfined, so it
// does not need (and does not get) the toolchainWritableDirs() lines.
//
// Both scopes' network access follows the network policy (issue #1397):
// under "(deny default)", every operation — including network — is denied
Expand All @@ -108,6 +116,9 @@ func seatbeltProfile(scope SandboxScope, workspaceRoot string, network NetworkPo
switch scope {
case SandboxScopeWorkspace:
b.WriteString(fmt.Sprintf("(allow file-write* (subpath %s))\n", seatbeltQuote(workspaceRoot)))
for _, dir := range toolchainWritableDirs() {
b.WriteString(fmt.Sprintf("(allow file-write* (subpath %s))\n", seatbeltQuote(dir)))
}
b.WriteString(`(allow file-write-data (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (literal "/dev/zero"))` + "\n")
b.WriteString(`(allow file-ioctl (literal "/dev/null") (literal "/dev/tty"))` + "\n")
case SandboxScopeLocal:
Expand Down
Loading
Loading