揭示「审核开/关」二元模式的缺陷(导致无谓阻塞或安全漏洞),提出按命令类型分类的细粒度权限控制方案,解决 agent 系统的自动化与安全平衡。
Human in the loop(人在回路)通常被实现成一个开关:要么开启审批,要么关闭审批。对于一个会执行 shell 命令的 Agent 来说,这两种设置都不对。而你最终会像所有人一样发现这一点——眼睁睁看着一个长任务在第三次执行 ls 时中断。
卡住时通常是这样的:你的 Agent 正在处理一项包含多个步骤的任务,它需要查看某个目录,运行框架发起审批请求,整个任务便彻底停在那里,等待一个已经起身去冲咖啡的人回来。把这种情况乘以 40 次工具调用,Agent 就不再具备自主性,而只是一个极其昂贵的交互式 shell。
于是,人们通常会采用下面两种解决办法之一,但这两种办法都会让情况变得更糟。
关闭审批门禁。这样一来,rm -rf 也可以在无人监督的情况下执行。门禁原本并不是问题,而现在,你移除了唯一一道防线——它原本挡在一条看似合理的命令与一次不可逆操作之间。
给工具再套一层别的东西。这是我最常见到的做法:Agent 卡在内置 shell 工具的审批流程上,于是有人让它改走另一个工具层,卡顿就消失了。之所以消失,是因为新工具层根本不会询问。你并没有取消审批,只是改变了应该由谁来发起审批,而现在,这个答案通常变成了:没人负责。
这种中断不是 bug,而是门禁在履行职责。真正的 bug 是,门禁分不清 ls 和 rm。
真正有用的判断维度,不是调用了哪个工具,而是这次调用是否能够撤销。读操作从定义上就是可恢复的,因为最坏的情况也只是读取了一些内容,然后将其丢弃。写入、删除,以及任何会把数据发送到这台机器之外的操作,都不属于这一类。
因此,我们给门禁加上一个分类器,由分类器决定是否需要把人叫醒:
READ_ONLY = {
"ls", "cat", "head", "tail", "wc", "stat", "file", "find", "grep", "rg",
"git status", "git log", "git diff", "git show", "git branch",
}
RISKY_PREFIXES = ("rm", "mv", "dd", "chmod", "chown", "kill", "curl", "wget",
"git push", "git reset", "npm publish", "docker rm")
# Anything that lets one approved command smuggle in another.
SHELL_OPERATORS = ("&&", "||", "|", ";", "`", "$(", ">", ">>", "\n")
def needs_human(command: str) -> bool:
cmd = command.strip()
# A command containing shell operators is not one command, it is several.
# Do not try to reason about the pieces. Ask.
if any(op in cmd for op in SHELL_OPERATORS):
return True
if any(cmd == allowed or cmd.startswith(allowed + " ") for allowed in READ_ONLY):
return False
# Everything not explicitly known-safe requires a human, including
# commands that merely look harmless.
return True
关于这个函数,有两点比那些命令列表本身更重要。
它使用的是 allowlist,而不是 blocklist。采用 blocklist,就等于押注你已经想到了所有危险命令——而事实上你不可能做到。上面的 RISKY_PREFIXES 用于记录日志,也用于向用户解释某项操作为什么被升级处理;真正作出决策的并不是它。决策来自最后的 return True,这行代码体现了默认拒绝原则。删掉这一行,前面的所有代码就都只是装饰。
它拒绝解析复合命令。ls && rm -rf build 以 allowlist 中的命令开头。如果你的检查方式是 startswith,那么你刚刚批准了一次删除操作。你可以去编写一个真正的 shell 解析器,也可以把操作符的出现直接视为需要自动升级审批。后者只需要两行规则,而且不会产生解析器 bug。
任何分类器都有判断错误的时候,因此真正的问题是:每种错误需要付出什么代价。
如果分类器把一条安全命令判定为有风险,用户只会多收到一次不必要的审批提示。它确实令人烦恼,但影响有限,而且看得见。
如果分类器把一条危险命令判定为安全,就会在无人监督的情况下发生某种不可逆的事情。其影响没有明确边界,而且往往要到事后才会被发现。
这两种错误根本不能相提并论。因此,默认分支必须是“询问”,而不是“允许”。你的目标不是构建一个永远正确的分类器,而是构建一个即使出错,所有错误也都会落在代价较低一侧的分类器。然后持续维护 allowlist,直到这些低代价错误足够少,少到可以接受。
这也是 allowlist 应该保持朴素、明确,而不是追求聪明的原因。你添加的每一项,都是一次小而慎重的决定:即使你正在睡觉,这个明确的操作也可以继续执行。
我们开发了 recal,一款运行在 macOS 上、local-first 的助手。它的命令工具采用的正是这种方式:只读命令可以在无人监督的情况下执行,其他所有命令都会触发审批,而且审批请求会携带实际的命令文本,让用户批准的是一个具体操作,而不是某个抽象类别。长任务不再频繁卡住,而剩下的审批,也都是真正值得阅读的审批。
必须坦诚说明的是,allowlist 需要持续维护。新的工具会不断出现,有人的工作流会需要 jq 或 kubectl get,这个列表也就必须随之扩展。不存在一种方案,可以让你只编写一次分类器,此后便一劳永逸。你真正得到的,是这样一个系统:判断错误的代价只是弹出一次询问,而不是从备份中恢复数据。事实证明,这种取舍每次都值得。
另一个必须坦诚说明的问题是:如果一条命令单独来看是安全的,但组合起来却糟糕透顶,这套机制对此无能为力。在错误的目录中执行 40 次已经获得批准的写操作,仍然是 40 次已经获得批准的写操作。可逆性分类限制的是单次调用的爆炸半径,而不是整个计划的爆炸半径。
从默认拒绝开始,先建立一个只包含四五条读取命令的 allowlist,然后在真实任务被某条命令卡住时,再把它加入列表。记录每一次升级审批及其完整命令文本,因为这些日志会告诉你接下来应该添加哪些条目。同时,请克制住让分类器变得“聪明”的冲动。这里真正的价值并不在于智能,而在于它的失败模式是提出问题,而不是执行删除。
本文在 AI 的协助下完成,并由人类编辑。关于优先使用 allowlist 而非 blocklist,以及复合命令的处理建议,参考了 Anthropic 的 bash 工具文档中的安全说明;其余内容来自我们实际发布的方案,以及我们为此付出的代价。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。