Governed Shell
Everything the command-analysis pipeline produces — the parsed command tree, the inspected scripts, the write-then-run links — is a decision about bytes that something else then executes: Claude Code's Bash, an upstream MCP tool that shells out. Between the decision and the execution the bytes can change, the environment can be dumped, and the network is whatever the host has.
The governed shell is the execution path on which none of that is true, by construction rather than by detection. Mitrity Gateway gains a tool source, exec, that executes shell commands itself:
- the command line and every inspected script run as the exact bytes the decision was made on — a script swapped after the decision changes nothing;
- a credential reaches the command as an environment variable or a private file, never as an argument, and a command that would put one on a child's command line is refused;
- the command runs inside a kernel sandbox — the host read-only, only the policy's workspace roots writable, no network except a local proxy that enforces the agent's destination allowlist — and the isolation actually in force is stamped on the audit event, with any degradation notified;
- what the command printed comes back through DLP, with every injected credential value redacted, and the control plane learns a hash and a size, never the output.
This is the path that closes the time-of-check/time-of-use window named on the coverage page.
What the agent sees
The agent calls bash ./deploy.sh. The gateway parses the command, reads deploy.sh under the policy's script-inspection roots, merges its commands into the tree, and decides. On allow, it executes that command line inside bubblewrap (or Seatbelt on macOS) with the host read-only, the workspace writable, and no network except a local proxy limited to the agent's allowlisted destinations — and deploy.sh inside the sandbox is the bytes that were inspected. GITHUB_TOKEN is in the environment because the operator mapped it; it is on no command line, and if the script prints it, the agent sees [REDACTED:credential:github_token].
The audit event shows the resolved commands, sandbox_mode: bubblewrap, the pinned script's SHA-256, the exit code, the hash and size of the redacted output, and the hosts the script tried to reach and was refused.
Enabling it
The governed shell is configured by one exec: block in mitrity-gateway.yaml, validated at boot exactly as upstreams are: an invalid block refuses to start, and mitrity-gateway doctor names the field.
exec:
enabled: true # default false — shell__execute is absent from tools/list when false
shell: /bin/bash # absolute; the basename must be bash or sh
default_cwd: /workspace # the cwd when a call names none, and the single writable
# root when the policy's workspace_roots is empty
sandbox:
backend: auto # auto | bubblewrap | seatbelt | none
seccomp: required # Linux: required | preferred | off
credentials:
env:
GITHUB_TOKEN: "${credential:github_token}"
Every key, its bounds and its default are in the Configuration Reference. Three things to know before you turn it on:
The shell must be bash or sh. The parser and the shell must agree on what the bytes mean, so zsh, fish and pwsh are refused at boot: there is no grammar for them, and running text through a shell the parser did not model would break the judged-bytes guarantee silently. The dialect used (bash or sh) is recorded on every event. The shell is invoked with --noprofile --norc and a constructed environment — no rc file, no BASH_ENV, no aliases or functions from the host user can redefine rm under the parser's feet.
The default image cannot serve it. A sandboxed command needs a shell interpreter and its tools on the host root the sandbox binds. The distroless gateway image has none, so exec.enabled: true refuses to start there with shell not found. A shell-bearing image variant of the gateway is planned but not yet published; until it is, run the gateway binary on a host, or in an image of your own, that provides bash or sh, bubblewrap on Linux, and the tools your commands need. Laptops use the host's own shell.
Check the host with doctor. mitrity-gateway doctor needs no control-plane connection, so it checks the host, not the policy: it reports the shell, the default working directory (exec.default_cwd), the selected sandbox backend and why — including the remedy when the Linux probe fails (the kernel.apparmor_restrict_unprivileged_userns sysctl on Ubuntu 24.04, user.max_user_namespaces, CAP_SETFCAP for a root caller), whether the seccomp stage installs, and whether cgroups are delegated so memory and process limits can actually be enforced. Workspace roots come from the mission profile at run time and are not part of the host check. It never executes an agent command and never prints a credential value.
The shell__execute tool
When enabled, one tool appears in tools/list: shell__execute. A tool name must be callable from a model API (letters, digits, _ and - only), so the tool is not served under its action type. Its action type is shell:execute — not mcp:shell__execute — so the built-in Shell catalog entry, and every tool permission, rule and audit row written against shell:*, apply without a second vocabulary. The served name and the action type are a fixed pair; neither is configurable. The operation is execute.
| Argument | Required | Constraint |
|---|---|---|
command | yes | At most 64 KiB, no NUL bytes. |
cwd | no | Absolute, or relative to exec.default_cwd. After symlink resolution it must be a directory inside a workspace root; otherwise the call is denied cwd_outside_workspace. |
timeout_seconds | no | 1 up to the effective ceiling (the policy's exec_timeout_seconds, capped by exec.limits.max_timeout_seconds). A larger value is clamped down. |
stdin | no | Bytes for the process, at most exec.limits.stdin_max_bytes (default 1 MiB). Hashed, DLP- and injection-scanned; when the command is a shell reading stdin, parsed and merged into the tree like a script. |
env | no | At most 32 keys, each listed in exec.env.agent_settable (default: none). Values may carry ${credential:<id>}. |
The result is the redacted, truncated stdout (and stderr, when non-empty) plus {exit_code, duration_ms, timeout_seconds, timed_out, truncated, sandbox_mode, redaction_count}. A non-zero exit code is a result, not an error; the call is an error only when it was denied or could not start.
The call goes through the full pipeline in the same order as every other action — tool permission, injection, threat intel, command analysis, policy rules, hold, DLP on arguments, credential brokering, ML — and only then reaches the executor. Two things are specific to this source: command analysis is mandatory (a policy with command_analysis_mode: off cannot use the governed shell; the call is denied command_analysis_off), and the executor has hard denials no knob relaxes: sandbox_unavailable under sandbox_required: true, modified_outside_governance on a pin mismatch, credential_in_argv, cwd_outside_workspace.
Policy knobs
Four knobs on the policy govern the shell. They sit beside the command-analysis settings on the policy screens (Policies → New / Edit, under Governed shell) and are validated on save — the dashboard flags a bad value before submit and renders the API's field errors inline.
| Knob | Values | Default | Governs |
|---|---|---|---|
sandbox_required | true / false | true | What happens when no sandbox backend is available on the host. true denies the command (sandbox_unavailable, sandbox_mode: unavailable_denied); false runs it with no kernel isolation (sandbox_mode: none) and notifies. A profile without the field is read as true. |
exec_timeout_seconds | 1–3600 | 120 | Wall-clock ceiling for one execution. A call may ask for less, never more; the edge's exec.limits.max_timeout_seconds may cap it lower. On expiry the sandbox receives SIGTERM, then SIGKILL five seconds later, and the partial output is returned with a truncation marker. |
builtin_exec_routing | framework / governed_shell | framework | Where a framework built-in execution tool admitted through the hook actually runs — see Routing Claude Code's Bash. |
workspace_roots | up to 32 absolute paths | [] | The only host paths writable inside the sandbox, and the set the call's cwd must resolve inside. Empty means the single directory exec.default_cwd. |
workspace_roots is deliberately separate from script_inspection_roots: the inspection roots say where the edge may read scripts at decision time, and widening one must not widen the other. A / inspection root is a reasonable setting on a single-purpose host; a / workspace root would make the host writable.
Sandbox modes
exec.sandbox.backend: auto probes at boot and re-checks per call: on Linux, bwrap ≥ 0.5.0 with unprivileged user namespaces and the seccomp stage; on macOS, /usr/bin/sandbox-exec. The isolation actually in force is stamped on every event the shell produces as sandbox_mode, a closed vocabulary:
| Mode | Meaning |
|---|---|
bubblewrap | Linux: user, pid, network, IPC and UTS namespaces, read-only root, writable workspace roots only, seccomp installed. Full strength. |
bubblewrap_no_seccomp | Linux: as above without the seccomp stage (only reachable under exec.sandbox.seccomp: preferred). Host Unix sockets are reachable from inside. Degraded; notifies. |
seatbelt | macOS: sandbox-exec with a generated profile. Full strength for the platform. |
none | The command ran with no kernel isolation (sandbox_required: false and no backend). Degraded; notifies. |
unavailable_denied | No backend and sandbox_required: true: the call was denied and nothing ran. Notifies. |
Events for executions the edge did not run itself — a hooked Bash the framework executed, an upstream MCP tool — carry no sandbox_mode at all. Absent means "not ours to vouch for", never "none".
Degradation alerts
The Sandbox degraded or unavailable notification (execution.sandbox_degraded) fires whenever the shell stamps none, bubblewrap_no_seccomp or unavailable_denied, once per agent, mode and reason per hour. It says which backend was requested, the host's reason (for example unprivileged user namespaces are disabled), whether the command ran, and what the policy required.
When nothing ran, the alert leads with denied: an agent that cannot execute anything is an outage the operator has to hear about, and until the host's sandbox prerequisites are fixed every governed-shell command from that agent is refused. When the command ran unsandboxed, the alert says what the run did not have — filesystem containment and egress control: the proxy variables are still set and the proxy still enforces the allowlist, but nothing stops a process from ignoring them. Everything else still holds without a kernel sandbox: pinned scripts, the constructed environment, output redaction, timeouts, output caps.
The notification is on by default for the bell and Slack, and deep-links to the audit event.
The egress allowlist
Inside the sandbox there is no route to anything: on Linux the network namespace has only loopback; on macOS outbound connections are allowed only to the gateway's two proxy ports. There is no resolver inside, so DNS happens only in the proxy, on the host, and only for names the proxy has already allowed. A process that unsets HTTP_PROXY or passes --noproxy has nothing to bypass to.
The allowlist is the agent's destination allowlist — the same list DLP checks attested tool destinations against (Agents → Destinations). It reaches the edge on every profile as exec_egress_allowlist, whatever the policy's DLP mode is: egress control for a shell must not switch off with DLP.
*matches everything;*.example.commatches any subdomain andexample.comitself; anything else is exact.- An entry may carry a
:portsuffix. Without one it matchesexec.egress.allowed_ports(default443and80). - An empty allowlist is no egress. DLP treats an empty list as "no restriction"; the shell does not — a shell with unrestricted egress is the thing this feature exists to prevent. A tenant that wants everything adds
*, visibly. - Allowed names are resolved once on the host and the address actually dialed is the one that passed. A name resolving to loopback, link-local, multicast, the cloud instance-metadata endpoints, any address on the host's own interfaces, or an operator-denied CIDR is refused. An IP literal is allowed only when that literal is itself an entry.
Every refused connection is counted on the audit event (egress.denied_hosts, first 16 distinct host:port) and produces a DLP event with unauthorized_destination, so the dashboard's destination views need no new concept. Bodies are not inspected and TLS is not terminated: the accurate description of this proxy is host:port allowlist with attribution.
Credentials
The command never receives a credential on a command line. A credential reaches the sandbox in one of three ways, all environment or file:
- Operator-mapped environment variables —
exec.credentials.env(GITHUB_TOKEN: "${credential:github_token}"). Injected into every execution by an agent that holds the grant, so tools that read their token from the environment (gh,aws,PGPASSWORD) need nothing else. - Operator-mapped files —
exec.credentials.files, materialised for each execution under$MITRITY_CRED_DIRwith mode0400, for tools that read key files (gcloud --key-file,kubectl --token-file). The directory lives only for the execution. - Agent references —
${credential:<id>}incommandor in anenvvalue. Incommandit is rewritten, before parsing and before the decision, to${MITRITY_CRED_<ID>}; the parser sees an ordinary variable reference, the audit trail names the credential, and the value is never in the judged text.
Whether the command then hands that variable to a child process's argument list is caught: a reference to a credential-bearing variable in an argument word of an external command — curl -H "Bearer $GITHUB_TOKEN" — is denied with credential_in_argv, because the value would land in that process's command line, readable by every process of the same user on the host. Assignments (X=$T cmd), redirections, here-strings and shell builtins are fine; the denial names the sanctioned forms.
The sandbox never inherits the gateway's own environment, and the host's ambient credential files (~/.ssh, ~/.aws, ~/.config/gcloud, ~/.kube, ~/.netrc, ~/.claude and the rest) are in a mandatory deny-read set. A credential the agent uses is a credential the operator granted through MITRITY, or it is nothing.
Output. Every injected value — and its base64, URL-encoded and hex forms — is redacted from stdout and stderr before they return to the agent, then the agent's DLP bindings run over each stream as they do on LLM responses. When a credential value was redacted, the Credential redacted from command output notification (execution.output_redacted) fires, once per agent and credential per hour: an env dump, a cat of a key file or a script that echoes its token is a high-signal event that should not be buried in a row. Ordinary DLP-pattern redactions do not notify; they are rows, as everywhere else.
Routing Claude Code's Bash through the governed shell
With builtin_exec_routing: governed_shell on the policy, a Claude Code (or Agent SDK) Bash call admitted through the hook is not executed by the framework at all:
- The hook sends the call to the admission API as today. The edge evaluates it as
shell:execute— so the Shell catalog entry, tool permissions and every rule written for the governed shell apply — and, on allow, fixes the execution plan (pinned scripts included) and issues a single-use execution ticket. - The hook returns
allowwith the command rewritten tomitrity-hook exec <ticket>. Onlycommandis rewritten; the call's description, timeout and background flag keep the model's values. A held call behaves as today: an approval yields the same rewrite. - The framework runs the relay. It redeems the ticket at the gateway, streams the redacted output back as it arrives, and exits with the command's exit code —
124on timeout,126when the execution could not start,137when killed. It never runs the original command.
Why a rewrite: a PreToolUse hook can change a tool's input; it cannot change which tool runs, and nothing else in the framework turns a Bash call into a tools/call. Rewriting the command to a relay is the only path that keeps the framework's own tool, transcript and result rendering intact while moving the execution into ours.
Under routing, the only thing Claude Code's own sandbox ever runs is the relay, which needs one thing that sandbox denies by default: a connection to the admission socket. The managed-settings renderer therefore emits sandbox.network.allowUnixSockets naming that one socket path (never allowAllUnixSockets) alongside the unchanged sandbox.enabled: true, allowUnsandboxedCommands: false and failIfUnavailable: true; see the Admission API page for the rendered settings. This widens nothing the model can use: the model's command runs in the governed shell, whose own sandbox denies Unix sockets. The execution-coverage findings read the attested sandbox block as before — routing changes which sandbox matters for Bash, not what is attested.
Routing covers Bash. Write, Edit, WebFetch and the rest are admitted and then performed by the framework as before. A routed command produces two audit events, both shell:execute on the agent_hook surface: the decision at admit time (so a decision is audited even if nothing is ever run) and the execution at redeem time, linked by the decision's event id. A ticket that expires unredeemed writes the second event with the status never ran.
What you see in the audit log
Every event the shell produced carries an execution card under the command analysis on Audit, and the list filters by sandbox mode:
- Status — completed, timed out, killed, failed to start, or never ran — and the machine-readable reason for a non-completed status (
pin_mismatch,concurrency_limit,spawn_error,sandbox_unavailable). - Sandbox mode, with the degradation or denial explained in place.
- Exit code (null unless completed), duration, the effective timeout.
- Output: bytes produced on each stream (including bytes discarded past the cap), whether either was truncated, and the redaction count across both streams.
- The command hash — SHA-256 of the judged text exactly as executed.
- Pinned scripts: each inspected script's path, hash and size, and how it was pinned (
bound over the pathon Linux,path rewrittenon macOS). - Credentials injected: ids only, never values.
- Egress: connections allowed and denied, and the denied destinations.
- Limits enforced: which resource bounds actually held on this host — process count, memory, tmpfs size, daemon kill — so a
falseis a documented platform gap you can see, not a silent one.
The audit decision stays the policy's: an allowed command with exit code 3 is allowed, and a denial before execution (sandbox_unavailable, credential_in_argv, cwd_outside_workspace) is a blocked event with blocked_by: command. Hashes, sizes, names and paths only — the control plane never receives output bytes, script bodies or credential values.
What remains outside isolation
Stated plainly, so the guarantee above is the one you rely on:
- Non-shell script bodies and inline interpreter code. A pinned Python, Node, Perl or Ruby script is the judged bytes, but what it does is not analyzed;
python -c …andnode -e …are flagged, hashed and scanned, not analyzed. The sandbox and output redaction are the walls there — a script that reads a credential from the environment and spawns a subprocess with it is contained, not understood. - macOS. There are no bind mounts: pinned scripts are copies in a private directory with the command line rewritten to them, and scripts referenced from inside a pinned script are re-read from the workspace at run time (
pin_coverage: partial). The copies are re-hashed immediately before execution, but a same-user host process racing that re-hash is the residual risk. Process termination is by process group, so a descendant that callssetsidsurvives — confined, but not killed — and there is no memory limit; the audit event reports both as not enforced. - Windows. The governed shell refuses to start on Windows; there is no sandbox backend. Command analysis, the hook and the audit trail still work — execution is the framework's, and the coverage page's Windows section applies.
- CPU time and total disk are not bounded; the per-file size and the private tmpfs are. Memory and process limits need a delegated cgroup v2 on Linux and are reported as not enforced otherwise.
- Body-inspected egress and TLS termination. The proxy is a host:port allowlist. Data in an allowed request's body is not seen.
- Read-deny by default. The host root is readable minus the mandatory deny set (credential directories, the gateway's own config and token, container secrets). A workspace-only read posture is an operator choice through
exec.sandbox.settings_file, which accepts asandbox-runtimesettings file as an additional, narrowing-only restriction. - Hosts without a shell. The distroless image cannot serve
exec:; use the shell-bearing variant. - Execution that never reaches the gateway — a command run by a tool the hook does not cover, or by a human in the same shell — is governed by nothing here. See Governance Coverage.
Related Documentation
- Governance Coverage — the decision points, the gaps, and the execution-coverage badge
- Configuration Reference — every
exec:key, bound and default - Writing Policies —
shell:*rules,resolved_commands, script inspection and the taint knobs - Credential Broker — the
${credential:<id>}placeholder and the broker the shell injects from - Mesh Egress Allowlist — network-layer egress governance for the mesh