Read OSS

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)、updatedInputsystemMessage 允许工具执行 阻断工具执行
PostToolUse systemMessage stdout 显示在 transcript 中 stderr 反馈给 Claude
Stop decision(approve/block)、reasonsystemMessage 允许退出 阻断退出,重新注入原因
SubagentStop 与 Stop 相同 允许子 agent 退出 阻断子 agent 退出
SessionStart hookSpecificOutput.additionalContext 上下文已注入 错误
UserPromptSubmit systemMessage 允许提示词 阻断提示词

所有 hook 都通过 stdin 接收 JSON,包含以下公共字段:session_idtranscript_pathcwdpermission_modehook_event_name。事件特有字段包括:工具事件的 tool_nametool_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 上的三层安全模型。