Note
危险命令防线:黑名单如何检查 Tool Call
How PseudoClaude normalizes run_command arguments and hard-denies known destructive patterns before configurable rules and permission modes.
run_command 是 Coding Agent 中风险最高的基础 Tool。模型可以用它运行测试,也可能请求删除目录、格式化设备或制造 fork bomb。把这些调用全部交给 Permission Mode 并不够:用户切到 Bypass Mode 后,未命中规则的命令会自动允许。
PseudoClaude 在可配置策略之前放了一层不可配置的危险命令黑名单。它能保证已经识别的模式不会被显式 Allow 或 Bypass 覆盖;但它仍是正则启发式检查,不是 Shell 解析器或操作系统级隔离。
先用最简单的话说:黑名单不是告诉模型“不要这样做”,而是在命令真正执行前把 Tool Call 拦下来。
这条链路里有四个角色:
Provider
只负责返回 Tool Call,例如 run_command + command/args。
Agent Tool Runner
负责接住 Tool Call,并在执行前交给权限层检查。
Permission Engine
负责把 command/args 还原成稳定命令串,然后先查黑名单。
Registry / run_command
只有前面都允许时,才会真正调用 exec.CommandContext。
所以危险命令防线的关键不是“模型会不会听话”,而是:Tool Call 到达真实工具之前,必须先过一层硬检查。
run_command 接收结构化参数
模型不是提交一整段默认交给 Shell 的脚本,而是分别给出可执行文件和参数数组:
func (runCommandTool) Definition() Definition {
return Definition{
Name: "run_command",
Description: "Run a local command in the current workspace and return stdout, stderr, and exit status. Prefer read_file, find_files, and search_code for reading, locating, or searching files; use commands for build, test, validation, or shell-only operations.",
// run_command 可能改文件、启动进程或访问系统资源,因此默认按有副作用工具处理。
Safety: SafetySideEffect,
// command 和 args 分开传入;执行时不会默认拼成 shell 脚本。
InputSchema: objectSchema(map[string]any{
"command": stringProp("Executable command name or path."),
"args": map[string]any{
"type": "array",
"description": "Optional command arguments.",
"items": map[string]any{"type": "string"},
},
}, "command"),
}
}
执行时使用 exec.CommandContext,参数不会默认再经过 Shell 展开:
// 到这里说明 Agent 层权限检查已经放行;run_command 才真正启动本地进程。
// command 和 args 直接交给 os/exec,不会默认经过 shell 展开。
cmd := exec.CommandContext(ctx, args.Command, args.Args...)
cmd.Dir = env.CWD
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
因此 command="echo"、args=["$HOME"] 不会自动展开环境变量,分号和管道也只是普通参数。模型仍可显式调用 sh -c,此时 Shell 语义由被启动的 sh 提供,而不是 run_command 自己提供。
权限层先重建稳定命令串
规则、审批摘要和黑名单需要同一种目标表示。commandText 解析 ToolCall,并对包含空白、引号或反斜杠的参数使用 Go Quote:
func commandText(call llm.ToolCall) (string, bool) {
if call.Name != "run_command" {
return "", false
}
var args struct {
Command string `json:"command"`
Args []string `json:"args"`
}
// ToolCall 参数必须先能解析成 command/args,后续规则和黑名单才有可靠目标。
if err := json.Unmarshal(call.Arguments, &args); err != nil {
return "", false
}
// command 为空表示不知道要执行什么;权限层会把它当成不可解释调用拒绝。
args.Command = strings.TrimSpace(args.Command)
if args.Command == "" {
return "", false
}
parts := []string{args.Command}
for _, arg := range args.Args {
// 带空白或引号的参数用 Go Quote 变成稳定展示文本,避免规则匹配时歧义。
if strings.ContainsAny(arg, " \t\n\"'\\") {
parts = append(parts, strconv.Quote(arg))
} else {
parts = append(parts, arg)
}
}
return strings.Join(parts, " "), true
}
例如 {"command":"git","args":["status","a b"]} 会变成 git status "a b"。这不是重新生成可执行 Shell 脚本,而是用于匹配和展示的规范化字符串。
参数 JSON 无法解析或 command 为空时,Permission Engine 直接 Deny。因为系统无法确认实际目标,此时不会降级成 Ask,让用户为一段不可解释输入授权。
黑名单位于所有可配置策略之前
Engine 在 Exec 分类分支中先调用 commandText,再检查 blacklist:
if category == CategoryExec {
// Exec 规则匹配的是规范化命令文本,无法解析参数时直接拒绝。
// 这里发生在 Registry.Execute 之前,因此命令还没有机会进入 os/exec。
command, ok := commandText(call)
if !ok {
return CheckResult{
Decision: DecisionDeny,
Source: "unknown",
Reason: "command arguments could not be parsed",
Category: category,
CWD: root,
}
}
target = command
matchTarget = command
// 黑名单先于用户规则与 Permission Mode,显式 allow 和 bypass 都不能覆盖。
// 命中后直接返回 Deny,后面的 session/local/project/user 规则不会再被查询。
if ok, pattern := hitsBlacklist(command); ok {
return CheckResult{
Decision: DecisionDeny,
Source: "blacklist",
Reason: "command matches dangerous blacklist pattern",
Rule: pattern,
Category: category,
Target: target,
CWD: root,
}
}
}
只有没有命中黑名单,调用才会继续查 Session、Local、Project、User 规则和 Permission Mode。这意味着 Bash(rm *) Allow 规则或 bypassPermissions 都没有机会覆盖已经命中的结果。
当前保护哪些命令
黑名单由八个编译期正则组成,主要覆盖:
var dangerousCommandPatterns = []*regexp.Regexp{
// 递归强制删除根目录或用户主目录,是最典型的不可恢复破坏。
regexp.MustCompile(`(?i)(^|\s)rm\s+-(?:[^\s]*r[^\s]*f|[^\s]*f[^\s]*r)[^\n]*(?:\s|=)(/|~)(?:\s|$)`),
// 关键系统目录即使不是根目录,也应在配置规则之前被硬拒绝。
regexp.MustCompile(`(?i)(^|\s)rm\s+-(?:[^\s]*r[^\s]*f|[^\s]*f[^\s]*r)[^\n]*(?:/bin|/boot|/dev|/etc|/home|/lib|/private|/sbin|/usr|/var)(?:\s|$)`),
// 直接写块设备或格式化设备,可能破坏磁盘和文件系统。
regexp.MustCompile(`(?i)(^|\s)dd\s+[^\n]*(?:^|\s)of=/dev/(?:disk|rdisk|sd|hd|vd|nvme|mapper/)`),
regexp.MustCompile(`(?i)(^|\s)(mkfs|mke2fs|newfs|diskutil\s+eraseDisk|format)\b[^\n]*(?:/dev/|[A-Z]:)`),
// Shell fork bomb 会快速耗尽进程资源。
regexp.MustCompile(`:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:`),
regexp.MustCompile(`(?i)>\s*/dev/(?:disk|rdisk|sd|hd|vd|nvme)`),
// 对根目录递归放开权限或改所有者,影响范围过大,不能靠一次 Allow 放行。
regexp.MustCompile(`(?i)(^|\s)chmod\s+-R\s+777\s+/(?:\s|$)`),
regexp.MustCompile(`(?i)(^|\s)chown\s+-R\s+[^;\n]+\s+/(?:\s|$)`),
}
它针对的是系统根目录、关键系统目录、块设备、文件系统格式化、fork bomb,以及递归修改根目录权限或所有者。rm -rf ./build 和普通镜像写入 dd if=input of=output.img 不命中,让正常构建清理和文件操作继续进入规则或审批。
hitsBlacklist 返回第一个命中表达式,CheckResult 会把它放入 Rule 字段,工具失败结果和日志因此能解释拒绝原因:
func hitsBlacklist(command string) (bool, string) {
normalized := strings.TrimSpace(command)
if normalized == "" {
return false, ""
}
for _, pattern := range dangerousCommandPatterns {
if pattern.MatchString(normalized) {
return true, pattern.String()
}
}
return false, ""
}
拒绝仍然是 ReAct Observation
黑名单命中不会终止整个 Agent 进程。Agent 将 CheckResult 转成 permission_denied Tool Result,写回 Conversation;模型可以看到失败原因并选择更安全的动作。测试中的 Provider 第一轮强行请求 rm -rf /,工具没有执行,第二轮仍能返回 safer plan 并正常结束。
真正阻断真实执行的是 Agent 层的这一小段:
switch check.Decision {
case permission.DecisionAllow:
return executeAllowedTool(ctx, registry, env, call, opts)
case permission.DecisionDeny:
// 黑名单、沙箱或规则拒绝都会停在这里;真实工具不会进入 Registry.Execute。
return permissionDeniedResult(call, check)
case permission.DecisionAsk:
// 省略交互审批分支
}
这里返回的是工具失败结果,而不是进程崩溃。对模型来说,它仍然是一条 Observation;对系统来说,危险命令没有越过执行边界。
这比直接关闭进程更适合 ReAct:危险动作被硬拒绝,但任务可以通过其他工具继续完成。
黑名单不是完整命令安全模型
当前实现需要明确以下边界:
- 正则匹配的是重建后的显示串,不会解析 Shell AST、脚本文件、环境变量或被调用程序的二次执行行为。
/bin/rm -rf /的可执行文件包含路径,现有(^|\s)rm模式不会按 basename 归一。sh -c "rm -rf /"中危险文本位于带引号参数内,现有模式不保证识别包装后的 Shell payload。python -c、解释器脚本、下载后执行、资源耗尽和网络破坏等不在八个模式的穷举范围内。- 文件路径沙箱不应用于
run_command。命令的工作目录设为 Workspace,但进程仍可以使用绝对路径或..访问外部文件。 - 黑名单不可由配置扩展;新增危险模式需要修改源码并增加测试。
所以它应被描述为“对已知高危命令的不可绕过策略层”,这里的不可绕过仅指后续 Permission Rule 和 Mode 不能覆盖一次已经命中的判断,不代表任何等价命令都能被识别。
完整流程
Provider 返回 run_command ToolCall
-> JSON 解析 command + args
-> 参数 Quote 后重建稳定命令串
-> 依次匹配八个危险正则
-> 命中:DecisionDeny(source=blacklist)
-> permission_denied Tool Result 回填模型
-> 未命中:继续分层规则与 Permission Mode
-> Allow 后 exec.CommandContext 直接启动进程
测试验证了什么
Blacklist 测试覆盖 rm -rf /、rm -fr ~、fork bomb、危险 dd、mkfs 和设备重定向,并确认 rm -rf ./build、git status 与普通文件 dd 不误报。Engine 测试验证 Bypass Mode 不能覆盖已命中的 rm -rf /。Command 测试覆盖直接进程执行、退出码、输出截断和 Context 超时。
现有测试没有覆盖绝对 executable、Shell wrapper、解释器包装或命令等价变形。这些属于本篇从实现结构推导出的风险,而不是已经通过测试证明安全的场景。
小结
PseudoClaude 将危险命令检查放在可配置授权之前,并把命令参数先归一为统一目标。这个顺序保证已识别模式不会被 Bypass 或 Allow 覆盖,同时保留拒绝结果进入 ReAct 循环的能力。
下一篇继续分析另一项硬边界:文件 Tool 怎样处理绝对路径、目录穿越、未创建文件和符号链接,确保目标真实位置仍在工作区内。