🐎 第5章 防微杜渐:Hooks 事件驱动自动化
📄 一句话概括

钩子跟主循环说话靠 stdout 上的一段 JSON。字段对了才生效,错了等于没返回——而且不报错。 decision 管拦不拦、reason 管拦了之后怎么办。reason 不是日志,是给模型看的指令。

📝 本章和前面几章的关系

上一节讲处理器怎么选,这一节讲它执行完之后怎么回话。 这一节是 Hooks 里最容易白干的一处:逻辑全对,格式错一个字段,效果为零。

一、三个字段

decision:要不要拦住这次动作。取值是 block 或者空,别的值不认识。 reason:拦的原因。它会回给模型,所以写的是给模型看的话,不是给人看的日志。 hookSpecificOutput:更细的决定,按事件不同而不同。比如工具调用前可以带权限决定。

三个字段之外的东西主循环不认。多写不报错,只是被忽略。 少写会出问题:只写 decision 不写 reason,拦是拦住了,但模型不知道为什么。

二、五份样本,两份合法

演示里五份输出:一份标准 block 带 reason、一份用了不存在的值(deny)、一份 block 但没 reason、 一份用 hookSpecificOutput、一份根本不是 JSON。 合法的两份是 A 和 D。B 的 deny 不是这个字段的取值,C 少了原因,E 压根没解析出来。 注意 D:它走的是另一条通道(hookSpecificOutput 里的权限决定),效果等价,写法不同。

两条通道并存是这套协议的一个特点:粗粒度的用 decision,细粒度的用 hookSpecificOutput。 所以写之前要先确认「我要拦的是整个动作,还是某个权限维度」。选错通道,字段写得再对也没用。

三、reason 为什么是给模型的指令

拦是拦住了,但模型收不到原因,它只知道「这次被拒了」。 接下来它会换个写法再试——而那个写法一样会被拒。两轮白跑,人还得介入。 给了原因就不一样:「命令里含 rm -rf,请改用精确路径删除」。模型会照着改,一次就过。

所以 reason 的写法该是「不行加怎么办」,不是「不行」。 这条在工程上很实用:一个钩子的好坏,一半看它拦得准不准,一半看它的 reason 写得清不清。

四、两条硬规矩

第一条:stdout 只放 JSON。前面多打一行调试信息,整段就不是 JSON 了,解析直接失败。 调试信息走 stderr。 第二条:退出码也算一种回答。 退出码零表示放行,非零表示拦下——两条通道同时存在时以哪条为准,要看具体事件,写之前先查清。 这两条合起来解释了一个现象:钩子不生效的时候,往往不是逻辑错了,是「回话的方式」错了。

所以调试钩子的第一步永远是:把它的 stdout 原样打出来看一眼,别猜。

五、一句话总结这套协议

钩子和主循环之间没有「商量」,只有「返回」。它不问你为什么,只看你返回了什么。 所以返回值的每个字段都得写对,写错不会报错,只会静默失效。 静默失效是这套体系里最难查的一类问题——它和「钩子根本没被触发」在表面上完全一样。

区分办法只有一个:在钩子开头写一行日志。有日志说明触发了,没日志说明没触发,剩下的事就是查格式。 这条办法在 5.10 节会展开成一套完整的排查顺序。

六、动手 Demo:五份钩子输出,几份能被主循环认

五份 stdout 样本:标准的、用错值的、缺 reason 的、走另一条通道的、根本不是 JSON 的。 逐个解析,看哪份能被认,以及被认的那份到底说了什么。

try:
    d = json.loads(txt)
except Exception:
    ok = u"无效"                     # 连 JSON 都不是
if d.get(u"decision") not in (u"block", u"approve", u""):
    ok = u"无效"                     # 值不认识

实跑输出:

--------------------------------------------------------------------
五份钩子输出,几份能被主循环认
--------------------------------------------------------------------

  钩子跟主循环说话靠 stdout 上的一段 JSON。格式对了才生效,格式错了等于没返回。

  ① 一份合法的输出长什么样
  --------------------------------------------------------------------
| 字段 | 取值 | 含义 |
|---|---|---|
| decision | block 或空 | 要不要拦住这次动作 |
| reason | 字符串 | 拦的原因,会回给模型 |
| hookSpecificOutput | 对象 | 更细的决定,按事件不同而不同 |


  ② 五份输出逐个解析
  --------------------------------------------------------------------
