module src/tools/shell.rs · sandbox/ · redact.rs│ posture fail-closed│ backend macOS Seatbelt│ deps zero, for redaction
Three layers between the model and the machine.
A local model with a shell tool is a local model that can run rm -rf ~. LocalNPC puts three independent layers in the way: a syntactic policy gate that classifies before the dangerous syscall, an OS sandbox that jails the spawn regardless of what the gate concluded, and a scrubber that masks secrets in tool output before they reach either the user or the model’s context.
01 · Classify firstThe ShellPolicy gate
The doc comment names its own model explicitly: “a small, auditable, fail-closed allow/deny layer applied before the model-supplied string ever reaches /bin/bash -lc”, and it says it is written “in the same spirit as the web.extract SSRF guard” — classify before the dangerous operation, and fail closed.
ShellDecision has three variants, and the middle one is the interesting design choice. Deny is a hard refusal. Allow is the only path that reaches the process spawn. Approval is neither — the command is held, an event is emitted carrying the command and the reason, and it does not run until a human or a UI approves it.
evaluate: “a hard Deny always wins over an allow entry… then allow-list, then default-deny-everything-else via Approval.” With the shipped defaults — allow empty, deny empty, deny_destructive true — every non-destructive command lands in Approval.The destructive checks are careful rather than clever. mentions_recursive_force_rm recognises combined flags (-rf, -fr), separate flags in any order, and the long forms --recursive/--force. rm_targets_dangerous_root distinguishes root-ish targets appearing mid-command from the same targets ending it, because rm -rf / and rm -rf / --no-preserve-root need different string tests. is_fork_bomb strips all whitespace before matching, so spacing variants do not evade it.
CommandPolicy::networked is the only constructor that permits network egress, and the comment in src/tools/verify.rs marks it as such: “This is the one legitimate allow_network site; it is still jailed to the workspace via the OS sandbox.” It is reserved for engine-authored verification commands that need cargo or npm to fetch — never for model-supplied shell.
| Constructor | Level | Timeout | Output cap | Network |
|---|---|---|---|---|
| default() | ReadOnly at cwd | 120 s | 128,000 B | no |
| read_only_root(root) | ReadOnly | 120 s | 128,000 B | no |
| for_root(root) | WorkspaceWrite | 120 s | 128,000 B | no |
| networked(root) | WorkspaceWrite | 300 s | 256,000 B | yes |
for_root is documented as “the correct default for attended chat / the REST gateway and for model-supplied worker commands”; read_only_root is “the unattended-safe posture”. The distinction between attended and unattended is expressed as a choice of constructor rather than as a runtime flag someone has to remember to set.
02 · Then jail it anywayThe OS sandbox
The policy gate is syntactic and admits it. The second layer does not depend on parsing anything. SandboxPolicy describes what a confined process may touch, in OS-agnostic terms, and a per-OS backend translates it into the real kernel mechanism — Seatbelt SBPL on macOS.
- ReadOnly
- Read anywhere, write nowhere, no network. Documented as “the safe default for unattended/autopilot runs”.
- WorkspaceWrite
- Read anywhere; write only inside
writable_roots; no network unlessallow_networkis set. - DangerFullAccess
- No OS sandbox. Reachable only via explicit user approval or a config opt-in.
workspace_write(root) builds the policy that matters most in practice. Writable roots are the project root plus $TMPDIR when it is set — because “build/test commands that scratch to $TMPDIR keep working”. Two subpaths are carved back out as read-only: .git, so the model cannot rewrite git hooks, and .localnpc, so it cannot rewrite the engine’s own config or run state.
“Key correctness note: writable roots MUST be canonicalized before becoming -D params… On macOS /tmp is a symlink to /private/tmp; an un-canonicalized writable root silently fails to grant writes.” The failure mode is the worst kind — the sandbox appears configured and the writes just do not happen. SandboxPolicy::canonicalized is called by the caller before the profile is built.
(version 1)
(deny default)
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow file-read*)
(allow file-write-data (literal "/dev/null"))
(allow file-write-data (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/null"))
(allow sysctl-read)
(allow user-preference-read)
(allow mach-lookup)
Read the last line of that profile by what is absent: there is no network-* allow rule, so under (deny default) all egress is blocked. Network permission is not a flag that gets checked — it is a rule that is either appended or not.
The read_denied_subpaths field is the newest layer and the most narrowly scoped. It emits trailing (deny file-read* (subpath …)) rules, which win under SBPL’s last-match-wins semantics even though the base profile allows reads broadly. It is empty by default; the external-harness adapters set it to the secret directories — ~/.ssh, cloud credentials, other agents’ keys — that no coding agent needs. The comment is explicit about why this is a partial measure: it shrinks the read-exfil surface “even though network egress can’t be IP-scoped.”
| Attempt | Result |
|---|---|
| Write outside a writable root | EPERM |
| Write inside a writable root (canonicalised) | ok |
Write to .git or .localnpc | EPERM |
| Read anywhere | ok |
| Network egress | blocked |
One small detail carries a disproportionate amount of the security story: SANDBOX_EXEC is a hardcoded absolute path, with a comment saying “never resolve via $PATH (an attacker who controls $PATH could otherwise point this at their own binary).” A sandbox that can be swapped out by an environment variable is not a sandbox.
03 · On the way outRedacting secrets from tool results
The threat here is not a malicious model — it is an ordinary one being helpful. The agent can fs.read or shell.run cat a dotfile, and the raw bytes would otherwise reach both the SSE reasoning stream and the model’s context in cleartext. src/tools/redact.rs is a dependency-free scrubber that masks the values of obvious secrets while leaving surrounding structure intact — “so the model still sees that (e.g.) a key exists without seeing the key.”
//registry.npmjs.org/:_authToken=npm_…, because the leading : was checked and the = was not. C-3: masking the whole line destroyed benign text, so now only the value token is replaced and the tail is rescanned.if value_looks_secret(&rest[..val_end]) {
let head = &line[..idx + ch.len_utf8()];
// Re-scan the tail so additional secrets after the value still get masked.
return format!("{head}{lead_ws}{MASK}{}", redact_tokens(&rest[val_end..]));
}
Two thresholds do most of the discrimination, and they are different on purpose. An assignment value needs only eight characters to be masked, because the key name already told us it is a secret. A standalone token needs twelve characters and a recognised prefix, because there is no surrounding context to justify a guess. The asymmetry is what keeps tokens: 42 used out of 100 intact while masking ghp_0123456789abcdefABCD in free prose.
Seven unit tests sit at the bottom of the file, and their names read as the specification: masks_env_style_assignments, masks_aws_credentials_block, masks_npmrc_authtoken_mid_token, does_not_over_redact_benign_hint_lines, masks_pem_private_key_body, masks_standalone_token_prefix, leaves_benign_text_untouched. Three of the seven test what the scrubber must not do.
04 · LimitsWhat the code says it does not do
The most useful thing about this part of the codebase is that each layer states its own boundary in a comment rather than leaving the reader to discover it.
“It is intentionally simple (line + token scanning, no regex dep). It is a defense-in-depth net, not a guarantee — the primary fix for arbitrary-file exfil is jailing the harness workspace root.”
src/tools/redact.rs — module doc“It is intentionally syntactic (we do not, and cannot, fully parse bash), so it errs toward blocking — the residual risk is covered by the approval gate, which holds every non-allow-listed command anyway.”
src/tools/shell.rs — fn destructive_reasonThose two comments compose into the actual argument. The scrubber does not claim to catch every secret; it claims to reduce accidental leakage into a context window and a stream. The pattern matcher does not claim to understand bash; it claims to catch the well-known catastrophes and hand everything else to a human. Neither is load-bearing alone. The load-bearing layer is the OS sandbox, which does not care what the command says.
Policy runs before the spawn and can refuse. The sandbox runs at the spawn and confines regardless. Redaction runs after, on the result, before it reaches the user or the model. A failure in any one of them is contained by the others: a pattern the matcher missed still cannot write outside the workspace; a file the sandbox permitted reading still has its credential values masked on the way back.
Platform scope
The backend module list is policy.rs, jail.rs, macos.rs and mod.rs. policy.rs’s doc comment names Landlock plus seccomp as the intended Linux translation of the same policy, and the enum comment cites Codex’s ReadOnly / WorkspaceWrite / DangerFullAccess as its model. The abstraction is deliberately OS-agnostic; the shipped kernel-level backend in this tree is the macOS one.
Where to look
- shell.rs
- 989 lines.
CommandPolicy,ShellPolicy,ShellDecision, the destructive classifiers, and the bounded process runner with its output caps and timeout handling. - sandbox/policy.rs
- The OS-agnostic
SandboxLevelandSandboxPolicy, with the constructors that encode the attended/unattended distinction. - sandbox/macos.rs
- SBPL generation, the hardcoded
sandbox-execpath, and SBPL string quoting. - redact.rs
- 251 lines including seven tests. No dependencies beyond
std.
Three test files exercise this surface directly — tests/shell_timeout_tests.rs, tests/tool_dispatch_tests.rs with thirty-two cases, and tests/fs_tools_tests.rs — alongside the in-file unit tests. The browser- and control-dependent cases are #[ignore]d and run explicitly with --ignored --test-threads=1, so a default cargo test stays hermetic.