跳转到内容

Hook 事件驱动安全机制

Hook 事件驱动安全机制

学习目标

读完本章后,你将能够:

  • 理解 Hook 事件的类型、生命周期和拦截模式
  • 设计 Block、Warn、Intercept 三种决策模式
  • 应用 Fail-open 原则确保 Hook 不阻断正常操作
  • 实现会话级状态管理和去重机制

核心概念

Hook 事件类型

Hook 是事件驱动架构中的拦截器,在特定生命周期点触发:

事件触发时机典型用途
PreToolUse工具执行前安全检查、权限拦截、模式匹配阻止
PostToolUse工具执行后警告提示、结果验证、审计日志
Stop用户或 Agent 请求停止时阻止会话退出、迭代循环、完成条件检查
UserPromptSubmit用户提交提示时上下文注入、提示预处理、规则提醒
SessionStart会话启动时初始化、上下文注入、环境检查

Hook 匹配器(Matcher)的第一层过滤

Hook 注册时可配置 matcher,作为事件过滤的第一层:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security.py"
}
]
}
]
}
}

Matcher 是一个正则表达式,只有匹配的工具才会触发 Hook。这减少了不必要的 Hook 调用开销。

三种决策模式

1. Block(阻止)

PreToolUse Hook 通过退出码 2 阻止工具执行:

# 发现安全问题 → 阻止
sys.exit(2)

工具不会执行,用户看到 Hook 输出的错误消息。

2. Warn(警告)

PostToolUse Hook 或 PreToolUse Hook 通过退出码 0 放行工具,但输出警告消息:

# 发现潜在风险 → 放行但警告
print(json.dumps({"hookSpecificOutput": {"permissionDecision": "deny"}}))
sys.exit(0)

工具会执行,但用户看到警告提示。

3. Intercept(拦截会话生命周期)

Stop Hook 通过输出特定 JSON 阻止会话退出:

{
"decision": "block",
"reason": "任务未完成",
"systemMessage": "Ralph iteration 3 | 继续工作"
}

这允许 Hook 控制会话的生命周期——即使 Agent 想停止,Hook 可以强制继续。

Fail-open 原则

核心规则:Hook 脚本的所有错误路径都必须以 exit 0 结束。

#!/bin/bash
# Stop Hook 的 fail-open 设计
state_file=".claude/ralph-loop.local.md"
# 状态文件不存在 → 放行(exit 0)
[ ! -f "$state_file" ] && exit 0
# 解析失败 → 清理并放行(exit 0)
iteration=$(grep "^iteration:" "$state_file" | awk '{print $2}')
[ -z "$iteration" ] && rm -f "$state_file" && exit 0
# 正常逻辑 → 可能 block 或放行
# ... 业务逻辑 ...
# 始终 exit 0(Hook 自身成功执行,业务决策通过 JSON 输出)
exit 0

为什么 Fail-open? 安全警告不应成为生产力阻碍。如果 Hook 脚本本身有 bug,系统应该放行而非卡死。

会话级状态管理与去重

Hook 可以在会话间维护状态:

# 状态文件:~/.claude/security_warnings_state_<session_id>.json
# 格式:{"file_path-ruleName": true, ...}
import os, json
def load_state(session_id):
path = f"~/.claude/security_warnings_state_{session_id}.json"
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def mark_shown(session_id, key):
state = load_state(session_id)
state[key] = True
with open(path, 'w') as f:
json.dump(state, f)

30 天自动清理:每次运行时以 10% 概率扫描并清理超过 30 天的状态文件,防止磁盘膨胀。

完整生命周期事件分类

在实践中,Hook 事件可以按照工具生命周期的完整链路进行分类:

事件触发时机阻塞能力典型用途
SessionStart新会话开始时加载上下文、包管理器检测、环境初始化
PreToolUse工具执行前(exit 2)安全门控、配置保护、事实强制(Fact-Force)
PostToolUse工具执行后否(异步)质量检查、PR 日志、构建分析、连续学习观察
Stop每次 AI 响应后批量格式化+类型检查、会话持久化、模式提取、成本追踪
PreCompact上下文压缩前保存关键状态以防压缩丢失
PostToolUseFailure工具执行失败后MCP 健康检查、失败重试
SessionEnd会话结束时生命周期标记、清理日志

运行时门控

生产级 Hook 系统支持运行时控制,无需修改配置文件即可调整行为:

Terminal window
# Profile 分级:minimal | standard | strict
export ECC_HOOK_PROFILE=standard
# 按 ID 禁用特定 Hook(逗号分隔)
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"
# 临时关闭门控(用于设置或恢复)
export ECC_GATEGUARD=off

Profile 分级的设计使得用户可以在不同场景下切换 Hook 密度:开发环境用 standard,生产审查用 strict,资源受限时用 minimal

陷阱与对策

