localnpcrust harness · v0.1.0

Sections

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.

Shell policy classification and the OS sandbox behind it A model-supplied command string enters ShellPolicy::evaluate. First, if deny_destructive is set, a GUI-open check and then seven destructive-pattern checks run: recursive-force rm at a root, home or wildcard path, no-preserve-root, fork bombs, pipe-to-shell downloads, dd writes to a block device, mkfs, and writes redirected outside the project root. Any match produces a hard Deny with a reason. Next the configured deny list is scanned, also producing Deny. Then the configured allow list is scanned, producing Allow. Anything left over falls through to Approval, which emits a ToolApprovalRequested event and does not execute. Only Allow reaches the spawn site, where a SandboxPolicy with a level, writable roots, read-only subpaths, read-denied subpaths and a network flag is translated by the macOS backend into a Seatbelt profile and passed to sandbox-exec. MODEL-SUPPLIED STRING capability_call → shell.run { command } 1 · HARD DENY · if deny_destructive (default true) gui_open_reason() open · /usr/bin/open · xdg-open recursive-force rm -rf/-fr or -r+-f, targeting / ~ $HOME * --no-preserve-root a red flag regardless of the target is_fork_bomb() whitespace stripped, then matched is_pipe_to_shell() curl/wget/fetch piped into a shell dd of=/dev/ · mkfs · write escape absolute, ~ or ../ outside project_root ShellDecision::Deny(reason) refused outright · the reason string reaches the model 2 · CONFIGURED deny[] — substring match any non-empty needle contained in the command → Deny 3 · CONFIGURED allow[] — substring match empty by default, so nothing is pre-approved out of the box 4 · DEFAULT · everything else ShellDecision::Approval("command is not on the allow-list") → ToolApprovalRequested { name, command, reason } · NOT executed held for a human the UI surfaces an inline approve/deny affordance ONLY Allow REACHES THE SPAWN SandboxPolicy { level, project_root, writable_roots, read_only_subpaths, read_denied_subpaths, allow_network } → macos::seatbelt_command() /usr/bin/sandbox-exec — hardcoded, never resolved via $PATH ORDERING IS THE SECURITY PROPERTY A hard Deny always wins over an allow[] entry — an attacker cannot allow-list their way past rm -rf /. The gate is syntactic and knows it: "we do not, and cannot, fully parse bash", so it errs toward blocking. The residual risk is covered by the approval gate holding everything else.
Shell policy. The four-step order is stated in the doc comment on 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.

The one legitimate network exception

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.

src/tools/shell.rs — CommandPolicy constructors
ConstructorLevelTimeoutOutput capNetwork
default()ReadOnly at cwd120 s128,000 Bno
read_only_root(root)ReadOnly120 s128,000 Bno
for_root(root)WorkspaceWrite120 s128,000 Bno
networked(root)WorkspaceWrite300 s256,000 Byes

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 unless allow_network is 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.

The canonicalisation bug this comment prevents

“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.

src/tools/sandbox/macos.rs31–44const BASE_POLICY — the SBPL preamble
(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.”

Empirically validated behaviours, per the module doc (macOS 26.1)
AttemptResult
Write outside a writable rootEPERM
Write inside a writable root (canonicalised)ok
Write to .git or .localnpcEPERM
Read anywhereok
Network egressblocked

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.”

The secret redaction scanner, line by line Input text is split on newlines and each line is processed in order while a PEM state flag is carried between lines. A line containing both BEGIN and PRIVATE KEY starts PEM mode and is kept verbatim; while in PEM mode every line is replaced with the mask until an END PRIVATE KEY line, which is also kept. Outside PEM mode, redact_line scans every equals and colon separator in the line; if the text before the separator contains one of eleven secret key hints and the value after it looks secret — eight or more characters after stripping quotes, or a known prefix — only that value is masked and the remainder of the line is rescanned for further tokens. If no secret assignment is found, redact_tokens splits the line on whitespace and masks any standalone token of at least twelve characters starting with one of twenty-two known credential prefixes. Line structure and the trailing newline are preserved throughout. TOOL RESULT TEXT [default] aws_access_key_id = AKIAIOSFODNN7EXAMPLE tokens: 42 used out of 100 -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAA redact_secrets() input.split('\n') — indexed a newline is pushed before every line but the first, so the trailing newline survives let mut in_pem = false; state carried across lines PEM STATE MACHINE · checked first contains "-----BEGIN" && "PRIVATE KEY-----" → in_pem = true, line kept verbatim while in_pem, any other line → replaced entirely with the mask "-----END … PRIVATE KEY-----" → kept, in_pem off redact_line() · PASS 1 — ASSIGNMENTS for (idx, ch) in line.char_indices() EVERY '=' and ':' is examined, not just #1 key = line[..idx].to_ascii_lowercase() must contain one of 11 SECRET_KEY_HINTS value_looks_secret(&rest[..val_end]) len >= 8 after stripping quotes — or a prefix masks ONLY the value; the tail is rescanned redact_tokens() · PASS 2 — STANDALONE split_inclusive(char::is_whitespace) whitespace re-attached, so spacing survives looks_like_secret_token(tok) len >= 12 AND starts with one of 22 prefixes SECRET_KEY_HINTS · 11 api_key · apikey · secret · token password · passwd · private_key access_key · client_secret · auth · credential SECRET_TOKEN_PREFIXES · 22 sk- · sk_live_ · sk_test_ · rk_live_ · pk_live_ ghp_ · gho_ · ghs_ · github_pat_ · glpat- xoxb- · xoxp- · xapp- AKIA · ASIA · AIza · ya29. npm_ · eyJ · hf_ · dop_v1_ · shpat_ eyJ catches a bare JWT; ya29. a Google OAuth token; AKIA/ASIA AWS keys. OUTPUT [default] aws_access_key_id = ***REDACTED*** tokens: 42 used out of 100 -----BEGIN OPENSSH PRIVATE KEY----- ***REDACTED*** WHAT SURVIVES ON PURPOSE the [default] section header — structure the key NAME aws_access_key_id — existence the BEGIN/END markers — the shape of the file "tokens: 42 used" — a benign hint word with a short value is left completely untouched STILL VISIBLE TO THE MODEL that a credential exists, where it lives, and what kind it is — enough to reason, not to use. no separator matched → fall through to pass 2
Redaction scanner. Two of the branches carry the codenames of the bugs that produced them. C-2: scanning only the first separator missed //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.
src/tools/redact.rs115–121the C-3 fix, in situ
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_reason

Those 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.

Where the layers sit relative to one another

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 SandboxLevel and SandboxPolicy, with the constructors that encode the attended/unattended distinction.
sandbox/macos.rs
SBPL generation, the hardcoded sandbox-exec path, 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.