Skip to content

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

小a的 Agent 已经把会话状态接进循环了。他在做安全评审时遇到一个问题:模型准备调用写文件工具时,谁能在真正执行前检查路径?他把这个疑问带到了走查会上。

“我在提示词里加一句‘写文件前先检查’,不就行了吗?”小a提议。

“提示词只能建议,不能保证。”老z摇头,“你要的不是让模型‘记得检查’,而是给运行时留出一个可验证的介入点——动作发生前,你的代码能真正说‘停’的地方。”

“可模型不是会遵循提示词吗?”小a问,“让它先检查再写,它多半会照做。”

“那是在赌概率。”老z说,“提示词是让模型‘想检查’,Hook 是让你‘能检查’。前者依赖模型按概率续写时恰好照着做,后者依赖你写的代码在事件发生时被调用——两条完全不同的路径。模型漏了,规则就没了;代码只要挂载点还在,规则就一直在。走查会上真正的问题不是‘模型会不会自觉’,而是‘规则有没有一个不靠模型自觉也能落地的位置’。”

本章讨论 Agent 系统中的 Hook(常写作 Agent Hook/Agent Hooks)的拦截、订阅和失败策略;不规定任何框架必须使用的函数名或执行顺序。

先把名字对齐 ​

中文术语英文全称或缩写本书工作定义不等于什么
Agent 钩子Agent Hook/HookAgent 生命周期事件发生时被调用的应用代码通用行业 API 标准
拦截器interceptor可允许、拒绝或改写主路径的 Hook只记录日志的监听器
订阅者/监听器subscriber/listener观察事件而不改变主路径的接收者审批或授权机制
生命周期事件lifecycle event运行时约定的状态转换点任意函数调用
重入reentrancyHook 执行时再次触发同一事件链普通异步等待

Hook 是运行时的介入点 ​

“那 Hook 到底是什么?”小a问。

“Hook 是在生命周期事件发生时调用的应用代码。它可以在动作前给出允许、拒绝或改写后的输入,也可以在动作后记录结果。”老z想了想,打了个比方,“你可以把它想成安检口:乘客(工具调用)进站前,安检员(Hook)有权放行、拦下或要求开箱检查。名称和能力由具体实现定义;beforeToolCall、afterToolCall 只是常见命名,不是通用标准。”

“只要在工具前加一个 Hook,就安全了吗?”小a问。

“不能这样推断。Hook 是一层策略执行点;它若没有覆盖某条调用路径,或其自身被绕过,风险仍在。”

介入方式是否改变主路径适合的职责
拦截可以允许、拒绝或改写审批、参数校验、配额
订阅不应改变结果审计、指标、调试事件

“那拦截和订阅有什么区别?”小a问。

“**把日志塞进拦截器会让正常执行依赖日志服务;把审批做成订阅则根本无法阻止动作。**两种职责应当分开。”

比喻:安检口

拦截是安检员(能放行、拦下、开箱),订阅是摄像头(只记录、不拦人)。别把摄像头当成安检员。

“那挂在哪一层,才叫‘生命周期事件’?”小a问。

“边界取决于你的运行时定义了哪些可观察的状态转换。”老z说,“工具调用前后是最常见的挂点,因为动作有明确的发生时刻;轮次开始与结束、会话建立与断开也常被挂上。判断标准是两件事:这个转换点是否稳定可观察,以及挂在它上面的代码是否在你需要的时刻恰好跑完。事件定义得越粗,拦截就越钝;定义得越细,挂载成本和失配风险越高。”

“事件粒度粗了或细了,具体什么样?”小a追问。

“粗到‘整轮对话’一个事件,你想拦‘写文件’就得自己从整轮内容里解析——等于重新实现了一次生命周期判断,还未必拦得住内部调用;细到每次 token 输出都算事件,光记录本身就淹没主路径。常见的折中是两层:按工具动作定义事件,拦截精确到单次调用;按轮次定义状态,观察用于汇总和审计。事件粒度决定策略能精确到哪一层,这也该写进契约。”

“那日志做成拦截器,到底会坏在哪里?”小a追问。

“坏在主路径会依赖一个本不该有副作用的组件。”老z说,“日志服务一旦变慢或不可用,原本能成功的工具调用就被拖垮,甚至超时失败。更糟的是,排查时你会先怀疑工具本身,而不是那个潜伏在拦截链里的日志写入。拦截器里的任何代码,都成了工具调用能否成功的前提;想观察而不担责,就该走订阅。”

