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'],
})
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. 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.
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.
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.
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. |
| 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. |
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.