权限与 Human-in-the-Loop v05
权限与 Human-in-the-Loop v05
v04(ch17)我们给产品装上了 Web 前端:浏览器消费 SSE 事件流,把工具调用渲染成卡片。但有一个关键问题被一路「先跑起来再说」地搁置了:Agent 想删文件,谁来拦?
ch14 我们说过工具集只放「纯函数、无副作用」的成员,把 delete_file、run_shell 这类危险工具推迟到「权限层出现的那一章」,就是本章。我们给产品加 v05:权限与 Human-in-the-Loop:
- 每个工具声明**风险分级(risk tier)**:
auto(自动放行)/suggest(提示但不阻断)/approve(必须人工批准); approve级工具在执行前暂停主循环、发出approval_request事件,等人类批准后才继续,这就是HITL(人在回路);- 拒绝则跳过执行,并在事件流里留下
denied标记;每个审批决策(谁批了什么、为什么)都记入**操作审计(audit log)**。
上一章(ch15)设计事件协议时把 approval_request / approval_result 两型先定义、后实现,「协议是契约,契约先于实现稳定」的伏笔,本章兑现。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出「为什么不能把全部工具都交给 Agent 自主执行」:过拟合用户意图(overeager)、诚实失误、prompt injection 三类失败模式,以及「审批疲劳」现象;
- 解释
auto/suggest/approve三档的语义与选择依据,并为任意工具做风险分级; - 画出
approve级工具调用的完整事件序列(tool_call_start→approval_request→ 暂停 →approval_result→tool_result),解释 callId 配对不变式如何保持; - 解释分层红线:主循环如何只靠注入的
getApproval回调拿到审批决定,而不 import 任何界面;同一回调如何被终端、HTTP、测试三种界面实现; - 说出「审批硬校验」为什么要做两层(loop 的交互门 +
executeTool的硬门),以及 fail-closed(默认拒绝)的含义; - 说出本章的失败模式:死等审批、超时策略、拒绝后的恢复、审批状态塞进 core、理由丢失。
概念与动机:为什么需要人在环里
先看一个「错误答案」:把全部工具交给 Agent
假设我们没有权限层。v04 的产品里再加两个工具:
registry.register({
name: 'delete_file',
description: 'Delete a file from disk.',
parameters: [{ name: 'path', type: 'string', description: 'File path.' }],
}, async (args) => { await rm(args.path, { force: true }); return 'deleted'; });
用户说「帮我清理一下项目里的旧文件」。模型很热心:列出几个文件,挑了个它判断为「旧的」的,删了。文件不可恢复。用户其实只想删掉临时目录里那几个,模型把 notes/ 下三个月的笔记也当成「旧文件」了。
这不是模型「坏」。它是over-eager(过拟合意图):它理解了你的目标、真心想帮忙,但把「清理」的边界推到了你授权之外。Anthropic 在一篇讲 Claude Code auto mode 的工程文章 里把这种失败分类为四类,其中三类都发生在「模型没做错什么但做过头了」:
- over-eager 行为:理解了目标但越界(删了判断为「碍事」的文件);
- 诚实失误:误解了影响半径(以为文件是测试用的,其实是共享的);
- prompt injection:文件/网页里藏着指令,劫持了模型;
- 模型自身对齐问题(现实中较少见)。
无论哪一类,防线都指向同一件事:在破坏性动作执行之前,让一个不在这条推理链上的人确认一下。
审批疲劳:为什么不能「每个动作都问」
那「所有工具都要求确认」行不行?也不行。Anthropic 的同一篇文章给了一个扎心的数字:用户会批准 93% 的权限提示。当每一次文件写入、每一次命令运行都要点「允许」,用户很快就麻木了,扫一眼、随手点掉,审批变成了形式。这叫审批疲劳(approval fatigue):审批频率越高,单次审批的质量越低,真正危险的请求反而更容易被放行。
所以正确的设计不是「全问」或「全放」,而是按风险分级:低风险高频的操作自动放行(否则 Agent 每走一步都要打断你),只有真正有破坏潜力的动作才叫人。分级越准,被打断的次数越少,每一次打断越值得认真对待,这正是本章三档设计的动机。
参照:业界怎么分级
- Claude Code 对每个工具/命令有独立的权限动作:
allow(直接放行)、ask(弹提示让你决定)、deny(禁止),并有「先设边界、再给自由」的权限治理章节(learn-claude-code s03 · Set boundaries first, then grant freedom);auto mode 更进一步,用分层裁决替代人工:内置安全白名单直接放行、项目内文件操作放行、其余动作交给一个分类器做「值得警示才人工」的判断(Anthropic · How we built Claude Code auto mode)。 - OpenAI Agents SDK 让工具声明「需要审批」(
needs_approval),触发后暂停运行、把待审批项作为中断(interruption)暴露给宿主,宿主批准/拒绝后再恢复(OpenAI Agents SDK · Human-in-the-loop)。「暂停 → 问 → 恢复」正是本章审批双向流(approval flow)的形态。 - 《从 0 开始构建 AI 智能体》的 FunHarness 用 auto / suggest / approve 三模式做权限:auto 全自动、suggest 只对写操作要审批、approve 所有读写都要审批(hyyhf/agent-book-code · Chapter 07 权限系统)。
我们借鉴的是「风险分级 + 执行前审批 + 暂停/恢复」这个机制;实现细节(事件协议、回调注入、审计)是我们自己的。
风险分级与三模式
三档语义
| 档位 | 含义 | 主循环行为 | 典型工具 |
|---|---|---|---|
auto | 无副作用或只读 | 直接执行,零交互 | get_time、get_weather、add、read_file |
suggest | 有轻微副作用 | 发 approval_request(risk=suggest)提示但不阻断,随即执行 | write_note(写 scratch 笔记) |
approve | 破坏性、不可逆、影响他人 | 发 approval_request(risk=approve)并暂停,等人类决定:批准→执行;拒绝→跳过(tool_result 带 denied 标记) | delete_file、run_shell、生产部署 |
分级判断一句话:这个动作执行后,如果它不该被执行,后果能撤销吗? 能撤销(写个临时文件)→ suggest 或更低;不可撤销(删文件)或影响范围超出当前会话(改共享配置、发消息、推生产)→ approve。注意 suggest 不等于「弱化版 approve」,它不等待,只是给前端一个展示「接下来会写文件」的机会,用户看到提示想拦的话,可以事后纠正(或者干脆把该工具改成 approve)。
flowchart LR
subgraph tools["工具分类"]
R["只读 / 无副作用<br/>get_time · get_weather · add"]
W["轻微副作用<br/>write_note"]
D["破坏性 / 不可逆<br/>delete_file"]
end
subgraph tiers["风险分级(spec.risk)"]
A["auto<br/>直接执行"]
S["suggest<br/>提示但不阻断"]
P["approve<br/>暂停 · 等人批准"]
end
subgraph gate["审批门"]
G1["批准 → 执行"]
G2["拒绝 → 跳过(denied)"]
end
R --> A
W --> S
D --> P
P -->|getApproval 回调| G1
P -->|getApproval 回调| G2
风险放在哪:工具的 spec 上,而不是模型手里
风险是策略(policy),不是模型知识。我们把 risk 字段放在工具注册表的 spec 上:
// lib/tools.mjs
registry.register(
{
name: 'delete_file',
description: 'Delete a file from disk. DESTRUCTIVE and irreversible.',
parameters: [{ name: 'path', type: 'string', description: 'File path.' }],
risk: 'approve', // 不可逆:主循环会先问人类
},
async (args) => { /* ... */ },
);
模型看到的工具列表里没有 risk(modelTools() 只输出 name/description/schema)。这样模型无法「说服」策略层放行:它连风险档位都不知道,自然也无法在 prompt 里辩称「这个删除其实很安全」。分级是注册表作者(人)的决定,不是模型的参数。
审批双向流协议
事件形状(ch15 定稿,本章激活)
v05 在 ch15 的七型事件里激活两型,并给 tool_result 加了一个可选标记:
{ type: 'approval_request', sessionId, requestId, toolName, args, risk }
{ type: 'approval_result', sessionId, requestId, approved, reason? }
{ type: 'tool_result', sessionId, callId, ok, result?, error?, denied? }
requestId:本次审批的唯一编号(我们的实现用req_<callId>)。它把approval_request与approval_result配对,也是回传审批结果的寻址键;denied: true:表示「这个 approve 级调用被人类拒绝、跳过执行」:工具从未运行,ok仍是false,error携带人类的理由。它是tool_result上的可选字段,不影响 ch15 的既有消费方。
一次审批的完整事件序列
approve 级工具调用,callId 配对不变式依然成立(每个 tool_call_start 恰好对应一个 tool_result):
tool_call_start callId, name, args ← 循环决定要调用这个工具
approval_request requestId, toolName, args, risk:'approve' ← 暂停,等人
approval_result requestId, approved, reason? ← 决策回来(审计)
tool_result callId, ok / ok:false + denied:true ← 执行结果 / 拒绝标记
注意顺序:tool_call_start 表示「循环决定了要调用」,随后立刻 approval_request 进入等待,前端看到这两型就知道「这个工具要审批,先别执行」。审批回来后的 approval_result 与 tool_result 告诉前端最终结果。
双向流:前端回传答案
审批是双向的:事件流把 approval_request 推给前端(下行),前端把人类的决定通过 POST /approvals 送回服务器(上行)。服务端是两半之间的桥:
| 方法 | 路径 | 做什么 |
|---|---|---|
POST | /approvals | 回传审批结果 { requestId, approved, reason? };未知/已解决的 requestId 返回 404 |
GET | /approvals | 操作审计:所有历史决策(requestId / 工具 / 参数 / 风险 / 决定 / 理由 / 时间) |
sequenceDiagram
participant LLM as mock LLM
participant LOOP as lib/loop.mjs
participant SRV as lib/server.mjs
participant UI as 前端 / 终端
participant HUM as 人类
LLM->>LOOP: tool_calls: [delete_file({path})]
LOOP->>SRV: emit tool_call_start → approval_request
SRV-->>UI: SSE: approval_request(requestId, toolName, args, risk)
LOOP->>SRV: await getApproval(request)(挂起,等待中)
UI->>HUM: 展示审批卡片/终端提问:批准?
HUM-->>UI: 批准 / 拒绝(附理由)
UI->>SRV: POST /approvals { requestId, approved, reason }
SRV-->>LOOP: resolve 挂起的 promise(决策 + 理由)
LOOP->>SRV: emit approval_result(审计事件)→ tool_result
SRV-->>UI: SSE: approval_result + tool_result(ok / denied)
两个值得停下来的设计点:
- 主循环不知道「前端」存在。loop 只做一件事:
await getApproval(request),一个注入的异步回调。谁提供这个回调、回调内部是终端提问、HTTP 挂起表、还是测试脚本,loop 一概不知。这是 ch14 分层红线的加强版:core 通过注入拿到「人的决定」,不 import 界面。 - 暂停期间 SSE 连接不能断。loop 挂着等审批,可能等几秒到几分钟;服务端的 15 秒注释行保活(ch15 的 keep-alive)在这时候才显出价值。连接活着,审批结果回来时事件才能推出去。
手写实现:把权限层装进产品
一、工具层:risk 字段 + 审批硬校验(lib/tools.mjs)
executeTool 加一个硬校验:approve 级工具只有拿到「匹配的审批记录」才执行,否则返回 denied 数据,不运行 impl:
export async function executeTool(registry, name, args, options = {}) {
const entry = registry.get(name);
if (!entry) return { ok: false, error: `unknown tool: ${name}` };
const risk = entry.spec.risk ?? 'auto';
if (risk === 'approve') {
const approved = options.requestId != null && options.approvedRequestIds?.has(options.requestId);
if (!approved) {
return { ok: false, denied: true, error: `tool ${name} requires approval (risk=approve); call denied` };
}
}
try {
return { ok: true, result: await entry.impl(args) };
} catch (err) {
return { ok: false, error: `Error: ${err.message}` };
}
}
两层防护(纵深防御)由此成型:交互门在主循环(会问人、会暂停),硬门在 executeTool(不认人、只认记录)。任何绕过交互门的调用方(一个忘了审批的集成、一个 bug、未来的某个界面),在硬门前都拿不到执行权。安全默认拒绝(fail closed):拿不出审批记录 = 不执行,而不是「可能没事,放行吧」。
(顺带一个实现细节:impl 可能是异步的,executeTool 要 await 它,否则返回的是 Promise,序列化成 {},异步错误还会逃出 error-as-data。ch15 的工具全是同步的,这一章引入 delete_file 才暴露这个问题。)
二、主循环:审批门(lib/loop.mjs)
loop 的改造集中在 act 阶段:每个 tool call 先查 riskOf(tools, name),approve 级走审批门(permission gate):
for (const call of response.toolCalls) {
emit({ type: 'tool_call_start', callId: call.id, name: call.name, args: call.arguments });
const risk = riskOf(tools, call.name);
if (risk === 'approve') {
const requestId = `req_${call.id}`;
const request = { requestId, toolName: call.name, args: call.arguments, risk };
emit({ type: 'approval_request', ...request });
// THE PAUSE:等人。没注入回调 = fail closed(默认拒绝)。
const decision = getApproval
? await getApproval(request)
: { approved: false, reason: 'no approver configured (fail closed)' };
emit({ type: 'approval_result', requestId, approved: decision.approved, reason: decision.reason });
if (!decision.approved) {
// 拒绝 → 跳过执行,模型能看到为什么(这是它恢复的依据)
const error = `denied by user${decision.reason ? `: ${decision.reason}` : ''}`;
messages.push({ role: 'tool', tool_call_id: call.id, content: error });
emit({ type: 'tool_result', callId: call.id, ok: false, denied: true, error });
continue;
}
// 批准 → 带着审批记录执行(executeTool 会再验一次,纵深防御)
const outcome = await executeTool(tools, call.name, call.arguments,
{ requestId, approvedRequestIds: new Set([requestId]) });
/* ... 成功/失败处理与 ch15 一致 ... */
continue;
}
if (risk === 'suggest') {
emit({ type: 'approval_request', requestId: `req_${call.id}`, toolName: call.name, args: call.arguments, risk });
// 提示而已,不等待、不问人
}
const outcome = await executeTool(tools, call.name, call.arguments);
/* ... 同 ch15 ... */
}
三个关键点:
- 「暂停」就是
await getApproval(request)。Promise 不 resolve,循环就停在这——HITL的「人在环里」在代码里的全部形态就是这一行。没有魔法。 - 拒绝 ≠ 崩溃,拒绝 ≠ 静默。被拒的调用变成一条
tool_result(ok:false, denied:true),消息历史里多了一条「denied by user: …」。模型下一轮能看到它,从而恢复:道歉、换目标、或者向用户确认意图。拒绝是数据,不是错误,和 ch08 的 error-as-data 一脉相承。 approval_result本身就是审计事件。谁批了什么、为什么,已经落在事件流里(服务端的GET /approvals再做一份落库副本)。不需要单独的审计模块。
三、服务端:审批中枢(lib/server.mjs)
服务端实现 getApproval 的方式是「挂起表」:pending Map 存 requestId → resolve,POST /approvals 到达时取出并 resolve:
function createApprovalHub() {
const pending = new Map(); // requestId -> { resolve, request }
const log = []; // 审计:已解决的请求,按决策顺序
return {
getApproval(request) {
return new Promise((resolve) => pending.set(request.requestId, { resolve, request }));
},
resolveApproval({ requestId, approved, reason }) {
const entry = pending.get(requestId);
if (!entry) return false; // 未知/已解决:不能重复生效
pending.delete(requestId);
log.push({ requestId, toolName: entry.request.toolName, args: entry.request.args,
risk: entry.request.risk, approved, reason, at: new Date().toISOString() });
entry.resolve({ approved, reason }); // loop 从这里醒来
return true;
},
approvals() { return log.slice(); },
};
}
POST /approvals 处理器只是把 body 转交给 resolveApproval,GET /approvals 返回 approvals()。审批状态(挂起表)和会话状态一样,是界面层的状态。loop 无状态,删掉 server,core 照常可测(这也是 ch15 坑四「状态塞进 core」的延续)。
四、跑起来看两条分支
demo 用确定性 mock 模型驱动两轮对话:第一轮请求 delete_file(触发审批门),第二轮按审批结果给终答。两个会话分别扮演「批准」和「拒绝」的人类:
--- session s-approve: POST /chat { text: "请把 demo-notes/scratch.txt 删掉。" } ---
[1] event: tool_call_start
data: {"sessionId":"s-approve","type":"tool_call_start","callId":"call_delete","name":"delete_file","args":{"path":"demo-notes/scratch.txt"}}
[2] event: approval_request
data: {"sessionId":"s-approve","type":"approval_request","requestId":"req_call_delete","toolName":"delete_file","args":{"path":"demo-notes/scratch.txt"},"risk":"approve"}
· human decides: POST /approvals { requestId: "req_call_delete", approved: true }
[3] event: approval_result
data: {"sessionId":"s-approve","type":"approval_result","requestId":"req_call_delete","approved":true,"reason":"临时文件,可以删"}
[4] event: tool_result
data: {"sessionId":"s-approve","type":"tool_result","callId":"call_delete","ok":true,"result":"deleted demo-notes/scratch.txt"}
[5] event: text_delta
data: {"sessionId":"s-approve","type":"text_delta","delta":"已删除 demo-notes/scratch.txt。"}
[6] event: done
data: {"sessionId":"s-approve","type":"done","usage":{"inputTokens":42,"outputTokens":24}}
批准分支:审批通过 → 工具真的执行了(文件被删)→ 模型确认删除。拒绝分支走完全一样的协议,区别只在决策:
--- session s-deny: POST /chat { text: "请把 demo-notes/important.txt 删掉。" } ---
[2] event: approval_request
data: {"sessionId":"s-deny","type":"approval_request","requestId":"req_call_delete","toolName":"delete_file","args":{"path":"demo-notes/important.txt"},"risk":"approve"}
· human decides: POST /approvals { requestId: "req_call_delete", approved: false, reason: "重要文件" }
[3] event: approval_result
data: {"sessionId":"s-deny","type":"approval_result","requestId":"req_call_delete","approved":false,"reason":"重要文件,不能删"}
[4] event: tool_result
data: {"sessionId":"s-deny","type":"tool_result","callId":"call_delete","ok":false,"denied":true,"error":"denied by user: 重要文件,不能删"}
[5] event: text_delta
data: {"sessionId":"s-deny","type":"text_delta","delta":"好的,已跳过删除 demo-notes/important.txt。"}
[6] event: done
data: {"sessionId":"s-deny","type":"done","usage":{"inputTokens":42,"outputTokens":24}}
拒绝分支:tool_result 带 denied: true,错误串带人类理由;文件未被删除;模型读到「denied by user」后给出了「好的,已跳过删除」:拒绝没有中断整轮对话,Agent 正常恢复。两个会话的事件流互不串扰(sessionId 分桶,ch15 的机制),审计日志 GET /approvals 能查到两条决策:
{
"approvals": [
{ "requestId": "req_call_delete", "toolName": "delete_file",
"args": { "path": "demo-notes/scratch.txt" }, "risk": "approve",
"approved": true, "reason": "临时文件,可以删", "at": "…" },
{ "requestId": "req_call_delete", "toolName": "delete_file",
"args": { "path": "demo-notes/important.txt" }, "risk": "approve",
"approved": false, "reason": "重要文件,不能删", "at": "…" }
]
}
常见坑与失败模式
坑一:死等审批。 审批门挂起后,如果前端/人类永远不回答(连接断了、界面关了、人走了),loop 就永远停在那——资源挂着、会话卡死、后面的用户请求排队。这是「暂停」机制最直接的风险。解法有三层:审批超时(到时默认拒绝或升级)、待审批状态可持久化(OpenAI Agents SDK 的做法是把暂停的运行序列化成 RunState,存下来、换进程也能恢复并继续,OpenAI Agents SDK · Human-in-the-loop)、以及服务端对挂起请求的清理与告警。我们 v05 的实现是「无限等」(教学优先);真产品里至少要有一个超时。
坑二:超时策略的默认值。 超时到了怎么办?「放行」是危险默认:人类没回答就当批准,等于把审批门(permission gate)变成摆设;「拒绝」是安全默认:没确认就不做,用户事后可以重新要求。本章的 fail-closed 原则一直贯彻到这里:超时 = 拒绝,并像正常拒绝一样记录进审计(带 reason 如 approval timed out)。只有对「确认过的高频安全操作」才考虑短超时自动放行,而且那是显式配置,不是默认。
坑三:拒绝后不恢复。 拒绝被静默吞掉(不发 tool_result、不给模型反馈)是最糟的:模型以为工具成功了,继续往下编答案;或者反复请求同一个被拒的删除,把人烦死。正确做法(本章实现)是拒绝 = 数据:模型能读到「denied by user: 重要文件,不能删」,于是道歉、换目标、或向用户确认。拒绝必须可见、必须可解释。
坑四:审批逻辑/界面塞进 core。 loop 里直接写 readline.question() 或 fetch('/approvals'),core 从此认识界面,换个界面(终端 → Web → 测试)就要改 core。红线不变:core 只认注入的 getApproval 签名;终端提问(cli.mjs)、HTTP 挂起表(server.mjs)、脚本决策(测试)都是界面层的实现。判断标准一句话:把 server 和 cli 删掉,loop 还能不能跑、能不能测?(ch15 坑四的同一个问题,这次问的是审批。)
坑五:getApproval 只返回 boolean,把理由丢了。 审计要记「为什么」,被拒的模型要知道「为什么」。如果回调只返回 true/false,理由就只能靠猜。所以我们的回调签名是 Promise<{ approved, reason? }>:决策和理由一起回来,一起进事件流、一起进审计。没有理由的审批决策,审计里只是一行没有灵魂的 true/false。
坑六:按工具名批准,而不是按调用批准。 「用户批准过 delete_file,所以这轮的所有 delete_file 都放行」:一次批准变成对整个工具的长期授权,审批门名存实亡。我们的 requestId 精确到每一次调用:批了 delete_file(demo-notes/scratch.txt) 不等于批了 delete_file(demo-notes/important.txt)。想要「这次会话里这个工具都放行」是显式策略(如「记住本次选择」),而不是默认行为。
小结
- 动机:Agent 的失败模式(over-eager、诚实失误、prompt injection)都指向「破坏性动作执行前要有人确认」;但「每个动作都问」引发审批疲劳(93% 的提示被随手批准),答案是按风险分级(risk tier),只在真正有破坏潜力的动作上叫人;
- 三档:
auto(零交互)→suggest(发提示但不阻断)→approve(暂停、等人、批准才执行)。风险放工具的 spec 上、不给模型看(策略不是模型知识); - 双向流:
approval_request(下行)→POST /approvals(上行)→approval_result+tool_result(下行);requestId配对,tool_result新增denied标记,callId 配对不变式保持; - 分层红线:loop 通过注入的
getApproval回调拿决定,不 import 界面;终端/HTTP/测试各用自己的实现喂同一个回调;审批挂起状态是界面层状态; - 纵深防御:交互门(loop 暂停问人)+ 硬门(
executeTool无审批记录即拒绝);安全默认拒绝(fail closed,包括「没注入回调」「超时」两种情况); - 审计:
approval_result落事件流 + 服务端GET /approvals落库副本,谁批了什么、为什么,可追溯; - 失败模式六连:死等审批、超时默认值、拒绝后不恢复、审批塞进 core、理由丢失、按工具名而非按调用批准。
下一章(ch19)做安全与沙箱 v06:工作目录约束与路径逃逸防护、Shell 隔离执行、prompt injection 缓解。权限层管「要不要做」,沙箱管「能做多坏」,两者叠加才是完整防线。再下一章(ch20)做可观测性与成本 v07,done.usage 和审批审计都会变成看板上的指标。先把本章练习做完:亲手实现风险判定、审批门、审计日志这三层。
延伸阅读
- Anthropic · How we built Claude Code auto mode:本章动机的核心出处:Agent 四类失败模式、审批疲劳(93% 批准率)、分层裁决(白名单/项目内操作/分类器)。「分级 + 只在真正有破坏潜力时打断」就是 auto mode 的设计哲学。
- OpenAI Agents SDK · Human-in-the-loop:「暂停 → 中断暴露 → 宿主批准/拒绝 → 恢复」的官方参考实现;
RunState序列化让暂停的运行跨进程存活(我们的「死等审批」坑的正式解法)。 - learn-claude-code · s03 权限与授权:Claude Code 的权限治理思路:「先设边界,再给自由」;每工具 allow/ask/deny 三动作与本章三档的对应关系。
- hyyhf/agent-book-code(FunHarness)· Chapter 07 权限系统:《从 0 开始构建 AI 智能体》的三模式权限章(auto/suggest/approve + 审批 UI),本章三档语义的参照底稿(实现为原创)。
- OWASP · Top 10 for Agentic Applications (2026):Agent 安全风险的权威清单(ASI01 目标劫持 → ASI10 失控 Agent),「过度授权/缺失审批」正是其中的一类,可作为把「审批门」讲给非技术读者的素材。