“反过来,审批做成订阅又错在哪?”小a又问。

“错在‘观察者拦不住动作’。”老z说,“订阅者收到事件时,动作往往已经开始或已经完成,它顶多能事后报警,不能阻止。把审批放在订阅里,等于让摄像头在乘客上车后才发现他带了违禁品——已经晚了。审批必须放在能说‘停’的位置,也就是拦截器。”

“那拦截器和订阅者出错,影响面一样吗?”小a又问。

“完全不一样。”老z说,“拦截器在决策路径上:它一旦抛异常或超时,主路径要么卡死、要么带着不完整的决策继续,两种都可能出错。订阅者在旁路上:它出错本不该影响主路径,但‘本不该’取决于框架怎么实现——订阅者的异常被并进主路径、或者主路径串行等待一个慢订阅者,旁路就变成了主路径的一部分。契约里要写明订阅者的失败隔离方式:异常被忽略、被记录还是向上抛,三种写法的可用性完全不同。”

“失败隔离方式不同,后果能举例吗?”小a追问。

“能。忽略异常,日志服务坏了主路径照常跑,代价是那段时间没有审计证据;向上抛,任何一条坏掉的订阅回调都能让调用失败,主路径变得极度脆弱;折中是给订阅者单独一条记录通道,异常写不进主链路也留得下痕迹。没有写明,实现者就会挑最容易写的那种,通常是静默吞掉——等出事了才知道连证据都没留下。”

先定义事件,再定义契约 ​

“那拦截器到底该返回什么?”小a问。

“先定义事件,再定义契约。一个最小工具调用生命周期可写成:”

text
产生调用请求 → 参数校验 → 预执行策略 → 执行工具 → 后处理 → 记录事件

“示意中的预执行策略必须明确返回语义:允许、拒绝、需要人工确认,或替换输入。”老z说,“拒绝也应当有结构化原因,供调用方、模型和审计记录分别使用;不要只静默丢弃请求。”

“多个 Hook 一起挂,顺序重要吗?”小a问。

“重要。多个 Hook 的顺序同样是契约的一部分。”老z说,“常见方案是按注册顺序串行执行,但这不是天然正确:并行订阅可能乱序,串行订阅又会把慢观察者变成主路径瓶颈。设计时至少要写清优先级、短路规则、超时和取消传播。”

“顺序不确定时,能写出确定的策略吗?”小a追问。

“能,靠把顺序写进契约。”老z说,“先按职责分层:安全类策略必须先跑(路径边界、权限校验),业务类策略其次(配额、改写),纯观察者最后(日志、指标)。同一层内若仍有多于一个 Hook,要么显式标优先级、要么约定短路顺序。关键是别让顺序由注册时机的偶然性决定——否则换个启动顺序,行为就变了。”

“并行订阅乱序会带来什么实际问题?”小a又问。

“主要是审计和指标的时间顺序失真。”老z说,“比如两个订阅者分别记录‘调用开始’和‘调用结束’,若它们并行且乱序,日志里可能出现‘结束’排在‘开始’之前。这不影响主路径,但会让排障时的事件链对不上。所以并行订阅适合不在乎顺序的指标聚合,需要顺序的事件链应串行记录或带单调递增的事件 ID。”

“那这个顺序分层,能不能列成一张表?”小a问。

老z在板上写下:

层级示例职责顺序要求常见失败
安全策略路径边界、权限校验必须先跑未规范化参数就做决策
业务策略配额、审批、改写在安全策略之后依赖上游策略的结果
观察者日志、指标、事件记录不要求先行慢观察者拖垮串行链

“同一层里挂多个 Hook,先后又怎么定?”小a追问。

“要么显式标优先级,要么约定短路顺序。”老z说,“但有两个反例要记住:同一层内并行执行,顺序不可复现,任何依赖先后结果的写法都会出错;同一层内串行执行,排在最前的一个慢 Hook 会拖住后面所有 Hook。顺序本身不是对错问题,可复现性才是——只要两次运行的行为能被同一份日志解释,这个顺序就是可管理的。”

一个受控的确认点 ​

“能给我看一个最小实现吗?”小a问。

“下面是简化示意,不是某个框架的 API:”

ts
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说,“更重要的是,拒绝原因不应让模型通过改写措辞绕过同一规则,策略应检查规范化后的实际参数。”

“模型改写措辞绕过规则,具体是什么样?”小a追问。

