背景
Bash 是本地 Coding Agent 里最容易“从帮忙变成闯祸”的工具。
读文件最多是把上下文撑大,写文件最多是改坏项目;但 Bash 可以删除文件、改权限、杀进程、联网、执行二级脚本,甚至用一行看似普通的命令把风险藏起来。所以我不想把 Bash 当成普通工具处理,它必须先过一层风险判断,再决定是否需要用户确认。
这里的目标不是做一个完美 shell sandbox。第一版只解决一个更小的问题:明显危险的命令不能静默执行 。
官方做法
我现在看 Claude Code / Codex 的 Bash 安全,不能只看某一个工具函数。更好的读法是分三层:
第一层是 agentic loop 。ShareAI 的 Agent 循环教程讲得很直白:模型先决定要用工具,程序真的执行工具,再把结果喂回模型,继续下一轮。Bash 的风险正是在这一轮一轮循环里放大的。一次命令不是孤立事件,它会影响下一轮模型看到的世界。
第二层是 terminal-native 架构 。Agent Aura 的 Claude Code 架构文档把 Claude Code 定义成运行在本地终端里的 agentic coding system。它的优势是能直接读项目、改文件、跑命令;代价也很明确:既然拥有完整 shell 能力,就必须有对应的权限和安全机制。
第三层是 源码级工具系统 。轩辕的 Claude Code 源码专题把工具、权限、上下文、子 Agent、MCP、LSP 拆成一组模块来看。这个视角对我很有用:Bash 不是“工具列表里的一个按钮”,而是会和权限规则、hook、审计、上下文压缩、任务编排互相牵连的核心能力。
所以 Claude Code 的思路更完整,它不是只在 Bash 工具里写一个 isDangerous()。
它有几层控制面:
permission rules :settings 里可以配置 allow / ask / deny,例如允许 Bash(git diff:*),拒绝 Bash(curl:*) 或读取敏感文件。permission mode :默认、计划、接受编辑、绕过权限等模式会改变工具执行策略。PreToolUse hook :工具执行前可以运行 hook。对于 Bash,hook 可以返回 allow / deny / ask,甚至把拒绝原因反馈给模型。PostToolUse hook :工具执行后可以做验证,比如格式化、跑检查、把失败信息喂回模型。MCP / plugin 工具名统一进入权限匹配 :权限系统不只面对内置 Bash,也面对外部工具。
Codex 的方向则更强调 approval mode + sandbox 。也就是说,Bash 能不能跑,不只看命令文本,还要看当前模式、沙箱是否允许访问相关目录、用户是否愿意让 agent 自动执行。
所以成熟实现的核心思想是:Bash 安全不是一个函数,而是一条权限管线 。
我是怎么做的
我目前做的是这条权限管线里最前面的静态判定层。
第一步,把命令拆成 token,拿第一个 token 当主命令:
const tokens = command.trim().split(/\\s+/).filter(Boolean)
const baseCmd = tokens[0].split('/').pop()
这一步虽然粗糙,但能挡住一类常见写法:rm、/bin/rm、sudo、chmod 都会归一到命令名本身。
第二步,维护一组高风险二进制:
const dangerous = [
'rm', 'mv', 'sudo', 'chmod', 'chown',
'dd', 'mkfs', 'kill', 'pkill', 'truncate',
]
这些命令不一定永远危险,比如 mv a b 很常见;但从第一版实现角度看,我宁愿多问一次,也不想让模型静默执行破坏性操作。
第三步,识别几类“藏起来的危险”:
if (baseCmd === 'bash' || baseCmd === 'sh') && tokens.includes('-c') => dangerous
if (baseCmd === 'eval' || baseCmd === 'exec') => dangerous
if (command.includes('$(') || command.includes('`')) => dangerous
if (command has single '>') => dangerous
if (command contains ';' or '|') => split and re-check subcommands
这些规则的重点是防止模型把真实操作包进二级解释器、命令替换、管道或重定向里。比如:
bash -c "rm -rf dist"
echo "$(cat ~/.ssh/id_rsa)"
grep foo file | xargs rm
echo bad > src/index.ts
第四步,危险命令不直接执行,而是走 confirmFn:
if (isDangerous(command)) {
const result = await confirm(command)
if (result === 'no') return `已取消执行: ${command}`
if (result === 'always') sessionAlwaysAllow = true
}
这里我把确认函数放在 ToolContext 里,而不是写死在 Bash 工具中。这样 REPL 可以弹交互确认,单次模式可以直接拒绝,测试也可以注入假的确认结果。
最后,无论危险与否,真正执行时都加了超时和输出截断:
const { stdout, stderr } = await execAsync(command, {
timeout: TOOLS.BASH_TIMEOUT_MS,
})
return truncateOutput(stdout + stderr)
这不是安全系统的全部,但它能防止另一个常见问题:命令跑太久或输出太多,把终端和上下文一起拖死。
当前实现的伪代码
把现在的实现压缩成伪代码,大概是这样:
function runBash(command, context) {
if (!command) return error('missing command')
if (isDangerous(command)) {
decision = context.confirm(command)
if (decision === 'no') {
return cancelled(command)
}
if (decision === 'always') {
skipFutureDangerPromptsInThisSession()
}
}
try {
result = exec(command, { timeout })
return truncate(result.stdout + result.stderr)
} catch (error) {
return normalizeExecError(error)
}
}
现在还差什么
现在这套规则能挡住一批明显风险,但它和 Claude Code / Codex 的完整权限体系还有很大距离。
第一,命令解析还不是 shell AST。 现在主要靠空格切 token 和少量正则,遇到引号、子 shell、复杂管道、xargs、find -exec、变量展开时都可能判断不准。Claude Code 的权限限制文档也提醒 Bash pattern 有局限,复杂命令需要更强的 hook 或策略检查。
第二,还没有 allow / ask / deny 配置。 现在只有“危险就问”。成熟体验应该允许用户配置:git status 永远允许,git push 总是询问,curl 或访问 .env 直接拒绝。Claude Code 的 settings 里就是这套思路。
第三,没有 sandbox。 确认只是人机交互,不是隔离。用户点了允许以后,命令仍然在真实环境里跑。Codex 的 approval mode 会和 sandbox 一起使用,这是我现在缺的关键层。
第四,没有 PreToolUse / PostToolUse hook。 现在风险判断写死在 BashTool 里。更好的做法是:BashTool 只声明“我要执行什么”,权限系统和 hook 决定能不能执行,执行后再由 PostToolUse 做验证或审计。
第五,没有审计日志和风险解释。 我现在只记录了 dangerous command detected,还没有把“为什么危险”结构化保存下来。更好的 UI 应该能展示:命中了哪个规则、为什么需要确认、这次确认是否只对当前命令有效。
所以这章的下一步不是再加几个正则,而是把 Bash 从“工具内部自检”升级成“权限管线的一环”:规则配置、hook、sandbox、审计日志要一起接上。