Agent 可靠性工程实战(二):把工具调用从“模型想做”变成“宿主允许做”

本篇直接复用上一篇的Ledger.append()load_events()runs/demo-001/events.jsonl。上一篇最后一条model_proposed事件就是本篇授权器的输入;本篇新增tools.jsonpolicy.py,输出tool_allowedtool_denied事件。模型只描述意图,真正的文件路径解析与权限判断由宿主完成。

一、工具声明不是权限

read_file的 JSON Schema 发给模型,只代表模型知道怎样构造参数,不代表它获得了读取任意文件的能力。如果宿主直接执行模型给出的路径,../../.ssh/id_rsa、符号链接和绝对路径都可能越出工作区。安全边界必须位于模型无法修改的确定性代码里。

tools.json用数据描述每个工具的操作类别、允许根目录和单次输出上限。策略默认拒绝:名称不存在、参数缺失、路径越界或操作类别不匹配都会产生明确原因码。原因码比自然语言稳定,后续统计和回放不会因翻译变化而失效。

{"schema_version":1,"tools":{"read_file":{"operation":"read","roots":["workspace/src","workspace/tests"],"max_bytes":65536},"run_tests":{"operation":"execute","commands":[["python","-m","unittest"]]}}}

运行输出:

loaded_tools=2 schema_version=1

下面的路径判断先做词法拒绝,再解析真实路径,最后用relative_to验证所属关系。只用字符串前缀会把/work/app-copy误判成/work/app的子目录;只删除..又挡不住指向外部的符号链接。

from__future__importannotationsimportjsonfromdataclassesimportdataclassfrompathlibimportPathfromtypingimportAnyfromledgerimportLedger,load_events@dataclass(frozen=True)classDecision:allowed:boolreason:strnormalized:dict[str,Any]definside(path:Path,root:Path)->bool:try:path.relative_to(root)returnTrueexceptValueError:returnFalsedefauthorize(call:dict[str,Any],manifest:dict,project:Path)->Decision:spec=manifest["tools"].get(call.get("tool"))ifspecisNone:returnDecision(False,"unknown_tool",{})ifspec["operation"]=="read":raw=call.get("path")ifnotisinstance(raw,str)orPath(raw).is_absolute():returnDecision(False,"invalid_path",{})candidate=(project/raw).resolve()roots=[(project/root).resolve()forrootinspec["roots"]]ifnotany(inside(candidate,root)forrootinroots):returnDecision(False,"outside_roots",{"path":str(candidate)})returnDecision(True,"allowed",{"path":str(candidate),"max_bytes":spec["max_bytes"]})returnDecision(False,"unsupported_operation",{})defmain()->int:events_path=Path("runs/demo-001/events.jsonl")proposed=load_events(events_path)[-1]["payload"]manifest=json.loads(Path("tools.json").read_text(encoding="utf-8"))decision=authorize(proposed,manifest,Path.cwd())Ledger(events_path,"demo-001").append("tool_allowed"ifdecision.allowedelse"tool_denied",{"reason":decision.reason,**decision.normalized},)print(f"allowed={decision.allowed}reason={decision.reason}")return0if__name__=="__main__":raiseSystemExit(main())

运行输出:

allowed=True reason=allowed

二、为什么规范化必须发生在执行前

权限判断若针对原始字符串,执行时再解析路径,两阶段看到的可能不是同一个对象。攻击者可以让符号链接在判断后指向另一个位置,这叫检查与使用之间的竞态。对高风险写操作,仅靠resolve()仍不够,需要使用支持目录文件描述符的系统调用、打开后核验 inode,或把执行放进只挂载允许目录的隔离环境。

本系列先把读工具限制在项目内,并把规范化后的绝对路径写入授权事件。执行器只能消费授权事件里的路径,不能重新使用模型原参数。记忆点是:裁决输入可以来自模型,执行输入必须来自裁决结果。两者看似重复,实际切断了“审的是 A,做的是 B”。

三、拒绝也是正常控制流

工具被拒绝不应抛出含糊的内部异常,也不该自动把权限扩大后重试。宿主把outside_roots连同允许根目录反馈给下一轮,模型可以选择合规路径。连续拒绝会消耗预算,防止模型无限探测目录。不存在的文件与无权访问也应使用不同原因码:前者可能通过选择其他文件恢复,后者需要用户明确授权。

输出上限同样属于权限。读取一个数百兆日志会挤掉上下文并增加费用,即使路径合法也可能造成拒绝服务。执行器读取max_bytes + 1个字节,超限时返回摘要与截断标志,而不是默默截断后假装得到完整文件。

四、怎样验收策略没有形同虚设

至少覆盖正常相对路径、绝对路径、..、相似目录名前缀和指向外部的符号链接。测试不能只断言布尔值,还要检查账本追加的原因码与规范化路径。tools.json自身必须由人工控制,不能放在 Agent 可写目录;否则模型只需给自己增加一个shell工具。

本篇输出仍是runs/demo-001/events.jsonl,现在多了一条带规范化路径的授权事件。下一篇会读取run_started.payload.goal与这条授权事件,生成contract.json:把原始目标、允许修改范围和检查命令做成指纹,防止重试过程中目标悄悄漂移。

参考来源

  • Python 文档|pathlib — Object-oriented filesystem paths
  • OWASP|Path Traversal

👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。

🚀 本文属于《Agent可靠性工程实战》系列,持续更新,关注不迷路。

📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。