“假设拦截器拒绝路径以 ../ 开头的写操作,并把这条原因返回给模型。”老z说,“模型可能下一轮把路径改成 ./subdir/../../etc/,措辞变了、字面上不以 ../ 开头,但规范化后指向同一目标。如果策略只做字符串匹配、不做路径规范化,就被绕过了。规则要作用在规范化后的真实参数上,而不是模型提交的原始文本上。”

“那 Hook 既然能改写输入,能不能把危险参数自动改成安全的?”小a又问。

“能改,但要非常克制。”老z说,“改写比拒绝更危险——它在悄悄替换模型意图。比如把路径 ../../etc/passwd 改成 ./local/passwd,看似无害,但模型可能基于原路径继续推理,导致后续动作与改写后的实际状态对不上。改写要么配合向模型回显改写结果、让它知道真实参数变了,要么干脆拒绝让模型重新决定。静默改写等于在主路径里埋了一个不一致。”

“拒绝的时候,模型会不会反复换写法再试?”小a又问。

“会,这是‘拒绝风暴’。”老z说,“你把‘路径越界’的拒绝原因返回给模型,它下一轮就换个写法再来一次,每次都要走一遍完整策略链。所以拒绝原因要给到模型能看懂的粒度——具体越过了哪条边界、规范化后的实际路径是什么;对同一动作的连续拒绝,要么限频、要么转人工。拒绝是决策循环的一环,不是终点,策略端要假设模型一定会拿着原因回来再试。”

“那‘需要人工确认’这条返回语义呢?”小a继续问。

“它是介于允许和拒绝之间的第三种状态。”老z说,“返回这个语义时,主路径停在等人工决定,模型拿不到最终结果。这里有个粒度问题:一次动作确认一次,还是同类动作授权一段时间?粒度太细,人工要被来回打断;粒度太粗,授权窗口里同类动作全放行,等于把决策权外包给了时间。确认粒度要能回滚:每次授权都应记录授权范围、期限和被放行的动作,事后可撤销、可审计。”

“那人工确认挂出去之后,进程崩了怎么办?”小a问。

“挂起的确认是持久化问题,不是内存问题。”老z说,“如果待确认状态只留在内存里,进程一重启,那条等着人点按钮的调用就凭空消失了——人点了确认,系统却不知道。要么把待确认状态落盘、重启后能恢复并继续等待;要么把确认超时设为短时限,重启后直接按超时拒绝。**人在等待期间做的决定,需要有一个它存在的地方。**取消也要能传播:用户取消时,等待中的确认和已经发起的动作都要收到信号。”

“确认超时和策略超时,是不是一回事?”小a又问。

“不是。策略超时防的是下游依赖不可用,确认超时防的是人迟迟不回应。”老z说,“确认请求挂出去,人没点,这条调用就一直悬着——要么设明确的等待上限,超时按拒绝处理;要么允许用户主动取消。挂起的确认还会叠加:用户一次点了多个确认,就要能批量取消。确认点是把主路径交给人来决定的位置,超时、取消、批量管理都得说清楚,否则它就是一条卡死主路径的隐性等待。”

“能不能把整个决策链画出来?”小a问。

老z画道:

text
模型提交原始文本
   ↓
规范化:解析、归一化、解析符号链接
   ↓
决策:允许 / 拒绝(结构化原因)/ 需要人工 / 改写
   ↓
记录:事件ID、策略版本、输入摘要、结果、耗时

“这张图里,决策一定发生在规范化之后。”老z强调,“顺序反了,规则就作用在模型提交的原始文本上,./subdir/../../etc/ 那类绕过就成立了。”

代价与可观测性 ​

“Hook 有什么代价?”小a问。

“Hook 增加了隐式控制流。排查一次工具失败时,除了工具实现,还要查看哪个策略改变了输入、拒绝了请求或超时。”老z说,“建议每次决策记录事件 ID、策略版本、输入摘要、结果和耗时;日志中不记录密钥或完整敏感内容。”

“那 Hook 是不是唯一的防线?”小a问。

“不是。对于必须执行的规则,Hook 不应成为唯一边界:工具本身仍需验证输入并在最小权限下运行。”老z说,“对于只想观察的规则,失败策略通常应与主路径解耦,并明确丢失事件是否可接受。”

“Hook 跳过或被绕过,具体发生在什么时候?”小a追问。

“主要有三种情形。”老z说,“一是调用根本没经过挂载点——比如某个内部直接调用的工具绕过了公共入口;二是配置错误或版本不匹配,Hook 没被注册到那条路径上;三是 Hook 自身抛了异常被框架静默吞掉,看起来在其实没起作用。这三种情形下,工具若不自带校验,规则就彻底失效。”

