0基础学会Agent Harness工程(03):Permission先划边界再给自由

本篇对应的官方文档

本篇主要内容
第 02 篇已经把 Bash 扩展成 read_filewrite_fileedit_fileglob 等原子工具,并通过 dispatch map 保持主循环稳定。但“工具已注册、参数合法、handler 存在”只说明程序能够执行,并不等于这次执行应当被允许。本篇沿一次 Bash 调用进入 handler 前的最后一步,建立 deny list、规则匹配和人工确认三道权限关卡,再说明拒绝后为什么仍需回填配对的工具结果,以及字符串规则为何不能替代操作系统沙箱。

下篇预告
下一篇把权限、日志、输出检查等横切逻辑从 agent_loop() 中移出,用 Hooks 建立明确的生命周期扩展点。

一、工具能够执行,为什么还不代表应该执行

第 02 篇完成了多工具扩展。模型可以从 Tool Schema 中选择能力,Harness 可以通过 TOOL_HANDLERS 找到实现,handler 也能把结果放回原来的消息循环。现在的主链已经很清楚:

model → tool_calls → dispatch map → handler → tool result → model

这条链解决的是“调用怎样落到真实动作”,没有回答“动作是否被授权”。假设模型生成 bash(command="rm -rf /tmp/demo"),Schema 合法、JSON 可解析、bash 也确实存在于分发表中。从执行能力看,它完全符合第 02 篇的合同;从风险看,程序却不应因为模型给出了结构化调用就立刻运行。

同样的问题也会出现在文件工具上。write_file(path, content) 的参数可能完全合法,但目标路径可能越过工作区;edit_file 可能修改关键配置;一个语法普通的 Bash 命令也可能覆盖系统文件。工具定义描述“能做什么”,权限系统决定“这一次能不能做”,两者不能混为一层。

第一张图把能力与授权分开。左侧是第 02 篇已经拥有的工具注册和路由,右侧是本篇新增的执行前判断。只有通过授权边界,调用才会进入 handler。

工具存在只代表具备能力,权限判断决定本次调用能否进入 handler
因此,第 03 篇不修改模型怎样选择工具,也不增加新的外部能力。它只在 arguments 已解析、handler 尚未执行的位置插入 check_permission()。这一步有三个关键要求:

  1. 明确哪些动作无条件拒绝;
  2. 对需要判断的高风险动作暂停并请求确认;
  3. 无论允许还是拒绝,都要让消息状态保持完整。

第三点容易被忽略。拒绝的确意味着“不执行 handler”,却不意味着可以从循环中悄悄删掉这次调用。模型已经产生一个带 ID 的 tool_call,后续状态必须告诉它该调用被拒绝,否则模型看不到环境反馈,也无法改换方案或向用户解释。

本篇始终围绕一个场景推进:模型提出 Bash 调用,Harness 在执行前依次判断它属于直接拒绝、需要确认还是直接允许。这里讨论的是教学代码中的静态控制流,不配置 API,也不声称某个模型一定会生成特定命令。

二、三道权限关卡怎样形成一条执行前管线

如果把所有风险动作都交给一个庞大的 if 判断,规则很快就会相互覆盖。教学版本把权限判断拆成三道关卡:deny list、规则匹配、人工确认。三者不是三个同义词,而是三种不同决策。

deny list 处理不允许协商的动作。命中后立即返回拒绝,不弹出确认,也不进入 handler。它表达的是系统硬边界。

规则匹配负责识别需要额外判断的动作,例如工作区外写入、带有删除或权限放大特征的 Bash 命令。命中规则并不自动代表最终拒绝,而是把调用转交给批准步骤。

人工确认只在规则命中后发生。程序显示原因、工具名和参数,得到明确同意才继续执行;其他输入都按拒绝处理。这一步把不可逆或高影响动作交给人做最后决策。

三道关卡的顺序很重要。无条件禁止的命令必须先于人工确认,否则硬边界可能被一次随意的 y 绕过。普通低风险调用则不应每次都询问,否则系统会出现“批准疲劳”,真正危险的提示反而容易被忽视。

