Agent 能调用工具以后,谁来决定这条命令能不能执行?
本文是「从零理解 Claude Code:20 个 Agent Harness 机制」系列的第 3 篇。
源码仓库:shareAI-lab/learn-claude-code
本文基于开源仓库学习整理,具体实现以仓库代码为准。
前两篇把 Agent 的基本闭环搭起来后,它已经不只是生成一段建议,而是开始具备介入开发环境的能力。
它可以先搜索项目里的文件,再读取相关代码,发现问题后修改文本、写入文件,必要时还能调用 Shell,运行命令并根据输出继续判断下一步。
从这一刻起,Agent 的每一次工具调用,都可能影响真实的文件、进程和项目状态。
这时有一个问题会立刻变得现实:
模型决定执行一条命令以后,程序是不是应该立刻执行?
比如用户让 Agent 清理临时文件,模型可能会生成:
rm test.log
这通常没什么问题。
但如果模型理解错了目录,或者命令本身就不该执行呢?
rm -rf /
sudo reboot
工具调用本身没有好坏之分,真正危险的是:程序把模型给出的内容,当成一条天然可信的指令直接执行。
所以这一章做的事情很明确:在工具真正运行前,加一道权限检查。
一、工具能调用以后,问题就变了
最小 Agent Loop 的流程是这样的:
模型请求工具
→ 程序执行工具
→ 工具结果返回给模型
→ 模型继续判断
在只有一个 bash 工具的教学版本里,跑通当然没有什么问题。
但工具变多以后,模型的动作不再只是回答一句话,而是会影响真实环境:
| 工具 | 可能做的事 | 风险 |
|---|---|---|
read_file |
读取代码和配置 | 可能读取敏感文件 |
write_file |
创建或覆盖文件 | 可能误改内容 |
edit_file |
替换文件片段 | 可能改错位置 |
bash |
执行任意命令 | 风险最高 |
尤其是 bash。
它既能执行 pwd、ls、find 这种普通查询,也能执行删除、提权、关机等高风险操作。
如果不加限制,Agent 的执行逻辑大致就是这样:
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
模型提出什么操作,程序就照着执行什么操作。
这种实现写起来很省事,但中间没有任何缓冲:模型的一次工具调用,会直接变成一次文件修改或终端命令。
所以权限检查其实就是在动作真正落到环境之前,补上一层判断:
这条操作能不能直接执行?
二、权限检查插在哪里
这一章依旧延续了上一章的 Agent Loop,只是在工具分发前加了一个判断。
原来的结构是:
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
加上权限检查后,变成了:
if not check_permission(block):
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "Permission denied.",
})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
位置很关键。
权限判断必须在工具执行之前,否则命令已经跑完了,再提示用户确认就没有意义。
可以把它理解成一个门卫:
这里还有一个细节:拒绝操作以后,程序不会直接崩掉。
它会把 Permission denied. 当作工具结果返回给模型。模型看到这个结果后,可以换一种更安全的方案,也可以向用户解释为什么没执行。
这比直接中断整个 Agent 更合理。
三、第一道门:明确危险的命令,直接拒绝
最简单的处理方式,是先准备一个高风险命令列表。
DENY_LIST = [
"rm -rf /",
"sudo",
"shutdown",
"reboot",
"mkfs",
"dd if=",
"> /dev/sda",
]
当 Agent 请求执行 bash 时,程序先检查命令里是否包含这些内容:
if block.name == "bash":
command = block.input.get("command", "")
for denied in DENY_LIST:
if denied in command:
print(f"DENIED: dangerous command: {denied}")
return False
例如模型请求:
sudo reboot
这条高危命令不会交给终端执行,而是直接被拒绝。
这是第一道门的特点:
- 规则明确;
- 不需要用户参与;
- 命中后立即停止。
像格式化磁盘、重启机器、危险删除这类操作,应该要让大模型做到令行禁止。
四、第二道门:有风险,但可以让用户决定
并不是所有风险操作都要一刀切拒绝。
比如用户明确要求删除一个临时文件:
rm test.log
这个动作有风险,但不一定是错误操作。
因此,代码里还配置了一组需要确认的规则:
PERMISSION_RULES = {
"bash": ["rm ", "> /etc/", "chmod 777"],
"write_file": ["outside_workspace"],
"edit_file": ["outside_workspace"],
}
当命令匹配到规则时,程序不会立刻执行,而是转到确认逻辑:
def ask_user(tool_name, tool_input):
print("\nPermission required")
print(f"Tool: {tool_name}")
print(f"Input: {tool_input}")
answer = input("Allow? [y/N] ").strip().lower()
return answer == "y"
终端里看到的效果类似这样:
Permission required
Tool: bash
Input: {'command': 'rm test.log'}
Allow? [y/N]
只有用户输入 y,工具才会继续执行。
默认值是 N,也就是直接回车时,操作会被拒绝。
这个默认行为很重要。权限系统应该倾向于保守,而不是把“没有明确同意”理解成“默认同意”。
五、第三道门:工作目录本身也是边界
除了命令检查,文件工具还有一个更基础的限制:文件路径必须在工作目录内。
例如:
def safe_path(path):
resolved = (WORKDIR / path).resolve()
if not str(resolved).startswith(str(WORKDIR.resolve())):
raise ValueError("Path escapes workspace")
return resolved
假设工作目录是当前项目,模型尝试读取:
../../.ssh/id_rsa
即使路径看上去只是一个普通字符串,解析后的真实位置已经跑到项目外面去了。
safe_path() 会拒绝这种路径。
这和前面的确认框不是一回事:
| 机制 | 解决的问题 |
|---|---|
| 危险命令列表 | 有些命令本身就不该执行 |
| 用户确认 | 有风险,但用户可能确实需要执行 |
| 工作目录限制 | 文件操作不能越出项目边界 |
它们放在一起,才构成一个比较基础的安全边界。
单看代码,权限判断只有几层 if。
但如果把它放回一次完整的工具调用里看,会更容易理解这三层检查各自负责什么,以及为什么它们必须出现在真正执行工具之前。
六、同样是工具调用,结果可能完全不同
假设模型连续发出了三次工具请求。
1. 读取项目说明
read_file("README.md")
这是普通读取操作,没有命中风险规则。
权限检查:通过
→ 执行 read_file
→ 返回文件内容
2. 删除临时文件
rm test.log
命令匹配到了 rm 规则。
权限检查:需要确认
→ 询问用户
→ 用户输入 y:执行
→ 用户直接回车或输入 n:拒绝
3. 使用 sudo 执行命令
sudo reboot
命中了硬拒绝列表。
权限检查:直接拒绝
→ 不询问用户
→ 不执行命令
→ 返回 Permission denied
同样是模型发出的工具调用,最后的处理方式并不一样。
权限系统做的不是简单回答能或者不能,而是把操作分成不同等级:
普通操作:直接执行
风险操作:征求确认
危险操作:直接拒绝
这三个例子看下来,可以发现权限检查并不是简单地给工具调用加一个“允许”或“拒绝”。
它会根据操作类型,把请求分流到不同处理路径:普通操作不打断流程,有风险的操作交给用户确认,明确危险的操作则在进入终端前直接拦下。
下图能够帮助大家更好理解不同工具调用的区别:
七、为什么要先拒绝,再确认
这几层判断顺序不能随便换。
正确顺序应该是:
先检查是否属于硬拒绝操作
↓
再检查是否属于需要确认的操作
↓
最后执行普通操作
原因也很简单,如果先弹确认框,用户很容易在不了解后果时直接同意;如果先执行,再回头检查,那权限系统就失去意义了。
所以代码里的 check_permission() 可以理解成一个从严到松的筛选过程:
def check_permission(block):
if is_dangerous(block):
return False
if needs_confirmation(block):
return ask_user(block.name, block.input)
return True
这段代码不复杂,但它把工具调用从“模型说什么就做什么”,变成了“模型提出请求,程序按规则决定如何处理”。
八、这还不是一个完整的安全系统
这一章的实现是教学版本,重点是让我们看清权限检查应该放在哪里,而不是提供生产环境可以直接使用的安全方案。
例如,简单的字符串匹配有明显局限:
rm test.log
能匹配到 rm ,但 Shell 命令还可以通过变量、脚本、管道、编码等方式组合。
真实系统里,通常还需要更多保护措施:
- 将命令放到容器或沙箱中执行;
- 按工具类型配置更细的权限;
- 限制网络、文件系统和环境变量访问;
- 记录每次工具调用和审批结果;
- 对高风险操作设置不可绕过的策略;
- 不把 Shell 当成默认工具能力。
不过对于学习 Agent Harness 来说,这一章已经说明了一个核心原则:
模型可以决定下一步想做什么,但不应该单独决定它有没有权限这么做。
模型负责规划和判断;权限层负责约束行动边界。
九、可以自己试一下
如果已经把这一章的代码跑起来,可以依次试这几类请求:
读取 README.md
观察普通读取工具是否直接执行。
删除 test.log
观察终端是否出现确认提示。
执行 sudo reboot
观察命令是否在确认前就被拒绝。
测试时建议在单独的临时目录中运行,不要直接拿重要项目或真实配置文件做实验。
小结
前一篇解决的是:模型如何从多个工具中找到合适的执行入口。
这一篇解决的是:工具找到以后,是否应该马上执行。
权限检查并没有让 Agent 的循环变复杂多少,只是在工具调用前多了一步:
模型请求操作
→ 权限层检查
→ 执行或拒绝
→ 结果返回模型
但这一步决定了 Agent 是一个只能演示的工具,还是一个可以开始接近真实环境的程序。
下一篇准备讲一下Hooks。
权限检查解决的是执行前能不能做;Hooks 更像是在执行前后插入一些固定动作,例如记录日志、补充上下文、格式化输出或触发额外检查。
更多推荐


所有评论(0)