“那 Hook 还有什么存在的意义?工具自己校验不就够了?”小a又问。

“两者的责任层次不同。”老z说,“工具自校验是底线——它在最小权限下运行、拒绝越界输入,保证‘即使 Hook 缺席,规则仍成立’。Hook 是集中策略点——它把审批、配额、改写这些跨工具的策略收敛到一个地方,便于审计和统一变更。底线防的是‘Hook 失效’,集中点防的是‘每个工具各写一套规则、迟早不一致’。两者是叠加,不是替代。”

“前面说 Hook 有三种失效情形,能不能列表对照着排查?”小a问。

失效情形典型表现排查入口
调用未经过挂载点内部路径直接调工具,绕过公共入口对比公共入口与内部调用路径
未注册或版本不匹配Hook 没挂到目标路径上检查注册配置与部署版本
异常被静默吞掉看起来生效,实际没生效核对 Hook 执行次数与决策记录

“这些怎么才能第一时间发现?”小a追问。

“靠计数和对比。”老z说,“给每个挂载点记录‘事件发生了多少次、Hook 参与决策了多少次’,两者对不上就是失效信号;给每次决策记版本号,升级后旧版本还出现的记录,就是没被替换的残留。失效往往不是一次性 bug,而是路径漂移——代码改了、配置改了,挂载关系没跟上,规则就悄悄脱钩了。”

“记这么多,会不会反而拖慢主路径?”小a又问。

“会,所以记录本身也要分层。”老z说,“决策的关键字段——事件 ID、策略版本、结果——同步写,排障时必须要有;耗时和输入摘要可以异步写或抽样,丢了不致命。但异步意味着‘事件还没落盘就可能丢’,丢失容忍度要写进策略。可观测性和性能是同一枚硬币的两面,只盯着一边,另一边就会在故障时反噬。”

“那规则本身错了,和 Hook 没生效,怎么区分?”小a问。

“看决策记录。”老z说,“如果记录显示‘策略按规则放行,但结果出了问题’,那是规则写得不对——校验条件缺了、规范化漏了、规则覆盖范围不够;如果记录显示策略本应触发却没触发,那是挂载失效。两种问题的修复方向完全不同:前者改策略,后者修挂载。没有决策记录,这两类问题看起来是一模一样的‘出了事’。”

“那 Hook 本身要不要写测试?”小a又问。

“要,而且测的是‘不变量’而不是‘实现’。”老z说,“三个不变量值得钉死:工作区外的路径必然被拒、日志故障不阻塞主路径、同一事件重复进入同一 Hook 不会二次执行。测试时把下游依赖——审批服务、日志库——换成可控的替身,分别注入慢响应、抛异常、断连,验证上述不变量仍然成立。Hook 测的不是代码写没写对,是契约成不成立。”

重入与错误传播 ​

“Hook 里再调工具,会不会出问题?”小a问。

“会。拦截器改写请求后若再次触发同一事件,会形成重入;实现应传递事件 ID 或深度标记,并规定同一策略是否只能运行一次。”老z说,“示意: beforeWrite 为备份调用写工具时,备份写入不应再次触发同一审批链。策略错误也必须有分类:强制策略失败默认拒绝,纯观察者失败记录并与主路径分离;两种选择都要有超时上限。”

“重入除了递归,还有别的形态吗?”小a追问。

“有跨 Hook 的间接重入。”老z说,“拦截器 A 改写请求后,触发拦截器 B,B 又调用了某个工具,那个工具再次经过 A——链路绕了一圈回到 A。这种重入比直接递归更隐蔽,因为没有任何一个 Hook 单独看起来有问题。防护手段是事件 ID 或深度标记:每个事件带唯一标识,Hook 检测到自己已在处理同一标识时直接放行或拒绝,而不是再次走完整链路。”

“强制策略失败默认拒绝,会不会把系统卡死?”小a又问。

“会,这正是它要配超时的原因。”老z说,“默认拒绝防的是‘不确定时放行’带来的安全风险,但它一旦遇到下游依赖不可用——比如审批服务挂了——就会让所有写操作都失败。所以强制策略要设超时:超时后是降级拒绝还是降级放行,取决于这条规则的安全权重。安全关键策略超时即拒绝,业务策略超时可放行——这是按风险分级,不是一刀切。”

“那间接重入的环路,能画出来看看吗?”小a问。

老z画道:

text
拦截器A改写请求
   ↓
