Novo: AIBOM: uma lista de materiais em tempo real de cada agente, ferramenta e conector. Veja como
MITRITY
Docs / Gateway, Sidecar e Mesh / Governed Shell

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.

ArgumentRequiredConstraint
commandyesAt most 64 KiB, no NUL bytes.
cwdnoAbsolute, 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_secondsno1 up to the effective ceiling (the policy's exec_timeout_seconds, capped by exec.limits.max_timeout_seconds). A larger value is clamped down.
stdinnoBytes 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.
envnoAt 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.

KnobValuesDefaultGoverns
sandbox_requiredtrue / falsetrueWhat 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_seconds1–3600120Wall-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_routingframework / governed_shellframeworkWhere a framework built-in execution tool admitted through the hook actually runs — see Routing Claude Code's Bash.
workspace_rootsup 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:

ModeMeaning
bubblewrapLinux: user, pid, network, IPC and UTS namespaces, read-only root, writable workspace roots only, seccomp installed. Full strength.
bubblewrap_no_seccompLinux: as above without the seccomp stage (only reachable under exec.sandbox.seccomp: preferred). Host Unix sockets are reachable from inside. Degraded; notifies.
seatbeltmacOS: sandbox-exec with a generated profile. Full strength for the platform.
noneThe command ran with no kernel isolation (sandbox_required: false and no backend). Degraded; notifies.
unavailable_deniedNo 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.com matches any subdomain and example.com itself; anything else is exact.
  • An entry may carry a :port suffix. Without one it matches exec.egress.allowed_ports (default 443 and 80).
  • 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:

  1. Operator-mapped environment variablesexec.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.
  2. Operator-mapped filesexec.credentials.files, materialised for each execution under $MITRITY_CRED_DIR with mode 0400, for tools that read key files (gcloud --key-file, kubectl --token-file). The directory lives only for the execution.
  3. Agent references${credential:<id>} in command or in an env value. In command it 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 commandcurl -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:

  1. 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.
  2. The hook returns allow with the command rewritten to mitrity-hook exec <ticket>. Only command is 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.
  3. 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 — 124 on timeout, 126 when the execution could not start, 137 when 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 path on Linux, path rewritten on 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 false is 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 … and node -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 calls setsid survives — 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 a sandbox-runtime settings 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.