第11章 在动作发生前插一句话——Hooks

小A的 Agent 已经把会话状态接进循环了。做安全评审时他遇到一个问题:模型准备调用写文件工具时,谁能在真正执行前检查路径?他把这个疑问带到了走查会上。
“我在提示词里加一句‘写文件前先检查’就行了,它多半会照做。”小A提议。
“提示词只能建议,不能保证,这赌的是模型按概率续写时恰好照做。”老Z摇头,“提示词顶多让模型‘想检查’,Hook 让你‘能检查’:你写的代码在事件发生时被调用,给运行时留出一个可验证的介入点,动作发生前能真正说‘停’。两条路径完全不同,模型这次漏了,规则就没了;代码只要挂载点还在,规则就一直在。走查会上要回答的,是规则有没有一个不靠模型自觉也能落地的位置。”
本章讨论 Agent 系统中的 Hook(常写作 Agent Hook/Agent Hooks)的拦截、订阅和失败策略;不规定任何框架必须使用的函数名或执行顺序。
先把名字对齐
| 中文术语 | 英文全称或缩写 | 本书工作定义 | 不等于什么 |
|---|---|---|---|
| Agent 钩子 | Agent Hook/Hook | Agent 生命周期事件发生时被调用的应用代码 | 通用行业 API 标准 |
| 拦截器 | interceptor | 可允许、拒绝或改写主路径的 Hook | 只记录日志的监听器 |
| 订阅者/监听器 | subscriber/listener | 观察事件而不改变主路径的接收者 | 审批或授权机制 |
| 生命周期事件 | lifecycle event | 运行时约定的状态转换点 | 任意函数调用 |
| 重入 | reentrancy | Hook 执行时再次触发同一事件链 | 普通异步等待 |
Hook 是运行时的介入点
“那 Hook 到底是什么?”小A问。
“Hook 是在生命周期事件发生时调用的应用代码,可以在动作前给出允许、拒绝或改写后的输入,也可以在动作后记录结果。”老Z想了想,打了个比方,“把它想成安检口:乘客是工具调用,安检员是 Hook。名称和能力由具体实现定义,beforeToolCall、afterToolCall 只是常见命名,不是通用标准。”
“只要在工具前加一个 Hook,就安全了吗?”小A问。
“不能这样推断。Hook 是一层策略执行点,它若没覆盖某条调用路径,或者自身被绕过,风险仍在。”
| 介入方式 | 是否改变主路径 | 适合的职责 |
|---|---|---|
| 拦截 | 可以允许、拒绝或改写 | 审批、参数校验、配额 |
| 订阅 | 不应改变结果 | 审计、指标、调试事件 |
“那拦截和订阅放错了位置,会坏在哪里?”小A问。
“两种坏法。”老Z说,“把日志塞进拦截器,主路径就依赖上一个本不该有副作用的组件:日志服务一变慢或不可用,原本能成功的工具调用被拖垮甚至超时失败,更糟的是排查时你会先怀疑工具本身,想不到是潜伏在拦截链里的日志写入。把审批做成订阅,订阅者收到事件时动作往往已经开始或已经完成,顶多事后报警,根本拦不住,等于让摄像头在乘客上车后才发现他带了违禁品。所以日志归订阅、审批归拦截器,两种职责要分开。”
比喻:安检口
拦截是安检员(能放行、拦下、开箱),订阅是摄像头(只记录、不拦人)。别把摄像头当成安检员。
“那挂在哪一层,才叫‘生命周期事件’?”小A问。
“边界取决于你的运行时定义了哪些可观察的状态转换。”老Z说,“工具调用前后是最常见的挂点,因为动作有明确的发生时刻;轮次开始与结束、会话建立与断开也常被挂上。判断标准是两件事:这个转换点是否稳定可观察,以及挂在它上面的代码是否在你需要的时刻恰好跑完。事件定义得越粗,拦截就越钝;定义得越细,挂载成本和失配风险越高。”
“粗和细各是什么样子?”小A接着问。
“粗到‘整轮对话’一个事件,你想拦‘写文件’就得自己从整轮内容里解析,等于重新实现了一次生命周期判断,还未必拦得住内部调用;细到每次 token 输出都算事件,光记录本身就淹没主路径。常见的折中是两层:按工具动作定义事件,拦截精确到单次调用;按轮次定义状态,观察用于汇总和审计。粒度定在哪一层,策略就只能精确到哪一层,这要写进契约。”
“拦截器和订阅者出错,影响面一样吗?”
“完全不一样。”老Z说,“拦截器在决策路径上,它一旦抛异常或超时,主路径要么卡死、要么带着不完整的决策继续,两种都可能出错。订阅者在旁路上,出错本不该影响主路径,但‘本不该’取决于框架怎么实现:订阅者的异常被并进主路径,或者主路径串行等待一个慢订阅者,旁路就变成了主路径的一部分。所以契约里要写明订阅者的失败隔离方式。”
“忽略、记录、向上抛,各是什么后果?”
“忽略异常,日志服务坏了主路径照常跑,代价是那段时间没有审计证据;向上抛,任何一条坏掉的订阅回调都能让调用失败,主路径变得极度脆弱;折中是给订阅者单独一条记录通道,异常写不进主链路也留得下痕迹。这一条不写明,实现者多半挑最容易写的那种,通常是静默吞掉,等出事了才知道连证据都没留下。”
先定义事件,再定义契约
“那拦截器到底该返回什么?”小A问。
“先定义事件,再定义契约。一个最小工具调用生命周期可写成:”
产生调用请求 → 参数校验 → 预执行策略 → 执行工具 → 后处理 → 记录事件“示意中的预执行策略必须明确返回语义:允许、拒绝、需要人工确认,或替换输入。”老Z说,“拒绝也应当有结构化原因,供调用方、模型和审计记录分别使用,不要只静默丢弃请求。”
“多个 Hook 一起挂,按注册顺序串行跑,应该就够了吧?”小A猜。
“不够,顺序是契约的一部分。”老Z说,“按注册顺序串行是常见方案,但它不是天然正确:并行订阅可能乱序,串行订阅又会把慢观察者变成主路径瓶颈。所以要把顺序写进契约:先按职责分层,安全类策略必须先跑(路径边界、权限校验),业务类策略其次(配额、改写),纯观察者最后(日志、指标);同一层内有多于一个 Hook,要么显式标优先级,要么约定短路顺序。别让顺序由注册时机的偶然性决定,否则换个启动顺序,行为就变了。优先级、短路规则、超时和取消传播,这几样都要写清。”
“并行订阅乱序,会有什么实际问题?”
“主要是审计和指标的时间顺序失真。”老Z说,“比如两个订阅者分别记录‘调用开始’和‘调用结束’,若它们并行且乱序,日志里可能出现‘结束’排在‘开始’之前。这不影响主路径,但会让排障时的事件链对不上。所以并行订阅适合不在乎顺序的指标聚合,需要顺序的事件链应串行记录,或带上单调递增的事件 ID。”
“那这个顺序分层,能不能列成一张表?”
老Z在板上写下:
| 层级 | 示例职责 | 顺序要求 | 常见失败 |
|---|---|---|---|
| 安全策略 | 路径边界、权限校验 | 必须先跑 | 未规范化参数就做决策 |
| 业务策略 | 配额、审批、改写 | 在安全策略之后 | 依赖上游策略的结果 |
| 观察者 | 日志、指标、事件记录 | 不要求先行 | 慢观察者拖垮串行链 |
“同一层里挂多个 Hook,先后又怎么定?”小A追问。
“前面说的标优先级、约短路顺序都行,另外有两个反例要记住:同一层内并行执行,顺序不可复现,任何依赖先后结果的写法都会出错;同一层内串行执行,排在最前的一个慢 Hook 会拖住后面所有 Hook。顺序本身没有对错,可复现才要紧:只要两次运行的行为能被同一份日志解释,这个顺序就是可管理的。”
一个受控的确认点
“能给我看一个最小实现吗?”小A问。
“下面是简化示意,不是某个框架的 API:”
type Decision = { allow: true } | { allow: false; reason: string };
async function approveWrite(path: string): Promise<Decision> {
if (!path.startsWith(workspaceRoot + "/")) {
return { allow: false, reason: "路径超出工作区" };
}
return await requestHumanApproval({ action: "write", path });
}“确认放行了一次,是不是就代表路径安全?”小A问。
“不是。确认结果只解决这一次动作,替代不了路径解析、防符号链接逃逸、凭据隔离和执行环境限制。”老Z说,“还有一层:策略得检查规范化后的实际参数,不然拒绝原因会被模型拿去改写措辞绕过去。”
“改写措辞绕过规则,具体是什么样?”
“假设拦截器拒绝路径以 ../ 开头的写操作,并把这条原因返回给模型。”老Z说,“模型可能下一轮把路径改成 ./subdir/../../etc/,措辞变了、字面上不以 ../ 开头,规范化后却指向同一目标。策略只做字符串匹配、不做路径规范化,就被绕过了。”
“那 Hook 既然能改写输入,能不能把危险参数自动改成安全的?”小A又问。
“能改,但要非常克制,改写比拒绝更危险,它在悄悄替换模型意图。”老Z说,“比如把路径 ../../etc/passwd 改成 ./local/passwd,看似无害,模型却可能基于原路径继续推理,后续动作与改写后的实际状态对不上。所以改写要么把改写结果回显给模型,让它知道真实参数变了,要么干脆拒绝,让模型重新决定。静默改写等于在主路径里埋了一个不一致。”
“拒绝的时候,模型会不会反复换写法再试?”
“会,这是‘拒绝风暴’。”老Z说,“你把‘路径越界’的拒绝原因返回给模型,它下一轮就换个写法再来一次,每次都要走一遍完整策略链。所以拒绝原因要给到模型能看懂的粒度:具体越过了哪条边界、规范化后的实际路径是什么。对同一动作的连续拒绝,要么限频、要么转人工,策略端要假设模型一定会拿着原因回来再试。”
“那‘需要人工确认’这条返回语义呢?”小A继续问。
“它是介于允许和拒绝之间的第三种状态:主路径停在等人工决定,模型拿不到最终结果。”老Z说,“这里有个粒度问题:一次动作确认一次,还是同类动作授权一段时间?粒度太细,人工要被来回打断;粒度太粗,授权窗口里同类动作全放行,等于把决策权外包给了时间。所以确认粒度要能回滚:每次授权都记录授权范围、期限和被放行的动作,事后可撤销、可审计。”
“那人工确认挂出去之后,进程崩了怎么办?”
“挂起的确认是持久化问题,不是内存问题。”老Z说,“如果待确认状态只留在内存里,进程一重启,那条等着人点按钮的调用就凭空消失了,人点了确认,系统却不知道。要么把待确认状态落盘,重启后恢复并继续等待;要么把确认超时设为短时限,重启后直接按超时拒绝。”
“确认超时和策略超时,是不是一回事?”
“不是。策略超时防的是下游依赖不可用,确认超时防的是人迟迟不回应。”老Z说,“确认请求挂出去人没点,这条调用就一直悬着,所以要设明确的等待上限,超时按拒绝处理,或者允许用户主动取消。取消要能传播:用户取消时,等待中的确认和已经发起的动作都要收到信号。挂起的确认还会叠加,用户一次点了多个,就得能批量取消。这几样不说清楚,确认点就是一条卡死主路径的隐性等待。”
“能不能把整个决策链画出来?”小A问。
老Z把决策链画了出来:
模型提交原始文本
↓
规范化:解析、归一化、解析符号链接
↓
决策:允许 / 拒绝(结构化原因)/ 需要人工 / 改写
↓
记录:事件ID、策略版本、输入摘要、结果、耗时“这张图里,决策一定发生在规范化之后。”老Z强调,“顺序反了,规则就作用在模型提交的原始文本上,./subdir/../../etc/ 那类绕过就成立了。”
代价与可观测性
“那 Hook 的代价是什么?”小A问。
“Hook 增加了隐式控制流。排查一次工具失败时,除了工具实现,还要查看哪个策略改变了输入、拒绝了请求或超时。”老Z说,“建议每次决策记录事件 ID、策略版本、输入摘要、结果和耗时;日志中不记录密钥或完整敏感内容。”
“有 Hook 把关,工具是不是就不用自己校验了?”小A猜。
“反了,Hook 不应成为唯一边界。”老Z说,“必须执行的规则,工具本身仍需验证输入并在最小权限下运行;只想观察的规则,失败策略要与主路径解耦,并明确丢失事件是否可接受。”
“Hook 跳过或被绕过,具体发生在什么时候?”
“主要有三种情形。”老Z说,“一是调用根本没经过挂载点,比如某个内部直接调用的工具绕过了公共入口;二是配置错误或版本不匹配,Hook 没被注册到那条路径上;三是 Hook 自身抛了异常被框架静默吞掉,看起来在其实没起作用。”
“那 Hook 还有什么存在的意义?”小A接着问。
“两者的责任层次不同。”老Z说,“工具自校验是底线,它在最小权限下运行、拒绝越界输入,保证即使 Hook 缺席,规则仍然成立。Hook 是集中策略点,把审批、配额、改写这些跨工具的策略收敛到一个地方,便于审计和统一变更。底线防的是 Hook 失效,集中点防的是每个工具各写一套规则、迟早不一致。两者是叠加,不是替代。”
“前面说的三种失效情形,能列表对照着排查吗?”
| 失效情形 | 典型表现 | 排查入口 |
|---|---|---|
| 调用未经过挂载点 | 内部路径直接调工具,绕过公共入口 | 对比公共入口与内部调用路径 |
| 未注册或版本不匹配 | Hook 没挂到目标路径上 | 检查注册配置与部署版本 |
| 异常被静默吞掉 | 看起来生效,实际没生效 | 核对 Hook 执行次数与决策记录 |
“这些怎么才能第一时间发现?”
“靠计数和对比。”老Z说,“给每个挂载点记录‘事件发生了多少次、Hook 参与决策了多少次’,两者对不上就是失效信号;给每次决策记版本号,升级后旧版本还出现的记录,就是没被替换的残留。失效往往不是一次性 bug,而是路径漂移:代码改了、配置改了,挂载关系没跟上,规则就悄悄脱钩。”
“记这么多,会不会反而拖慢主路径?”
“会,所以记录本身也要分层。”老Z说,“决策的关键字段,事件 ID、策略版本、结果,同步写,排障时必须要有;耗时和输入摘要可以异步写或抽样,丢了不致命。但异步意味着事件还没落盘就可能丢,丢失容忍度要写进策略。”
“那规则本身错了,和 Hook 没生效,怎么区分?”
“看决策记录。”老Z说,“记录显示‘策略按规则放行,但结果出了问题’,那是规则写得不对,校验条件缺了、规范化漏了、规则覆盖范围不够;记录显示策略本应触发却没触发,那是挂载失效。两种问题的修复方向完全不同:前者改策略,后者修挂载。没有决策记录,这两类问题看起来就是同一种‘出了事’。”
“Hook 本身要不要写测试?”
“要,而且测的是‘不变量’而不是‘实现’。”老Z说,“三个不变量值得钉死:工作区外的路径必然被拒、日志故障不阻塞主路径、同一事件重复进入同一 Hook 不会二次执行。测试时把下游依赖,审批服务、日志库,换成可控的替身,分别注入慢响应、抛异常、断连,验证上述不变量仍然成立。Hook 要测的是契约成不成立,不只是代码写没写对。”
重入与错误传播
“Hook 里再调工具,会不会出问题?”小A问。
“会。拦截器改写请求后若再次触发同一事件,就形成重入,实现应传递事件 ID 或深度标记,并规定同一策略是否只能运行一次。”老Z说,“示意一下:beforeWrite 为备份调用写工具时,备份写入不应再次触发同一审批链。策略错误也必须有分类:强制策略失败默认拒绝,纯观察者失败记录并与主路径分离,两种选择都要有超时上限。”
“重入除了递归,还有别的形态吗?”小A追问。
“有跨 Hook 的间接重入。”老Z说,“拦截器 A 改写请求后,触发拦截器 B,B 又调用了某个工具,那个工具再次经过 A,链路绕了一圈回到 A。这种重入比直接递归更隐蔽,因为没有任何一个 Hook 单独看起来有问题,防护也就不能指望各自小心,得靠事件 ID 或深度标记这样的机制。”
“强制策略失败默认拒绝,会不会把系统卡死?”
“会,这正是它要配超时的原因。”老Z说,“默认拒绝防的是‘不确定时放行’带来的安全风险,可它一旦遇到下游依赖不可用,比如审批服务挂了,所有写操作就都会失败。所以强制策略要设超时,超时后是降级拒绝还是降级放行,看这条规则的安全权重:安全关键的策略超时即拒绝,业务策略超时可以放行。”
“那间接重入的环路,能画出来看看吗?”小A问。
老Z画了这条绕回来的链:
拦截器A改写请求
↓
拦截器B执行 → 调用工具 C
↓
C 的调用再次进入 A ──→ 绕回原链路
↑ │
└── 事件ID 检测到同一标识 ──┘“没有事件 ID,这一圈会无限绕下去;有了它,A 看到同一标识,直接放行或拒绝。”老Z说,“深度标记也同理:规定同一事件最多嵌套几层,超过即中断,比试图在逻辑上排除所有环路更现实。”
“那失败策略的分类,有没有一张统一的对照?”
老Z列了张对照表:
| 策略类型 | 失败时默认行为 | 配套超时 | 主要风险 |
|---|---|---|---|
| 强制策略 | 拒绝 | 超时即拒绝 | 依赖不可用时全面卡死 |
| 业务策略 | 按安全权重降级 | 超时可放行 | 放行本应拦截的动作 |
| 纯观察者 | 与主路径解耦 | 不阻塞主路径 | 事件丢失不可恢复 |
“降级决策本身,要不要记录?”小A问。
“要,而且要比正常决策记得更详细。”老Z说,“降级是‘在依赖不可用时临时改了规则’,这类临时状态最容易被忘记。放行过一次的动作、拒绝过一次的动作,都要留原因和时间;模式切换(今天放行、明天拒绝)还要留是谁、在什么时候切回去的。否则排障时会看到同一类请求时而被放、时而被拒,却不知道边界在哪。降级是有时效的策略变更,得有和策略本身一样的版本和记录。”
让契约能在故障中成立
“审计和审批在故障时怎么处理?”小A问。
“同一事件的同步 Hook 会把结果直接纳入主路径,适合审批和参数改写;异步订阅适合日志、指标和通知,但必须明确是否等待完成。”老Z说,“要是‘记录审计’失败就阻断写文件,审计服务会意外成为可用性单点;要是审批作为异步订阅,动作已经发生。默认失败策略要逐项写明,不能笼统地一句‘失败即放行’或‘失败即拒绝’。”
“能串一个完整场景吗?”
“贯穿场景:模型请求把构建产物上传到外部。路径检查先规范化路径并判断工作区边界,外发审批再显示实际 URL 和文件摘要,审计订阅记录决策。”老Z说,“正常路径是检查通过、确认后上传;失败路径一是审批超时,返回拒绝结果且不发请求;二是审计写入失败,按既定策略报警但不重试上传;三是用户取消,传递 AbortSignal 并停止等待。”
“能把这个场景画成一条链吗?”
老Z在板上画了一条链:
模型请求上传构建产物
↓
路径检查:规范化 + 工作区边界 ── 越界 → 拒绝并返回原因
↓
外发审批:显示实际 URL 与文件摘要 ── 超时/拒绝 → 不发请求
↓
执行上传
↓
审计订阅:幂等键去重记录 ── 写入失败 → 报警,不重试上传“注意每条失败路径都在链上不同位置。”老Z说,“路径检查和审批是同步拦截,失败发生在动作前,模型拿得到原因、可以改;审计是异步订阅,失败发生在动作后,只能报警补救。失败发生在哪个位置,决定了补救手段是什么:想在动作前拦,就放拦截器;只想事后留痕,就放订阅。这个场景里同一个动作两种通道都走,拦在前的负责安全,记在后的负责可观测。”
“那顺序和重入的规则怎么定?”小A问。
“顺序、短路与重入的规则也属于 API。”老Z说,“优先级沿用前面的分层:先安全策略、后业务策略、再观察;拦截一旦拒绝便短路,订阅不能偷偷恢复它。Hook 内部若再次触发同一事件,同样要有事件 ID 或深度限制,避免递归审批。对可能重试的动作,审计与通知使用幂等键,避免一次上传失败重试产生多条‘已上传’记录。”
“幂等键具体怎么避免重复记录?”小A追问。
“每次动作带一个唯一的键,审计订阅按这个键去重。”老Z说,“动作失败重试时,重试和原调用共享同一个键;审计写入时先查这个键是否已记录,已记录就更新状态而不是新增一条。没有幂等键,一次失败重试会被记成两次‘已上传’,审计日志就和真实世界对不上。幂等键的生成应在动作发起处,而不是 Hook 里,否则不同 Hook 看到的键可能不一致。”
“那取消信号怎么在多个 Hook 之间传?”小A接着问。
“用 AbortSignal 之类的显式取消通道,沿着调用链向下传。”老Z说,“用户取消时,信号置位;每个 Hook 和工具实现都应监听这个信号,收到就停止等待、释放资源。有一点要写进契约:取消是协作式的,不是强制的。它请求 Hook 停下,但 Hook 是否真的能立即响应,取决于它当前是否在可中断的等待上,一个卡在不可中断操作里的 Hook,仍然会拖到完成或超时。”
“那拒绝原因返回给模型,算不算泄露内部策略?”小A问。
“分两层。”老Z说,“给模型看的,是结构化的‘为什么不行’:越过了哪条边界、需要满足什么条件;不给模型看的,是内部实现细节:策略文件在哪、权限系统的账号结构、审批人的联系方式。原因是对话的一部分,模型需要它来调整下一步;策略实现是系统的内幕,模型不需要。返回值要能指导模型修正,又不暴露实现面,通常做法是给理由模板,而不是拼接原始内部信息。”
“模型拿着原因,就一定能修正到合规吗?”
“不一定。”老Z说,“可能它没看懂原因,也可能理解了规则却做不到,比如权限确实不够。所以自动重试要有次数上限,到上限就停下来转人工,不能假设给了原因它就会自己修正。”
小结
Hook 是搭在 Agent 生命周期事件上的受控介入点,它让外部代码能在关键时刻插话。这里的“插话”分两种:拦截器可以改变或阻止即将发生的动作,比如在工具执行前拦下一次危险的调用;订阅者只观察已经发生的事件,比如记录日志或刷新界面。两者的边界必须分清,拦截器出错可能让整个回合失败,订阅者出错的影响取决于事件管道如何调用和捕获。只有当订阅者与主路径隔离时,失败才可能只影响日志或指标;否则监听器异常可以沿调用链传播。一个 Hook 是否可靠,取决于四件事的工程设计:多个 Hook 之间的执行顺序、每个 Hook 的超时处理、取消信号如何传递、以及关键动作有没有审计记录。少了任何一项,Hook 就会从“可控的介入”退化成“不可预测的干扰”。
这四件事背后是同一条原则:把顺序和失败写进契约,而不是留给运行时碰运气。顺序上,安全策略先跑、业务策略其次、观察者最后,同一层内靠显式优先级或短路规则保证可复现;换个启动顺序行为就变,是顺序没有成文的典型信号。失败策略上,强制策略失败默认拒绝但要配超时,业务策略按安全权重降级,观察者与主路径解耦并明确事件丢失是否可接受;逐项写明,而不是笼统套一个“失败即放行”或“失败即拒绝”。拦截与订阅的分层,加上同步与异步通道的区分,决定了同一动作在“动作前拦截”和“动作后留痕”两条路径上各自该放什么。
Hook 最容易踩的坑是重入:一个 Hook 在执行时又触发了同一类事件,从而无限递归。这要求 Hook 的实现必须显式防护,不能假设“我不会触发自己”。直接递归之外还有跨 Hook 的间接环路,任何单个 Hook 单独看都正常,连起来才构成死循环,所以防护要依赖事件 ID 或深度标记这类机制,而不是靠写代码时小心。除此之外,拦截器还有两类固有风险:静默改写会在主路径里埋不一致,模型拿着拒绝原因反复换写法会形成拒绝风暴。改写要么回显给模型、要么干脆拒绝;拒绝原因要结构化,让模型看得懂也绕不过。
还要记住,Hook 是软扩展点,不是硬安全层:它能提供拦截时机,但缺席时放行还是拒绝由宿主策略决定,不能视为通用默认;它能观察,但观察不等于控制。真正的安全约束仍然要由工具、权限和执行环境承担。Hook 的失效也往往不是一次性 bug,而是路径漂移,调用没经过挂载点、配置没注册、异常被吞掉,三种情形下规则会悄悄脱钩。要靠“事件发生次数与决策次数对得上、版本升级后旧版本不残留”这类信号去发现,而不是等事故暴露。把职责、顺序、失败策略和可观测性都设计清楚,Hook 才是那个“可验证的介入点”;设计不清,它就只是多一层不知道什么时候会出错的隐式控制流。
动手核验
- 为一个写文件动作列出事件、输入、允许/拒绝结果和审计字段。
- 给两个策略设定顺序与超时:路径边界检查必须先于人工确认;日志失败不得放行或阻塞写入。
- 让工具实现自行拒绝工作区外路径,再验证即使跳过 Hook,拒绝仍然成立。