拦截器B执行 → 调用工具 C
                   ↓
          C 的调用再次进入 A ──→ 绕回原链路
                   ↑                │
                   └── 事件ID 检测到同一标识 ──┘

“没有事件 ID,这一圈会无限绕下去;有了它,A 看到同一标识直接放行或拒绝。”老z说,“环路里每个 Hook 单独看都‘没问题’,只有把链路连起来才看得出来——重入防护不能靠写代码时小心,要靠事件 ID 这类机制兜底。深度标记也同理:规定同一事件最多嵌套几层,超过即中断,比试图在逻辑上排除所有环路更现实。”

“那失败策略的分类,有没有一张统一的对照?”小a又问。

老z列出:

策略类型失败时默认行为配套超时主要风险
强制策略拒绝超时即拒绝依赖不可用时全面卡死
业务策略按安全权重降级超时可放行放行本应拦截的动作
纯观察者与主路径解耦不阻塞主路径事件丢失不可恢复

“降级决策本身,要不要记录?”小a问。

“要,而且要比正常决策记得更详细。”老z说,“降级是‘在依赖不可用时临时改了规则’——这类临时状态最容易被忘记。放行过一次的动作、拒绝过一次的动作,都要留原因和时间;模式切换(今天放行、明天拒绝)还要留是谁、在什么时候切回去的。否则排障时会看到同一类请求时而被放、时而被拒,却不知道边界在哪。降级是有时效的策略变更,不是故障现场的临时妥协,它该有和策略本身一样的版本和记录。”

让契约能在故障中成立 ​

“审计和审批在故障时怎么处理?”小a问。

“同一事件的同步 Hook 会把结果直接纳入主路径,适合审批和参数改写;异步订阅适合日志、指标和通知,但必须明确是否等待完成。”老z说,“**若‘记录审计’失败就阻断写文件,审计服务会意外成为可用性单点;若审批作为异步订阅,动作已经发生。**默认失败策略应逐项写明,而非笼统采用‘失败即放行’或‘失败即拒绝’。”

“能串一个完整场景吗?”小a问。

“贯穿场景:模型请求把构建产物上传到外部。路径检查先规范化路径并判断工作区边界,外发审批再显示实际 URL 和文件摘要,审计订阅记录决策。”老z说,“正常路径是检查通过、确认后上传;失败路径一是审批超时,返回拒绝结果且不发请求;二是审计写入失败,按既定策略报警但不重试上传;三是用户取消,传递 AbortSignal 并停止等待。”

“能把这个场景画成一条链吗?”小a问。

老z画道:

text
模型请求上传构建产物
   ↓
路径检查:规范化 + 工作区边界 ── 越界 → 拒绝并返回原因
   ↓
外发审批:显示实际 URL 与文件摘要 ── 超时/拒绝 → 不发请求
   ↓
执行上传
   ↓
审计订阅:幂等键去重记录 ── 写入失败 → 报警,不重试上传

“注意每条失败路径都在链上不同位置。”老z说,“路径检查和审批是同步拦截,失败发生在动作前,模型拿得到原因、可以改;审计是异步订阅,失败发生在动作后,只能报警补救。失败发生在哪个位置,决定了补救手段是什么——想在动作前拦,就放拦截器;只想事后留痕,就放订阅,两边别放反。同一个动作同时走两种通道,正是这个场景的常态:拦在前的负责安全,记在后的负责可观测。”

“那顺序和重入的规则怎么定?”小a问。

“多个 Hook 的顺序、短路与重入也属于 API。可采用‘先安全策略、后业务策略、再观察’的显式优先级;拦截一旦拒绝便短路,订阅不能偷偷恢复它。”老z说,“Hook 内部若再次触发同一事件,必须有事件 ID 或深度限制,避免递归审批。对可能重试的动作,审计与通知使用幂等键,避免一次上传失败重试产生多条‘已上传’记录。”

“幂等键具体怎么避免重复记录?”小a追问。

“每次动作带一个唯一的键,审计订阅按这个键去重。”老z说,“动作失败重试时,重试和原调用共享同一个键;审计写入时先查这个键是否已记录,已记录就更新状态而不是新增一条。没有幂等键,一次失败重试会被记成两次‘已上传’,让审计日志和真实世界对不上。幂等键的生成应在动作发起处,而不是 Hook 里,否则不同 Hook 看到的键可能不一致。”

“那取消信号怎么在多个 Hook 之间传?”小a又问。

