---
url: 'https://adk.nht.io/batteries/sandbox/run-command.md'
description: >-
  The gated, OS-enforced shell tool, including output ordering, git behaviour,
  and failure signals.
---

# `run_shell_command`

## LLM summary — run\_shell\_command

* [`createRunShellCommandTool`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createRunShellCommandTool) requires a gate and a [`SandboxPolicyEnforcer`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/interfaces/SandboxPolicyEnforcer); it runs the real shell and is the git tool.
* It returns one complete spooled artifact: stdout/stderr are merged in arrival order.
* Non-zero exit, timeout, and observed violations are output signals, not handler errors.
* `cwd` is workspace-relative and `timeout_seconds` defaults to 300.

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

```ts
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: false` and 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/hooks` is denied unconditionally.** Hook injection is the actual attack — a committed
  `pre-commit` runs 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/config` is denied unless you set `allowGitConfig: true`.** That blocks
  `git config --local`, not `git status`.
* **`gitSafeDirectories` is not cosmetic.** Set it or git refuses the repo with `dubious 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](./index.md#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.

```ts
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' } })
```

::: danger `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.

::: danger 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](./index.md#what-the-model-actually-reads-the-prefix-is-not-ours).

## 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:

```ts
const enforcer = await srtEnforcer({ policy, enableWeakerNestedSandbox: true })
```

::: warning 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.
:::