工具调用依次经过硬拒绝、规则匹配和人工确认三道关卡
观察这幅状态图时,重点不是记住三个英文单词,而是看清每种状态是否会产生副作用、是否需要等待输入,以及怎样向下一轮模型交付结果。把权限决策压缩以后,可以得到三种出口:

  • allow:没有命中硬拒绝,也没有命中需确认规则,或命中后得到同意;
  • ask:规则发现潜在风险,循环暂时停在执行前;
  • deny:命中硬拒绝,或人工确认没有得到同意。

ask 不是最终执行结果,而是 allowdeny 之间的决策状态。当前案例使用同步 input(),所以程序会在本地终端阻塞等待。生产系统可能把待审批记录持久化,允许稍后恢复,但那已经超出这个单文件实现。

允许、询问和拒绝三种状态及其进入 handler 的差异
这里还要区分两层协议。OpenAI-compatible Chat Completions 负责把工具意图表示为 tool_calls,其中包含调用 ID、函数名和 JSON 参数;Permission Pipeline 是 Harness 的内部控制,不是模型端新增的协议字段。兼容接口不会替本地程序批准 Shell 命令,模型也不能用自然语言声明自己“已经获得权限”。

权限决策发生在应用侧,输入来自已经解析的工具调用,输出是 Harness 内部的布尔判断。真正跨越模型协议边界的是拒绝后的工具结果:

if not check_permission(block, arguments):
    messages.append({
        "role": "tool",
        "tool_call_id": block.id,
        "content": "Permission denied.",
    })
    continue

这段代码没有调用 handler,却仍追加一条 role="tool" 消息,并复用原来的 block.id。对模型来说,这次调用获得了一个明确 observation:动作没有发生,原因是权限拒绝。下一轮可以改用只读工具、缩小操作范围,或说明任务无法继续。

被拒绝的 tool_call 不进入 handler,但仍用相同 ID 回填权限结果
如果拒绝后只写 continue,消息历史里会留下一个没有结果的调用。这样做既破坏了“提出调用—获得结果”的反馈闭环,也使后续模型无法区分“执行失败”“尚未执行”和“被策略拒绝”。权限结果本身就是一种环境 observation,只是它不来自 Shell 或文件系统。

这也解释了为什么权限判断不能被理解成一次普通的 Python 条件分支。它同时连接两个世界:向下决定外部动作是否发生,向上维护模型能够理解的消息连续性。向下拒绝而不向上回填,状态会断裂;向上声称拒绝却仍执行 handler,事实与消息又会冲突。一个可审计的 Harness 必须让决策、实际执行和回填内容三者一致。

三、跟着一次调用看 Permission 怎样接回原有循环

s03_permission.py 大部分代码都来自第 02 篇:TOOLS 仍然公开五种能力,TOOL_HANDLERS 仍然负责路由,run_read()run_write() 等 handler 也没有改写。新增实现集中在四个对象:

  1. DENY_LISTcheck_deny_list()
  2. PERMISSION_RULEScheck_rules()
  3. ask_user()
  4. 串联三者的 check_permission()

真正的接入点只有一个:解析参数之后、选择 handler 之前。读图时先沿蓝色原主链找到橙色 Permission 节点,再分别跟踪绿色允许路径与红色拒绝旁路;这个坐标会直接映射到后面的 check_deny_list()check_rules()check_permission()

第 03 篇保留第 02 篇骨架,只在参数解析与 handler 执行之间增加权限管线
图中的橙色节点不是一个大而全的安全函数,而是三个职责明确的判断依次组合。先从不会进入人工批准的硬拒绝开始:它接收命令文本,只负责返回命中原因,既不触碰消息,也不执行任何外部动作。

DENY_LIST = [
    "rm -rf /", "sudo", "shutdown", "reboot",
    "mkfs", "dd if=", "> /dev/sda",
]

def check_deny_list(command: str) -> str | None:
    for pattern in DENY_LIST:
        if pattern in command:
            return f"Blocked: '{pattern}' is on the deny list"
    return None

输入是一段 Bash 命令字符串,输出是拒绝原因或 None。函数不执行命令,也不修改消息,它只负责识别。把原因作为字符串返回,而不是只返回 True,可以让上层打印明确提示。