陷阱表现对策
Matcher 过宽Hook 被不必要地频繁触发,增加延迟精确匹配工具名,使用正则缩小范围
Matcher 过窄安全漏洞逃逸(如只匹配 Edit 没匹配 Write审计所有可写工具,确保覆盖完整
正则字段推断错误单条件 pattern 自动推断为 commandnew_text,可能误判使用多条件 conditions 显式指定字段
Bash == 全局匹配[[ $a == $b ]]== 执行 glob 匹配,* 字符导致误判使用 = 做精确字符串比较
Hook 脚本 stderr 输出stderr 被当作错误消息,可能被用户误解使用 stdout 输出 JSON,stderr 仅输出用户可见警告

设计取舍

为什么 Hook 用外部脚本而非内置规则引擎?

外部脚本(Python/Bash)可以访问完整操作系统能力,支持复杂的模式匹配、网络请求、状态持久化。内置 DSL 虽然安全,但表达能力有限。

为什么采用 Fail-open 而非 Fail-closed?

Fail-closed 会在 Hook 出错时阻止所有操作,安全性更高但用户体验极差。Fail-open 保证生产力不被阻断,代价是安全规则失效时可能有短暂漏洞窗口。

为什么状态管理用 JSON 文件而非数据库?

JSON 文件无需额外依赖,人类可读可调试,对会话级小规模数据足够。数据库引入部署复杂度,对 Hook 场景过度设计。

OpenHands 补充:HTTP 代理式 Hook 加载

在 OpenHands V1 架构中,Hook 加载采用HTTP 代理模式:App Server 作为薄代理,将 Hook 加载请求转发到 Agent Server 的 /api/hooks 端点。

核心特征

维度OpenHands 实现
加载路径{project_dir}/.openhands/hooks.json,支持仓库级(.openhands/hooks.json)和项目级
通信协议HTTP POST + Session API Key 认证,30 秒超时
容错策略load_hooks_from_agent_server 吞掉所有异常返回 None;fetch_hooks_from_agent_server 抛出异常
HookConfig来自 SDK 的 HookConfig.from_dict(),支持 is_empty() 空值检查
双路径设计对话启动路径使用容错加载(不阻塞);Hook 查看端点使用严格加载(暴露错误)

与进程内 Hook 的差异

前文描述的 PreToolUse/PostToolUse Hook 是进程内拦截器,而 OpenHands 的 Hook 加载是启动时配置注入。两者的结合方式是:

  1. 启动时通过 HTTP 加载 HookConfig(配置数据)
  2. 运行时根据 HookConfig 中的定义,在 Agent 循环中执行相应的拦截逻辑

陷阱

  • Agent Server 不可达时 Hook 静默跳过,安全规则可能不生效
  • 30 秒超时在 Agent Server 负载高时可能触发,导致 Hook 加载失败
  • 仓库选择变更时(如切换 Git 项目),project_dir 路径变化导致 Hook 重新加载

补充:Agent Skills 的 SessionStart 钩子模式

来源:Addy Osmani / agent-skills v0.6.1,hooks/ 目录 + hooks/hooks.json,commit 6ce0298

agent-skills 项目展示了如何用 SessionStart 钩子实现会话级自动化,而非安全拦截。这是 Hook 系统的另一类典型用法。

SessionStart 链式执行

{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
}
]
}
]
}
}

每个会话启动时自动执行 session-start.sh,实现以下功能:

  • 检测项目类型和技术栈
  • 注入项目级上下文(如 CLAUDE.md 的内容摘要)
  • 加载可用技能列表

SDD 缓存管理(前置 + 后置钩子)

agent-skills 实现了成对钩子模式来管理 Spec-Driven Development 的缓存:

钩子功能
sdd-cache-pre.sh会话启动前检查 SDD 缓存是否存在、是否过期,输出缓存状态
sdd-cache-post.sh规格编写完成后将规格写入缓存,供后续会话复用

缓存生命周期

Session Start → sdd-cache-pre.sh(检查缓存)
如果命中 → 注入缓存内容到上下文
如果未命中 → 空状态,需要重新编写规格
规格完成后 → sdd-cache-post.sh(写入缓存)

设计特点

  • 缓存基于项目路径和规格内容的 hash
  • 过期的缓存自动标记但不删除,用户可以手动清理
  • 前后置钩子通过共享的缓存目录协调状态(而非会话内变量)

Code-Simplify 忽略规则引擎

simplify-ignore.sh(12KB+)是一个复杂的忽略规则引擎,决定哪些文件/变更不应该被 code-simplify 处理:

  • 基于文件路径模式匹配(如 node_modules/vendor/.git/
  • 基于变更类型(添加 vs 修改 vs 删除)
  • 基于文件大小(超大文件不简化)
  • 支持项目级自定义规则(通过 .simplify-ignore 文件)

配套的 simplify-ignore-test.sh 验证规则的正确性,确保新规则不意外忽略应处理的文件。

与前文拦截式 Hook 的区别

维度拦截式 Hook(PreToolUse 等)自动化 Hook(SessionStart 等)
目的安全防护、权限控制会话初始化、状态管理
决策Block / Warn / InterceptExecute / Inject
频率每次工具调用每次会话启动
性能要求低延迟(工具调用阻塞)可接受较长延迟(一次性)
失败处理Fail-open(不阻断操作)Fail-open(不阻断会话)

agent-skills 的 Hook 用法展示了 Hook 系统不仅用于安全防护,还可以用于会话级自动化和状态管理

  • Claude Code Hooks 文档 — Hook 系统的完整规范
  • 源码验证: OpenHands app_server/app_conversation/hook_loader.py — HTTP 代理式 Hook 加载实现