本文是「从零理解 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

它既能执行 pwdlsfind 这种普通查询,也能执行删除、提权、关机等高风险操作。

如果不加限制,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

调用具体工具

把工具结果交回模型

这里还有一个细节:拒绝操作以后,程序不会直接崩掉。

它会把 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 更像是在执行前后插入一些固定动作,例如记录日志、补充上下文、格式化输出或触发额外检查。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