Hook 实战——安全监控、Hookify 规则引擎与 Ralph 循环
前置知识
- ›第 2 篇:插件系统深度解析(hook 基础、事件类型、退出码)
- ›Python 基础知识
- ›Shell 脚本基础
Hook 实战——安全监控、Hookify 规则引擎与 Ralph 循环
第 2 篇将 hook 介绍为基于事件的拦截器,并说明了退出码协议。但理解协议和真正用它构建有价值的东西之间,存在相当大的距离。本篇将深入分析三个来自生产环境的 hook 实现,它们覆盖了从 15 行 shell 脚本到多模块 Python 规则引擎的完整复杂度范围,并从中提炼出构建自己 hook 时所需的设计模式。
Security Guidance:带去重机制的模式监控
security-guidance 插件是代码库中功能最完整的单文件 hook。位于 plugins/security-guidance/hooks/security_reminder_hook.py 的这个文件,监控九种安全反模式,并在每个 session 首次触发时阻断工具执行并输出警告。
这些模式以字典列表的形式定义,从第 31 行开始:
plugins/security-guidance/hooks/security_reminder_hook.py#L31-L126
| 模式 | 检测方式 | 检测目标 |
|---|---|---|
github_actions_workflow |
路径检查(.github/workflows/) |
workflow 文件中的命令注入 |
child_process_exec |
子字符串(exec(、execSync() |
通过 child_process 进行 shell 注入 |
new_function_injection |
子字符串(new Function) |
通过 Function 构造函数进行代码注入 |
eval_injection |
子字符串(eval() |
任意代码执行 |
react_dangerously_set_html |
子字符串 | 通过 React 进行 XSS |
document_write_xss |
子字符串 | 通过 document.write 进行 XSS |
innerHTML_xss |
子字符串(.innerHTML =) |
通过 innerHTML 进行 XSS |
pickle_deserialization |
子字符串(pickle) |
通过 pickle 执行任意代码 |
os_system_injection |
子字符串(os.system) |
Python 中的 shell 注入 |
这里并存着两种检测方式:基于路径(GitHub Actions 模式检查文件路径)和基于内容(其余模式检查正在写入的新内容)。第 183 行的 check_patterns 函数优先尝试路径检测,再尝试内容检测:
flowchart TD
INPUT["Hook receives JSON via stdin"] --> PARSE["Parse tool_name, tool_input"]
PARSE --> RELEVANT{"Tool is<br/>Edit/Write/MultiEdit?"}
RELEVANT -->|No| ALLOW["exit 0 (allow)"]
RELEVANT -->|Yes| EXTRACT["Extract file_path + content"]
EXTRACT --> CHECK["Check against 9 patterns"]
CHECK --> MATCH{"Pattern<br/>matched?"}
MATCH -->|No| ALLOW
MATCH -->|Yes| DEDUP{"Already warned<br/>this session?"}
DEDUP -->|Yes| ALLOW
DEDUP -->|No| SAVE["Save to state file"]
SAVE --> BLOCK["Print warning to stderr<br/>exit 2 (BLOCK)"]
style BLOCK fill:#E53935,color:#fff
style ALLOW fill:#4CAF50,color:#fff
去重机制的设计相当巧妙。每个 session 都会在 ~/.claude/security_warnings_state_{session_id}.json 生成独立的状态文件,其中存储着 {file_path}-{rule_name} 格式的键集合。某个文件第一次触发某条规则时,hook 会以 exit 2 阻断操作;之后同一文件与规则的组合再次匹配,则静默放行:
plugins/security-guidance/hooks/security_reminder_hook.py#L258-L276
我最欣赏的细节是概率性清理机制。状态文件会随 session 不断积累——每个 session ID 对应一个文件。为了避免引入固定的清理调度逻辑,hook 在每次调用时以 10% 的概率触发清理:
plugins/security-guidance/hooks/security_reminder_hook.py#L226-L228
# Periodically clean up old state files (10% chance per run)
if random.random() < 0.1:
cleanup_old_state_files()
清理函数会删除 30 天前的状态文件。在 10% 触发率加上每个 session 多次 hook 调用的情况下,清理能够定期发生,同时又不会给每次 hook 调用带来额外延迟。这个模式非常值得借鉴,适用于任何需要维护持久状态的 hook。
环境变量 ENABLE_SECURITY_REMINDER 提供了一个逃生出口——将其设置为 "0" 即可在不卸载插件的情况下完全禁用该 hook。
Hookify:可配置的规则引擎
如果说 security-guidance 是单一用途的 hook,那么 hookify 就是一个用于构建 hook 的框架。它实现了三层架构:解析 Markdown 规则文件的配置加载器、支持六种运算符的规则评估引擎,以及针对每种 hook 事件的入口脚本。
classDiagram
class ConfigLoader {
+extract_frontmatter(content) tuple
+load_rules(event) List~Rule~
+load_rule_file(path) Rule
}
class Rule {
+name: str
+enabled: bool
+event: str
+conditions: List~Condition~
+action: str
+tool_matcher: str
+message: str
+from_dict(frontmatter, message) Rule
}
class Condition {
+field: str
+operator: str
+pattern: str
+from_dict(data) Condition
}
class RuleEngine {
+evaluate_rules(rules, input_data) dict
-_rule_matches(rule, input_data) bool
-_check_condition(condition, ...) bool
-_extract_field(field, ...) str
-_regex_match(pattern, text) bool
}
class PreToolUseHook {
+main()
}
ConfigLoader --> Rule : creates
Rule --> Condition : contains
RuleEngine --> Rule : evaluates
PreToolUseHook --> ConfigLoader : uses
PreToolUseHook --> RuleEngine : uses
自定义 YAML 解析器
Hookify 内置了自己的 YAML frontmatter 解析器,而非依赖 PyYAML。这是一个有意为之的设计决策——hook 需要在各种各样的环境中运行,外部依赖会带来可靠性风险。位于 plugins/hookify/core/config_loader.py#L87-L195 的解析器只处理规则文件实际用到的 YAML 子集:简单键值对、列表,以及列表中的嵌套字典。
规则文件存放在 .claude/hookify.*.local.md 中——注意 .local.md 后缀,按照惯例这些文件会被 gitignore,仅在当前 session 本地生效。frontmatter 定义了规则的结构:
---
name: dangerous-rm
enabled: true
event: bash
conditions:
- field: command, operator: regex_match, pattern: "rm\s+-rf"
action: block
---
⚠️ Dangerous command detected! This rm -rf could delete important files.
规则引擎
位于 plugins/hookify/core/rule_engine.py 的评估引擎支持六种运算符:
| 运算符 | 行为 |
|---|---|
regex_match |
对字段值进行正则搜索 |
contains |
子字符串检查 |
equals |
精确匹配 |
not_contains |
反向子字符串检查 |
starts_with |
前缀检查 |
ends_with |
后缀检查 |
正则编译使用了 @lru_cache(maxsize=128),避免在 hook 被反复调用时重复编译相同的模式:
plugins/hookify/core/rule_engine.py#L14-L24
@lru_cache(maxsize=128)
def compile_regex(pattern: str) -> re.Pattern:
return re.compile(pattern, re.IGNORECASE)
规则匹配时,引擎会根据 hook 事件类型输出不同格式的结果。对于 PreToolUse,阻断规则会设置 "permissionDecision": "deny";对于 Stop 事件,则设置 "decision": "block"。警告规则只包含 systemMessage:
plugins/hookify/core/rule_engine.py#L60-L94
失败放行设计
Hookify 最重要的架构决策是其失败放行(fail-open)哲学。位于 plugins/hookify/hooks/pretooluse.py 的入口点用 try/except 包裹了整个 main 函数,并始终以 exit 0 退出:
try:
from hookify.core.config_loader import load_rules
from hookify.core.rule_engine import RuleEngine
except ImportError as e:
error_msg = {"systemMessage": f"Hookify import error: {e}"}
print(json.dumps(error_msg), file=sys.stdout)
sys.exit(0)
main 函数中同样如此:
except Exception as e:
error_output = {
"systemMessage": f"Hookify error: {str(e)}"
}
print(json.dumps(error_output), file=sys.stdout)
finally:
# ALWAYS exit 0 - never block operations due to hook errors
sys.exit(0)
注释已经说明了一切:永远不要因为 hook 自身的错误而阻断操作。一个损坏的规则文件不应该妨碍开发者使用 Claude Code。错误通过 systemMessage 上报,但绝不通过 exit 2 触发阻断。对于以提升效率为目标的 hook,这是正确的默认行为——而安全类 hook 则可能需要相反的策略。
提示: 构建 hook 时,应提前决定好失败模式。效率类 hook 应该失败放行(出错时 exit 0);安全类 hook 应该失败封闭(出错时 exit 2)。Hookify 选择失败放行,是因为因为 YAML 笔误而阻断开发者工作流,比漏掉一条警告的代价更大。
最简 Hook:Session 上下文注入
为了与 security-guidance 和 hookify 的复杂度形成对比,下面是完整的 explanatory-output-style hook——总共 15 行:
plugins/explanatory-output-style/hooks-handlers/session-start.sh#L1-L15
#!/usr/bin/env bash
cat << 'EOF'
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "You are in 'explanatory' output style mode..."
}
}
EOF
exit 0
就这些。一个用 heredoc 输出包含 additionalContext 字段的 JSON 的 cat 命令,然后 exit 0。additionalContext 会被注入到 session 中,让 Claude 以"解释模式"运行——在工作过程中提供教学性的说明。
其注册文件 plugins/explanatory-output-style/hooks/hooks.json 同样极为简洁——只有一个 SessionStart hook 条目。
这里有一个重要的启示:hook 不必复杂。最简单的可用 hook 就是一个输出 JSON 然后 exit 0 的 shell 脚本。从这里起步,只在使用场景确实需要时才增加复杂度。
Hook 输出格式与退出码协议
让我们把 hook 通信协议整理成一张参考表。不同事件类型的输出格式有所不同:
| 事件 | 关键输出字段 | Exit 0 的含义 | Exit 2 的含义 |
|---|---|---|---|
| PreToolUse | permissionDecision(allow/deny/ask)、updatedInput、systemMessage |
允许工具执行 | 阻断工具执行 |
| PostToolUse | systemMessage |
stdout 显示在 transcript 中 | stderr 反馈给 Claude |
| Stop | decision(approve/block)、reason、systemMessage |
允许退出 | 阻断退出,重新注入原因 |
| SubagentStop | 与 Stop 相同 | 允许子 agent 退出 | 阻断子 agent 退出 |
| SessionStart | hookSpecificOutput.additionalContext |
上下文已注入 | 错误 |
| UserPromptSubmit | systemMessage |
允许提示词 | 阻断提示词 |
所有 hook 都通过 stdin 接收 JSON,包含以下公共字段:session_id、transcript_path、cwd、permission_mode、hook_event_name。事件特有字段包括:工具事件的 tool_name 和 tool_input、UserPromptSubmit 的 user_prompt,以及 Stop/SubagentStop 的 reason。
位于 examples/hooks/bash_command_validator_example.py#L56-L83 的 bash_command_validator 示例是退出码协议最清晰的参考实现:
issues = _validate_command(command)
if issues:
for message in issues:
print(f"• {message}", file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
sys.exit(2)
三个退出码,三种含义:0 表示成功/放行,2 表示阻断(stderr 显示给 Claude),其他值表示非阻断性错误(stderr 仅显示给用户,不传递给 Claude)。这个三路协议让 hook 能够精确控制失败信息的传递方式。
flowchart LR
HOOK["Hook Executes"] --> E0{"Exit 0"}
HOOK --> E2{"Exit 2"}
HOOK --> E1{"Exit 1/other"}
E0 --> ALLOW["Allow operation<br/>stdout → transcript"]
E2 --> BLOCK["Block operation<br/>stderr → Claude"]
E1 --> WARN["Non-blocking error<br/>stderr → user only"]
style ALLOW fill:#4CAF50,color:#fff
style BLOCK fill:#E53935,color:#fff
style WARN fill:#FF9800,color:#fff
下一步
到这里,我们已经从架构到实现完整地走过了插件体系。第 5 篇将转向代码库的自动化部分——12 个 GitHub Actions workflow、TypeScript 生命周期脚本,以及用 Claude Code 本身管理数千个 issue 的安全沙箱机制。我们将追踪一个 issue 从创建,经由 AI 驱动的分类与去重、过期扫描,直到自动关闭的完整流程,并深入分析让 AI agent 安全运行在 GitHub 上的三层安全模型。