这份 deny list 是教学数据,不是可靠的命令解析器。它依赖大小写和连续子串,无法理解引号、转义、变量、管道、别名或编码后的等价表达。它能够展示“硬拒绝应该早于 handler”,不能证明所有危险命令都被识别。

硬拒绝没有命中以后,系统仍需识别“可以讨论、但不能静默执行”的潜在风险。下一段代码把工具范围、参数判断和提示理由放在同一条规则中,目的是把风险识别与最终批准决定分开。

PERMISSION_RULES = [
    {
        "tools": ["write_file", "edit_file"],
        "check": lambda args: not (
            WORKDIR / args.get("path", "")
        ).resolve().is_relative_to(WORKDIR),
        "message": "Writing outside workspace",
    },
    {
        "tools": ["bash"],
        "check": lambda args: any(
            kw in args.get("command", "")
            for kw in ["rm ", "> /etc/", "chmod 777"]
        ),
        "message": "Potentially destructive command",
    },
]

def check_rules(tool_name: str, args: dict) -> str | None:
    for rule in PERMISSION_RULES:
        if tool_name in rule["tools"] and rule["check"](args):
            return rule["message"]
    return None

每条规则包含适用工具、判断函数和提示信息。工具名先缩小检查范围,check 再观察参数。这样,path 检查只面向文件写入工具,命令关键字只面向 Bash。

规则的返回值是“为什么需要确认”,不是直接执行结论。这个分层使检测与决策分开:规则发现风险,ask_user() 决定是否放行。以后即使把终端确认替换成网页审批或企业工单,风险识别仍可保留。

路径规则与第 02 篇的 safe_path() 也不重复。权限规则在 handler 之前决定是否询问;safe_path() 在 handler 内部执行路径边界校验。当前 run_write() 最终仍会拒绝工作区外路径,因此即使人工同意,handler 也可能返回路径错误。这暴露了教学案例的一个不一致:批准并不会自动取消执行层的安全检查。

规则只给出“为什么需要确认”,还没有产生最终决定。当前终端版使用一个同步函数显示理由与参数,并把开放式文本输入收敛成允许或拒绝两种结果;默认拒绝保证没有明确同意时不会继续。

