run_shell_command
Most "safe shell" tools are a curated vocabulary: a hand-picked list of verbs, a subset of flags someone thought were harmless, and a wrapper that drifts out of sync with the real CLI within a release or two. The model then spends its turns discovering which flags you forgot. This tool takes the opposite approach — it runs the real command through an OS policy and lets the kernel decide what is reachable. That also makes it the git tool: git status, diff, log, add, commit, checkout are ordinary commands with the real binary and its real output, and cwd lets git work in a subdirectory without a cd prefix. What you give up is the comfortable illusion that a name allow-list is a security boundary. It is a name check; the policy is the boundary.
Assemble it
import { createRunShellCommandTool } from '@nhtio/adk/batteries/sandbox'
import type { SandboxPolicy } from '@nhtio/adk/batteries/sandbox'
const policy: SandboxPolicy = {
filesystem: {
allowRead: ['/workspace'],
allowWrite: ['/workspace/out'],
gitSafeDirectories: ['/workspace'],
},
network: { allowedDomains: [] },
}
const tool = createRunShellCommandTool({
sandbox: policyEnforcer,
policy,
translator: workspaceTranslator,
gate: async (_ctx, call) => {
if (!await approvalService.ask(call.tool, call.args))
return // a void verdict is approval; throw or return `{ approved: false }` to deny
},
allowedCommands: ['git', 'pnpm', 'node'],
onCompletion: (completion) => auditRows.insert(completion),
})
const result = await tool.executor({ command: 'git status --short', cwd: '' }, dispatchContext)The final invocation shape is validated by the tool: command is required, cwd defaults to the workspace root, and timeout_seconds defaults to 300 seconds. command is a full shell command line, not an argv word list: printf hello, a | b, quoting, and redirection are parsed by the shell (sh -c), exactly as they would be at a terminal. In application assembly, register tool in the normal tool registry; the executor line above is only a direct-handler illustration for a harness that already has a DispatchContext.
With allowedCommands set (including an empty list), the line must be a single plain command: no operators, pipes, substitution, expansion, redirection, or grouping. The conservative check refuses ;, &, |, backticks, $, <, >, (, ), {, }, carriage returns, newlines, and backslashes even inside quotes. Thus git commit -m "a;b" is refused, as is plain $VAR: all dollar signs are rejected to avoid expansion ambiguity without a shell parser. Ordinary quoted arguments such as git commit -m "a message" work, but the first word must be unquoted and match the list literally. Leading spaces/tabs are accepted; quoted or escaped command names are not. Refusals throw E_SANDBOX_REFUSED before policy resolution or spawning and fire onCompletion once with denied: true. Without allowedCommands, full shell syntax is unchanged.
Per-call policy
policy is a static object OR a function. A function is evaluated once per invocation, after the gate has approved and after cwd has passed the path gauntlet, and its return value is the policy for that one child:
const tool = createRunShellCommandTool({
sandbox: enforcer,
// A revoked grant is gone on the very next call; two concurrent calls each get their own object.
policy: async ({ args, relativeCwd }) => ({
filesystem: { allowWrite: [await resolveGrant(args)], gitSafeDirectories: [relativeCwd || '.'] },
// Network is NOT per-call: any section that DIFFERS from the session's — a new domain, a
// narrowing, an added deny, a different `disabled` — throws
// `E_SANDBOX_NETWORK_GRANT_UNSUPPORTED`. Repeat the session's section here (or construct it
// from the same source) and change network access by establishing a new session.
network: { allowedDomains: baselineDomains },
}),
translator: workspaceTranslator,
gate,
})The callback receives { args, relativeCwd }. args is the validated { command, cwd, timeout_seconds } and relativeCwd is the resolved workspace-relative working directory. There is deliberately no gate decision in the context: a gate approves with void and denies by THROWING, and a denial returns before the policy is ever resolved — so a denied call reaches no callback at all.
If you return a function, return a fresh policy object per call ({ ...base } at minimum). The enforcer stores what it is handed, so a shared mutable object would collapse concurrent runs back into one shared policy.
A per-call filesystem grant REPLACES the baseline under SRT, it does not add to it
Measured against real seatbelt AND real bubblewrap (issue #50): a per-call filesystem.allowWrite is baked into that child's profile in place of the process baseline, so the per-call child cannot reach baseline-only writes unless it repeats them in its own policy. Include the baseline paths you still want; the per-call policy IS the child's complete filesystem policy. A per-call network section different from the session's — wider OR narrower — is REFUSED outright; see per-call policy semantics under SRT.
Structured completion
onCompletion is called exactly once per invocation, on every terminal outcome — a clean exit, a non-zero exit, a timeout, a kill, a gate denial, a failing per-call policy function, and an enforcer throw. It receives a RunShellCommandCompletion:
type RunShellCommandCompletion = {
exitCode: number | null // null when the command never ran or never reported a status
failed: boolean // true for every non-success outcome, including denials and timeouts
timedOut: boolean
killedBy?: string // the REAL signal that killed the child (measured 'SIGKILL' on timeout); absent on a normal exit
denied?: boolean // refused before spawning (gate denial, path refusal, allow-list)
diagnostics: readonly string[] // enforcer denial lines, plus a handler failure's message
artifactRef?: string // the spool-store correlation id behind the returned artifact
}The tool's return type is unchanged — it still resolves to the SpooledArtifact; the completion travels only through the callback. An onCompletion that throws is logged and swallowed: it must not break the tool call, and it must not become an unhandled rejection.
One stream, complete output
The result is one spooled artifact — stdout and stderr drained concurrently and merged in arrival order, no source tags. That is what a terminal shows and what 2>&1 does: a failure is legible because the error sits next to the output that preceded it. The cost is that the two streams are not separable afterwards. Redirect inside the command (2>/tmp/err) when you need them apart and allowedCommands is unset.
Nothing is truncated and nothing is killed for volume. A 50 MB build log arrives whole, because the one line explaining a failure is disproportionately likely to be the line a byte cap would cut, and the model cannot ask for it back. The bytes stream into the spool store as they arrive rather than accumulating in host memory, so complete does not mean buffered.
Status rides in the bytes, and only when there is something to say:
| Situation | What appears in the output |
|---|---|
| Clean exit | nothing — no status line, no Exit code: 0 noise |
| Non-zero exit | a final Exit code: N line |
| Timeout | a final [timed out after Ns] line, in place of the exit code |
| Observed violation | [sandbox] denied: … (observed after), at the point it was observed |
The exit code is a final line rather than a prefix for a reason that is not stylistic: it is not knowable until both pipes have closed, and the bytes are already streaming by then. Every one of the outcomes above returns the artifact. The command ran; its output is the signal; a refusal is a response, not a crash.
Violation reporting is thinner than you want it to be, and on macOS it is currently empty
This battery's headline argument is that a sandboxed agent should read "that host is not on the allowlist" instead of exit 1. Measured against SRT 0.0.71 on macOS 26.4, that argument does not yet hold, and pretending otherwise would get someone owned:
| Command run | What the child saw | What diagnosticsFor() returned |
|---|---|---|
cat a denyRead path | Operation not permitted, exit 1, empty stdout | [] |
curl a non-allowlisted host | exit 56 (connection reset), no stderr | [] |
SandboxManager.getSandboxViolationStore().getTotalCount() was 0 in both cases while isSandboxingEnabled() was true — so this is not a wrong attribution key, and not a commandId mismatch. Enforcement is real and fails closed; the structured explanation simply is not populated on this platform. The Linux path scrapes its own audit source and is expected to differ, which is exactly why the release process requires an observed non-skipped run on both platforms rather than inferring one from the other.
What that means for a deployment:
- The model gets the errno, not the reason.
[sandbox] denied: …lines appear only when the enforcer has something to report. On macOS today it has nothing, so a refused command looks to the model like a broken command — the flailing-against-an-invisible-wall failure this battery exists to prevent. Compensate in the tool description and the gate, not by hoping the diagnostics arrive. - Do not build alerting on it. A returned call is
isError: falseand the artifact may carry no denial line, so "no violation reported" is not evidence that nothing was refused. - This is a beta dependency's internal behaviour, tracked deliberately because the alternative is trusting a README that is already wrong about ripgrep. Re-measure on every SRT bump; a version bump here is a code change, not a dependency change.
Git and network
Git needs no wrapper here — it is just a command, so there is nothing to drift out of sync with a fast-moving CLI. What the policy does to it is narrow:
.git/hooksis denied unconditionally. Hook injection is the actual attack — a committedpre-commitruns on the next commit, outside anything the sandbox can see. No ordinary git operation needs to write a hook, so denying it costs nothing..git/configis denied unless you setallowGitConfig: true. That blocksgit config --local, notgit status.gitSafeDirectoriesis not cosmetic. Set it or git refuses the repo withdubious ownership— a sandboxed child reading a repo it does not own trips that check, and the failure reads as a bug rather than a policy. It defaults to the workspace root.
Network policy is OS policy in Node. In a browser there is no SRT and no equivalent: CORS gates response readability, not request emission, so a mode: 'no-cors' fetch exfiltrates freely. The actual analogue of a domain allowlist there is a CSP connect-src header the embedder must set — see Browser deployment.
A blocked git push is refused — but per the section above, do not expect it to arrive as a violation naming the host. On macOS today the model sees git's own transport error and nothing more, so deniedDomainReasons is a value you supply for the day upstream reports it, not a message you can currently rely on reaching the model.
Two residuals. The wrapper has a TOCTOU window — a path checked and then swapped is checked once, and the OS decides second. And bypass is deliberately not a shell-tool option: no command is safe merely because its name is familiar, and an interpreter turns argv into code. Keep secrets outside the workspace root; denyRead is a boundary, not a hiding place.
The child's environment
A sandboxed child inherits PATH and nothing else. Not HOME, not USER, not TMPDIR, and none of your credentials.
The reason is the same one that makes this tool useful: it runs commands the model chose, and env is a command. Anything the child can read, the model can print into its own context — where no filesystem or network policy reaches it, because the value arrives in the tool result rather than over the wire. A network-deny policy does not help.
PATH is in by default because the searcher spawns rg by bare name, and the PATH a shell synthesises from an empty environment does not include /opt/homebrew/bin or a Nix profile — without it, search_files fails on those hosts and reads as a broken tool rather than a configuration choice. PATH is not a credential.
SRT's own plumbing — the proxy, the CA bundle, git's safe.directory — is injected separately and always survives, so restricting the host side cannot break the network boundary. On macOS, a real srtEnforcer run measured HOST_SECRET_E4 absent from the child while PATH remained present. On Linux, a real bubblewrap 0.10.0 run reproduced the environment-relevant portion of the bwrap spawn shape: the pre-fix host-environment merge leaked HOST_SECRET_E4, while the post-fix allow-list did not. That Linux measurement used bubblewrap directly rather than a complete srtEnforcer run; the macOS measurement was end to end.
const enforcer = await srtEnforcer({
policy,
// REPLACES the default; it does not extend it. Naming CARGO_HOME alone drops PATH.
envAllowList: ['PATH', 'CARGO_HOME'],
})
// Per-call additions, applied last. Readable by the model — configuration, not credentials.
const tool = createRunShellCommandTool({ sandbox: enforcer, policy, translator, gate, env: { CI: '1' } })inheritHostEnv: true hands the model every secret in the process
It exists for deployments that genuinely need ambient configuration, and it does exactly what it says: the child gets the whole host environment, so env returns your API keys to the model. Name what you need in envAllowList instead.
Gates and failure delivery
Every tool takes a required gate, this one included. Omit it and construction throws. No reference gate ships: a default would be adopted unread as "the safe config", carrying an assumption about which commands are boring into a deployment it knows nothing about.
A blanket approval is a decision, not a default
gate: async () => {} is legal and supported. It is also this:
You have just handed the model an unsandboxed shell. That was a choice. Own it.
Not "consider the security implications". The OS policy is now the only thing between a prompt injection and the machine.
A gate is a real suspension — the turn stops until a decider answers, so a headless harness without one hangs indefinitely. Wire a decider, or wire an explicit auto-verdict knowing that is what it is.
Failure delivery inverts the house convention, and the reason is mechanical. A gate denial throws a narrated E_SANDBOX_REFUSED; anything failing before the command runs throws a narrated E_SANDBOX_*. A returned string gets spooled and rendered as a handle, so the model would spend a call querying an artifact to read one line of "the operator declined". A throw arrives inline on every backend — see what the model actually reads.
Troubleshooting
| Symptom | Meaning |
|---|---|
dubious ownership | gitSafeDirectories was not threaded into SRT policy. |
apply-seccomp: write /proc/self/uid_map: Operation not permitted — exit 1, empty stdout, no diagnostics | You are running inside an unprivileged container, where bubblewrap cannot mount a fresh /proc. Set enableWeakerNestedSandbox: true on srtEnforcer — see below. |
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted | The runner permits the user namespace but denies the network namespace's loopback setup. Configure Linux-only bwrapPath and socatPath to point at a wrapper that adjusts bwrap's arguments, or use a runner that permits the required network setup. enableWeakerNestedSandbox is about /proc, not this netlink failure. |
| A command reports a missing variable that exists on the host | Expected: the child inherits only PATH. Name what it needs in envAllowList, remembering that it REPLACES the default. |
| Artifact handle instead of text | All adapters use inline: false for these spooled results. Query it with artifact tools, including artifact_grep. |
| Non-zero exit | The command ran; inspect the final exit line and output. It is not a handler exception. |
| Hung turn | No gate decider answered a real suspension. |
"[object Object]" | An unsupported handler result was passed to an adapter; use the declared result shapes. |
command not found for something that is obviously installed | Pre-fix builds quoted the whole command line as one word. Current builds run the line under sh -c; if you still see this on a modern version, check allowedCommands and the PATH allow-list. |
Running inside a container
The failure above is the one worth naming precisely, because it looks like nothing. There is no diagnostic, no violation, no thrown error — just a command that exits 1 having printed nothing, which is indistinguishable from a policy denial until you read the stderr line.
enableWeakerNestedSandbox: true makes the inner sandbox bind-mount the container's existing/proc instead of mounting a fresh one:
const enforcer = await srtEnforcer({ policy, enableWeakerNestedSandbox: true })It weakens the boundary, in upstream's own words
The bind-mounted /proc exposes process information a fresh mount would hide. Enable it only when the outer container already provides the isolation you need — it trades inner isolation for the sandbox running at all. If the outer container is not a boundary you trust, this is not the fix you want.
When bwrap's network setup is forbidden
On Linux, SRT emits --unshare-net for the ordinary restricted-network configuration. With no proxy sockets available, that gives the child no network at all; on a runner that denies the netlink operation needed to configure loopback, you pay for a hard failure before getting even that result. The failure is not fixed by enableWeakerNestedSandbox: that option changes /proc mounting, while this failure occurs during network-namespace setup.
srtEnforcer exposes bwrapPath and socatPath as Linux-only options. They are intended for a wrapper-script deployment: point bwrapPath at an absolute wrapper path, let the wrapper adjust the bwrap arguments as required by the runner, and use socatPath for the corresponding SRT socket binary. The options are passed to SRT's session initialization, so a nonexistent absolute path is caught by SRT during initialize() on Linux. On macOS, the options are neither validated by SRT nor used; ADK still validates that any supplied value is an absolute, NUL-free path, but the paths are otherwise ignored there.
This passthrough does not change the network policy mapping: network.disabled remains mapped as it was before. The wrapper is an escape hatch for the platform's bwrap invocation, not a declaration that the child has unrestricted network access.