“用 AbortSignal 之类的显式取消通道,沿着调用链向下传。”老z说,“用户取消时,信号置位;每个 Hook 和工具实现都应监听这个信号,收到就停止等待、释放资源。关键是要在契约里写明:取消是协作式的,不是强制的——它请求 Hook 停下,但 Hook 是否真的能立即响应,取决于它当前是否在可中断的等待上。一个卡在不可中断操作里的 Hook,仍然会拖到完成或超时。”

“那拒绝原因返回给模型,算不算泄露内部策略?”小a问。

“分两层。”老z说,“给模型看的,是结构化的‘为什么不行’——越过了哪条边界、需要满足什么条件;不给模型看的,是内部实现细节——策略文件在哪、权限系统的账号结构、审批人的联系方式。原因是对话的一部分,模型需要它来调整下一步;策略实现是系统的内幕,模型不需要。返回值要能指导模型修正,又不暴露实现面,通常做法是给理由模板,而不是拼接原始内部信息。”

“那模型拿着原因,一定能修正到合规吗?”小a又问。

“不一定。”老z说,“可能它没看懂原因,可能它理解了规则却做不到——比如权限确实不够。所以拒绝之后要么让模型重试有限次数、要么转向人工,不能假设‘给了原因就会自己修正’。连续拒绝到一定次数就应停止自动重试,把决策权交回给人。拒绝是循环的输入,不是循环的终点,循环失控时要有止损机制。”

小结 ​

Hook 是搭在 Agent 生命周期事件上的受控介入点,它让外部代码能在关键时刻插话。这里的"插话"分两种:拦截器可以改变或阻止即将发生的动作,比如在工具执行前拦下一次危险的调用;订阅者只观察已经发生的事件,比如记录日志或刷新界面。两者的边界必须分清——拦截器出错可能让整个回合失败,订阅者出错最多丢一条日志。一个 Hook 是否可靠,取决于四件事的工程设计:多个 Hook 之间的执行顺序、每个 Hook 的超时处理、取消信号如何传递、以及关键动作有没有审计记录。少了任何一项,Hook 就会从"可控的介入"退化成"不可预测的干扰"。

这四件事背后是同一条原则:把顺序和失败写进契约,而不是留给运行时碰运气。顺序上,安全策略先跑、业务策略其次、观察者最后,同一层内靠显式优先级或短路规则保证可复现——换个启动顺序行为就变,是顺序没有成文的典型信号。失败策略上,强制策略失败默认拒绝但要配超时,业务策略按安全权重降级,观察者与主路径解耦并明确事件丢失是否可接受;逐项写明,而不是笼统套一个"失败即放行"或"失败即拒绝"。拦截与订阅的分层,加上同步与异步通道的区分,决定了同一动作在"动作前拦截"和"动作后留痕"两条路径上各自该放什么。

Hook 最容易踩的坑是重入——一个 Hook 在执行时又触发了同一类事件,从而无限递归。这要求 Hook 的实现必须显式防护,不能假设"我不会触发自己"。直接递归之外还有跨 Hook 的间接环路,任何单个 Hook 单独看都正常,连起来才构成死循环,所以防护要依赖事件 ID 或深度标记这类机制,而不是靠写代码时小心。除此之外,拦截器还有两类固有风险:静默改写会在主路径里埋不一致,模型拿着拒绝原因反复换写法会形成拒绝风暴。改写要么回显给模型、要么干脆拒绝;拒绝原因要结构化,让模型看得懂也绕不过。

还要记住,Hook 是软扩展点,不是硬安全层:它能拦截,但拦截不是强制的,没装 Hook 时默认放行;它能观察,但观察不等于控制。真正的安全约束仍然要由工具、权限和执行环境承担。Hook 的失效也往往不是一次性 bug,而是路径漂移——调用没经过挂载点、配置没注册、异常被吞掉,三种情形下规则会悄悄脱钩。靠"事件发生次数与决策次数对得上、版本升级后旧版本不残留"这类信号去发现,而不是等事故暴露。把职责、顺序、失败策略和可观测性都设计清楚,Hook 才是那个"可验证的介入点";设计不清,它就只是多一层不知道什么时候会出错的隐式控制流。

动手核验 ​

  1. 为一个写文件动作列出事件、输入、允许/拒绝结果和审计字段。
  2. 给两个策略设定顺序与超时:路径边界检查必须先于人工确认;日志失败不得放行或阻塞写入。
  3. 让工具实现自行拒绝工作区外路径,再验证即使跳过 Hook,拒绝仍然成立。