def ask_user(tool_name: str, args: dict, reason: str) -> str:
    print(f"\n⚠ {reason}“)
    print(f” Tool: {tool_name}({args})“)
    choice = input(” Allow? [y/N] ").strip().lower()
    return “allow” if choice in (“y”, “yes”) else “deny”

只有 yyes 会放行,空输入及其他文本都进入拒绝。这是更稳妥的默认值:没有明确同意就不产生副作用。当前批准只存在于这一次函数返回值中,没有审批人身份、时间、原因、过期时间,也不会在进程退出后保留。

三个局部判断必须有一个固定编排,否则硬拒绝、风险识别和批准可能以错误顺序运行。check_permission() 的职责正是确定先硬拒绝、再规则匹配、最后在必要时询问,并把整条管线收敛为供主循环使用的布尔值。

def check_permission(block, arguments: dict) -> bool:
    name = block.function.name

    if name == "bash":
        reason = check_deny_list(arguments.get("command", ""))
        if reason:
            print(f"\n⛔ {reason}")
            return False

    reason = check_rules(name, arguments)
    if reason:
        decision = ask_user(name, arguments, reason)
        if decision == "deny":
            return False

    return True

对 Bash 调用,先检查硬拒绝,再检查普通规则;对其他工具,直接进入适用规则。没有命中风险,或得到明确批准,函数才返回 Trueagent_loop() 据此决定是追加拒绝结果,还是继续查找 handler。

把静态输入代入这段代码,可以推演三条路径,但不能把它们写成真实模型运行结果:

  • bash({"command": "Get-ChildItem"}):不命中当前字符串规则,返回允许;
  • bash({"command": "rm temp.txt"}):命中 "rm ",进入人工确认;
  • bash({"command": "sudo reboot"}):先命中 deny list,直接拒绝。

这些例子只证明给定函数和参数时的控制流。它们不证明兼容模型会生成相同工具名、参数格式或命令,也不证明目标操作系统能够执行这些命令。

四、Permission 解决了什么,又没有解决什么

第 03 篇完成的核心增量不是“让 Agent 变得安全”,而是建立了一个可见的执行前决策点。此前,合法工具调用会直接进入 handler;现在,调用必须先经过硬拒绝、规则识别和必要的人工确认。被拒绝的调用不会产生外部副作用,但仍会以配对工具消息回到模型。

最需要保留的主线可以压缩为:

能力注册回答“能不能调用”
Permission 回答“这一次允不允许”
handler 回答“实际怎样执行”
tool result 回答“环境发生了什么”

但字符串匹配很容易绕过。大小写变化、额外空格、Shell 变量、别名、脚本间接调用、编码与管道组合,都可能让同一意图不再包含原始子串。下图把“规则没有命中”与“动作没有风险”明确分开。

字符串规则可能被空格、变量、别名或间接脚本绕过
因此,deny list 和规则匹配只能作为一层策略信号。生产环境还需要更可靠的命令解析、结构化能力白名单、参数级校验、身份与角色授权、最小权限凭证,以及对副作用的审计和恢复机制。

更重要的是,Permission 不等于 sandbox。Permission 位于 Harness 控制流中,前提是所有动作都老实经过这个入口;sandbox 位于操作系统、容器、虚拟机或隔离运行时层,即使上层判断失误,也限制进程能访问的文件、网络和系统调用。

Harness 权限策略负责执行前决策,操作系统沙箱负责底层能力隔离
观察对照图时,应分别跟踪“策略允许了什么”和“进程实际上拥有什么能力”。两层解决的问题不同:

  • Permission 可以根据任务语义、工具参数和人工判断决定是否执行;
  • sandbox 不需要理解任务语义,只限制进程实际拥有的能力;
  • Permission 规则可能漏判,sandbox 仍应阻断越权访问;
  • sandbox 允许某项能力,也不代表业务策略必须批准这次使用。

当前代码还有几项必须公开的边界。批准过程是同步阻塞的,无法跨进程恢复;没有审批身份和审计记录;未知工具仍由后续路由处理;handler 超时只在 Bash 中单独设置;文件写入没有幂等键或事务;多个并行工具调用逐个处理,也没有针对审批顺序的并发设计。

生产权限还必须回答“谁在什么上下文中批准了什么”。同一条命令由项目维护者在隔离测试环境执行,和由匿名会话在生产主机执行,风险完全不同。规则输入不能只有工具名和参数,还应包含调用身份、租户、工作区、环境、数据级别、会话来源和当前任务。批准记录也不能只保存一个 allow,至少应保留决策人、理由、参数快照、策略版本、有效范围与过期时间,否则事后无法解释一次副作用为什么发生。

还要考虑批准之后、执行之前状态已经变化的情况。文件可能被其他进程替换,工作目录可能改变,短期凭证也可能过期。稳妥做法是把批准绑定到规范化后的具体操作摘要,并在执行前重新检查关键前提。对写入类工具,还需要幂等键、临时文件与原子替换;对远程操作,则需要请求超时、重试上限和可恢复记录。Permission 的价值在于形成这些控制的统一入口,而不是把所有控制压缩成几个字符串。

把这些边界整理成生产检查表,可以看到单个 check_permission() 只是治理入口,而不是治理终点。

从教学权限管线走向生产系统需要补齐身份、沙箱、审计、超时与恢复
本篇没有配置 API,也没有调用真实模型端点。所有允许、询问和拒绝路径都来自对 s03_permission.py 的静态代码推演。静态推演足以确认判断顺序、handler 是否被跳过、拒绝消息是否配对;不能验证兼容服务的实际模型选择,也不能验证特定操作系统下命令的真实副作用。

到这里,主循环第一次具备了执行前边界。新的问题随之出现:权限检查已经写进 agent_loop(),接下来再加入日志、输入上下文、输出检查和停止统计,循环会不断吸收与核心协议无关的分支。第 04 篇会把这些横切逻辑移到 Hooks 中,让主循环继续只负责模型调用、工具执行和结果回填。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