给测试 Agent 几把钥匙?最小权限、人工确认与审计日志
权限等级来自你自己的登记表,危险操作要人批准且批准绑定参数,每一次判定都写进防篡改的审计日志。这三件事都能写成可回归的测试,包括越权和绕过的负向用例。
BugHunt 的测试 Agent 要浏览页面、提交报告、造测试数据,偶尔还要清库。模型会被网页里的文字带偏,也会自己犯错;工具网关是它和真实副作用之间最后一层你能完全控制的代码。
1四级权限:BugHunt 的 9 个工具
按"出了错能不能撤回、影响谁"把工具分成四级。每种 Agent 只拿完成任务所需的最少等级(最小权限):
| 等级 | 工具 | explorer | hunter(默认) | operator(有人值守) |
|---|---|---|---|---|
| 只读浏览 | browse_page list_reports get_report | 允许 | 允许 | 允许 |
| 提交报告 | submit_report | 拒绝 | 允许(每次运行 ≤ 20 份) | 允许 |
| 修改测试数据 | seed_fixture update_fixture | 拒绝 | 只限本次运行的租户 test-<run_id> | 同左 |
| 危险操作 | reset_database delete_report grant_role | 拒绝 | 拒绝 | 人批准后执行;staging / prod 清库直接拒绝 |
3 种 profile × 9 个工具 = 27 格,是权限回归测试的第一张表。参数级约束另算:浏览只限被测应用域名 conduit.test,一次造超过 50 条数据要人确认。
2一次调用过哪几道关
- 登记表查名字:精确匹配,查不到一律拒绝(默认拒绝)。
Reset_Database、末尾带空格、西里尔字母冒充的名字都算"查不到"。 - 参数 schema:必填、类型、不允许多余参数。模型自己塞一个
"confirmed": true进来,在这一步就被拒。 - 等级 × profile:等级来自本地登记表,不来自 MCP server 声明的 annotations。
- 参数级约束:域名白名单(解析 URL 取 hostname,不做子串匹配)、租户精确等于本次运行的沙箱、报告配额、只有 test 环境能清库。
- 是否要人确认:危险操作、批量造数 → 等人批准;其余直接执行。
- 执行前先写审计:写不进去就不执行(fail closed);执行后再写一条结果。
判定函数 decide() 是纯函数:同样的工具名、参数、上下文、开着的防线,永远给同样的结果。这样 27 格矩阵和所有边界都能逐格断言。
3哪些要人确认,批准怎么才算数
要人确认的只有两类:危险操作(清库、删报告、改角色)和批量造数(count > 50)。《被测页面里藏了一句「指令」,测试 Agent 会照做吗?》的模拟里没有分级,提交报告、写笔记和访问外部域名都要人确认;这里按等级把确认收窄到不可逆的操作,提交报告靠配额兜住,外部域名直接拒绝。hunter 无人值守,一次造超过 50 条数据时调用会一直停在待批准、不执行(本文实现里待批准不会自动过期,实际系统应设超时并按拒绝处理)。确认不是弹个框就完事,批准本身也要过检查:
| 检查 | 防的是什么 |
|---|---|
| 批准绑定"工具名 + 完整参数"的摘要 | 批准删 R-17,执行时把参数换成 R-1 |
| 一次性,5 分钟内有效 | 一次批准反复用;昨天的批准今天拿来用 |
| 批准人在审批人名单里,且不是 Agent 自己 | Agent 通过某个回调自己批准自己 |
| 执行时按实际参数重新判定 | 比如批准的是清 test 库,执行时参数换成 prod;即使"绑定参数"这道防线失效,重新判定也会以 env_forbidden 拒绝 |
| 有些操作批准了也不行 | 审批人顺手批准了清 prod 库 |
4审计日志:记什么,怎么防篡改
| 问题 | 字段 |
|---|---|
| 谁 | actor(哪个 Agent,agent:hunter-run42)、on_behalf_of(替谁跑)、profile、run_id |
| 什么时候 | ts(带时区)、seq(单调递增) |
| 哪个工具 | tool、tier、call_id(把判定、批准、结果三条串起来) |
| 参数 | args(密码、token 打码)、args_digest(对原始参数算摘要,用来核对批准) |
| 谁批准 | approval_id、approver |
| 结果 | phase(decision / approval / result)、decision、rule(哪条规则做的决定)、result |
| 防篡改 | prev_hash、hash = SHA-256(上一条的 hash + 本条的规范化 JSON) |
拒绝也要记。越权尝试本身就是信号:同一个 run 里出现 host_not_allowed,往往说明页面里有东西在诱导 Agent 往外发数据。
哈希链只防"改了不重算"。不带密钥的 SHA-256 谁都能算:能写日志的人改了一条,再把后面每条的 hash 重算一遍,链照样完好;删掉末尾几条,剩下的也仍然首尾相连。所以要定期把最新的 hash 写到 Agent 碰不到的另一个系统(锚点):锚点之前的任何改动、删除、截尾,重算后的最新 hash 都会和锚点对不上。日志本身也要放在 Agent 进程没有删改权限的地方;要防的是能写日志的人,就得用带密钥的 HMAC 或签名。另外,参数摘要对低熵的秘密(比如短密码)可以被暴力反推,生产环境应换成带密钥的 HMAC,这里为了演示用的是普通 SHA-256。
5MCP 的 tool annotations 是提示,不是权限
MCP 2026-07-28 规范的 ToolAnnotations 有 title、readOnlyHint(默认 false)、destructiveHint(默认 true,只在 readOnlyHint 为 false 时有意义)、idempotentHint(默认 false)、openWorldHint(默认 true)。schema 注释写明这些都是 hints,不保证如实描述工具行为,客户端不应根据来自不可信 server 的 annotations 做工具使用决定;Tools 页面用的是 MUST:除非来自可信 server,客户端必须把 annotations 当作不可信。
所以网关的等级只看本地登记表。annotations 可以用来提示(比如新接入一个 server 时,它声明 destructive 的工具默认放进"危险操作"待人审核),不能用来放行。同一页的安全建议里还写着客户端 SHOULD 对敏感操作征得用户确认、SHOULD 记录工具使用以便审计。
6对照 Codex 的审计事件
Codex 的审批和沙箱在《Agent 要执行 rm -rf,谁来拦?》讲过,Guardian 在《两份 Bug 报告换个顺序,Judge 就改判了?》讲过,这里只看它把"谁批准了什么"记在哪(commit 7993248,路径相对 codex-rs/):
codex.tool_decision事件(otel/src/events/session_telemetry.rs:1168-1195):tool_name、call_id、decision、source。source 是可选字段,有值时是ToolDecisionSource的三个取值之一AutomatedReviewer/Config/User(otel/src/lib.rs:51-57);网络访问审批记的 tool_decision 不带 source(core/src/tools/network_approval.rs:828-833)。hook 给的结论记成 Config,Guardian 记成 AutomatedReviewer(core/src/tools/approvals.rs:873-885);未开启 strict auto-review 时,不需要审批直接放行的也记一条 Approved + Config(core/src/tools/orchestrator.rs:167-196)。codex.tool_result事件(otel/src/tool_result.rs:54-81):日志目标里有arguments和按上限截断的输出预览(多 agent 之间的明文消息,参数换成[plaintext arguments],core/src/tools/router.rs:63-71);trace 目标不带参数和输出原文,只带长度。公共字段(otel/src/events/shared.rs:14-32)里,日志目标带user.account_id、user.email,trace 目标不带。- 日志导出默认关闭:
exporter不配置时是None(core/src/config/otel.rs:17)。
和本文的设计比:它用 call_id 把判定和结果串起来,用 source 区分人、配置和自动审批,和这里的 approver 字段是一个意思;人 / hook / Guardian 拒绝会记一条 decision=denied 的 tool_decision(approvals.rs:508-529),网络访问审批被拒也记一条(network_approval.rs:828-833);只有策略层的 Forbidden 直接返回错误(orchestrator.rs:199-201),不发 tool_decision,但整个调用包在 log_tool_result_with_tags 里(core/src/tools/registry.rs:681-696),仍会留下一条 success=false 的 tool_result。这些事件是遥测,没有哈希链。
▶互动演示 1:工具网关单步执行
选一条用例,单步执行。11 道防线都可以单独关掉,看哪条攻击会漏过去(用例按一一对应设计:只关一道,恰好放进针对它的那一条)。网关逻辑、哈希(SHA-256)、审计日志都是在页面里真的算出来的,和本地 gateway.py 的规则一致;"示例会话"跑完的最后一条 hash 和 demo.py 的输出逐位相同。被测环境是假的(合成),不调任何模型。
审计日志(JSONL 的表格视图,hash 只显示前 10 位)
整套用例(11 条攻击 + 6 条正常操作),按当前防线现算
▶互动演示 2:确认策略和确认负担
一次有人值守的 BugHunt 运行(合成数据):137 次工具调用。给每个等级选"允许 / 确认 / 拒绝",看审批人每天要点多少次,以及有多少危险操作没经过确认、有多少正常调用被拒。
✎练习
update_fixture,tenant 填的是 "test-run42-prod"。网关怎么判?test-run42-prod、test-run420 都能过。规则写成精确相等,测试里专门放几个"长得像"的值(测试代码里有 6 个)。delete_report(report_id="R-17"),Agent 拿着这个批准号去执行 delete_report(report_id="R-1")。全开的网关在哪一步拦下?reset_database 标成 readOnlyHint: true。explorer profile 调它,本文的网关怎么处理?7面试要点与代码
一句话讲清楚
工具权限我分三层做:按"能不能撤回、影响谁"把工具分级,每种 Agent 只拿最少的等级,等级只看本地登记表、不信 server 的 annotations;不可逆的操作要人批准,批准绑定工具名和完整参数、一次性、有有效期、不能自批,最危险的那类批准了也不执行;每一次判定都先写审计再执行,写不进去就不执行,审计用哈希链加写在别处的锚点(或 HMAC / 签名)防篡改和截尾。这些规则都写成纯函数加数据驱动的负向用例,每条攻击用例都证明"关掉对应防线它就能成功"。
追问准备
- 为什么不让模型自己判断要不要确认?模型会被网页内容带偏,也会把"确认"当成参数自己填。确认与否必须由网关按规则决定,模型能控制的只有它发出的调用。
- 确认太多怎么办?先用权限和参数约束把能自动判的判掉(只读、本租户、配额内),只把不可逆、影响别人的操作留给人;最危险的直接拒绝。可以统计每天的确认次数和批准率,批准率接近 100% 往往说明审批已经流于形式(笔者的经验判断,不是引用)。
- 审计日志放哪?和 Agent 隔离:Agent 进程没有删改权限的只追加存储,定期把最新哈希写到另一个系统做锚点。字段里打码秘密,但摘要要对原始参数算,才能核对批准。
- 怎么证明负向测试有用?每条攻击用例都配一个"只关掉对应防线"的对照,断言攻击在对照里成功;再用变异测试往实现里埋常见 bug(差一、子串匹配、bool 当 int、不打码……),本地 7/7 被抓到。
常见错误说法
❌ "有人工确认就安全了":批准不绑定参数、可重放、能自批,确认就是摆设;确认太多,人也会不看就点。
❌ "审计只记执行成功的调用":拒绝和越权尝试最有价值。
❌ "审计日志加了哈希链就改不了、删不掉":链本身挡不住会重算哈希的人,删末尾几条更是连重算都不用;要靠写在别处的锚点(或 HMAC / 签名)。
❌ "域名白名单用
'conduit.test' in url 就行":conduit.test.attacker.example、conduit.test@attacker.example 都能过。
判定函数(Python,gateway.py 节选)
def decide(tool, args, ctx, on, annotations=None) -> Decision:
annotations = annotations or {}
spec = R.TOOLS.get(tool)
if spec is None:
if "default_deny" in on:
return Decision(DENY, "unknown_tool", None)
return Decision(ALLOW, "unknown_tool_allowed", None)
if not validate_args(tool, args):
return Decision(DENY, "bad_args", spec["tier"])
tier = spec["tier"]
if "ignore_annotations" not in on and annotations.get("readOnlyHint") is True:
tier = R.READ # 有漏洞的写法:server 说只读就当只读
if "least_privilege" in on and tier not in R.PROFILES[ctx["profile"]]:
return Decision(DENY, "tier_not_granted", tier)
# ……域名白名单、租户、配额、环境四条参数约束
if tier == R.DANGEROUS:
return Decision(CONFIRM, "dangerous_needs_human", tier)
if tool == "seed_fixture" and args["count"] > R.BULK_SEED_THRESHOLD:
return Decision(CONFIRM, "bulk_seed", tier)
return Decision(ALLOW, "granted", tier)
完整代码在 week08_安全与权限/code/tool_permissions/,只用 Python 标准库:python3.13 -m unittest 跑 37 个测试,python3.13 attacks.py 打印每道防线单独关掉时的结果,python3.13 mutation_check.py 跑 7 个变异。