| 输出 | 检查 | reason | 结论 |
|---|---|---|---|
| A | 合法 | 有 | 有效 |
| B | decision 值非法 | — | 无效 |
| C | block 但缺 reason | — | 半有效 |
| D | 合法 | 无 | 有效 |
| E | 不是 JSON | — | 无效 |

  五份里 2 份完全合法。
  B 用了一个不存在的值(deny 不是这个字段的取值)、C 拦了但没说原因、E 根本不是 JSON。

  ③ 缺 reason 会怎样
  --------------------------------------------------------------------
  拦是拦住了,但模型收不到原因。
  它只知道「这次动作被拒了」,接下来可能换个写法再试一次——而那个写法一样会被拒。
  给了原因就不一样:「命令里含 rm -rf,请改用精确路径删除」。模型会照着改,一次就过。
  所以 reason 不是给人看的日志,是给模型看的指令。它应该写「怎么办」,而不只是「不行」。

  ④ 协议的两条硬规矩
  --------------------------------------------------------------------
  第一条:stdout 只放 JSON。前面多打一行调试信息,整段就不是 JSON 了,解析直接失败。
  调试信息要走 stderr。
  第二条:退出码也算一种回答。
  退出码零表示放行,非零表示拦下——两条通道同时存在的时候,以哪条为准要看具体事件,写之前先查清。
  ! 一句话:钩子和主循环之间没有「商量」,只有「返回」。
  所以返回值的每个字段都得写对,写错不会报错,只会静默失效。

①三个字段:decision 管拦不拦、reason 管拦了之后怎么办、hookSpecificOutput 管更细的决定。 ②五份里两份完全合法。 B 用了不存在的值(deny 不是这个字段的取值)、C 拦了但没说原因、E 根本不是 JSON。 注意 D 走的是另一条通道——hookSpecificOutput 里的权限决定,效果等价但写法不同。 所以写之前要先确认「拦的是整个动作还是某个权限维度」。 ③缺 reason 会怎样:拦是拦住了,但模型不知道原因,接下来会换个写法再试——而那个写法一样会被拒, 两轮白跑。 给了原因就不一样:「命令里含 rm -rf,请改用精确路径删除」,模型会照着改,一次就过。 所以 reason 不是给人看的日志,是给模型看的指令。它该写「怎么办」,而不只是「不行」。 ④两条硬规矩:stdout 只放 JSON(调试信息走 stderr)、退出码也算一种回答。 钩子不生效的时候,往往不是逻辑错了,是「回话的方式」错了。 所以调试第一步永远是把 stdout 原样打出来看一眼。

30 秒实验

  1. 给 C 那份补一句 reason,重跑第 ② 步。它从「半有效」变成什么?对模型的下一轮有什么不同?
  2. 把 A 的 reason 改成「不行」,再想一遍:模型看到这两个字会做什么? 这和「请改用精确路径删除」差在哪?
  3. 在 A 的 JSON 前面加一行 print,重跑第 ② 步。它变成什么? 这说明了 stdout 那条规矩为什么是硬规矩?

常见误区

  1. 在 stdout 里打调试信息。整段就不是 JSON 了,解析失败,钩子等于没返回。
  2. 用不存在的 decision 值(deny、allow)。字段不认识,等于没返回,而且不报错。
  3. 只写 decision 不写 reason。拦住了但模型不知道为什么,下一轮还会撞同一堵墙。
  4. 把 reason 写成给人看的日志(「[钩子] 命中规则 R3」)。模型看不懂这行,改不了行为。
  5. 选错通道:该用 hookSpecificOutput 的用了 decision,或者反过来。字段再对也没用。
自测三问(点开对答案) 1. decision 和 hookSpecificOutput 有什么区别? —— decision 管「拦不拦这次动作」,粒度粗;hookSpecificOutput 带更细的决定,比如某个权限维度。 两条通道并存,选错等于没返回。 2. 为什么 reason 要写成「怎么办」而不是「不行」? —— 因为 reason 是回给模型的指令。只说不行,它会换个写法再撞一次;说了怎么办,它一次就能改对。 3. 为什么 stdout 里不能有别的东西? —— 因为主循环把 stdout 当协议通道,整段必须是一个 JSON。 前面多一行调试信息,解析就失败,钩子等于没返回。

协议通了,下一节开始拼真的:一套完整的安全防护体系,三条钩子挂在十二个调用上。

相关笔记