Read OSS

フックの実践 — セキュリティモニタリング、Hookify ルールエンジン、そして Ralph ループ

上級

前提知識

  • 第2回:プラグインシステム詳解(フックの基礎、イベントタイプ、終了コード)
  • Python の基礎知識
  • シェルスクリプトの基礎知識

フックの実践 — セキュリティモニタリング、Hookify ルールエンジン、そして Ralph ループ

第2回では、フックをイベント駆動型のインターセプターとして紹介し、終了コードによるプロトコルを解説しました。しかし、プロトコルを理解することと、それを使って実際に役立つものを作ることの間には大きな隔たりがあります。本記事では、15行のシェルスクリプトから複数モジュールで構成された Python ルールエンジンまで、複雑さのスペクトラムを横断する3つの本番向けフック実装を取り上げ、独自のフックを構築する際に役立つ設計パターンを紐解いていきます。

Security Guidance:重複排除を備えたパターンモニタリング

security-guidance プラグインは、リポジトリ内で最も機能が充実した単一ファイルのフックです。plugins/security-guidance/hooks/security_reminder_hook.py に実装されており、9つのセキュリティアンチパターンを監視し、セッション内で初めて該当パターンを検出した際にツールの実行をブロックして警告を表示します。

パターンは31行目から辞書のリストとして定義されています。

plugins/security-guidance/hooks/security_reminder_hook.py#L31-L126

パターン 検出方法 検出対象
github_actions_workflow パスチェック (.github/workflows/) ワークフローファイルへのコマンドインジェクション
child_process_exec 文字列一致 (exec(, execSync() child_process 経由のシェルインジェクション
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 でのシェルインジェクション

検出方法は2種類あります。パスベース(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

重複排除の仕組みはシンプルかつ巧妙です。各セッションは ~/.claude/security_warnings_state_{session_id}.json に専用の状態ファイルを持ち、{file_path}-{rule_name} というキーの集合を管理します。あるファイルに対してパターンが初めてマッチした場合は exit 2 でブロックし、同じファイルとルールの組み合わせが以降に再びマッチした場合は警告なしで処理を許可します。

plugins/security-guidance/hooks/security_reminder_hook.py#L258-L276

特に気に入っているのが、確率的なクリーンアップの仕組みです。状態ファイルはセッションIDごとに1つずつ作成され、セッションをまたいで蓄積されていきます。決定論的なクリーンアップスケジュールを実装する代わりに、フックは呼び出しのたびに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日以上前の状態ファイルを削除します。1セッションあたり複数回フックが呼び出されることを考えると、10%のトリガー率でも定期的にクリーンアップが実行され、毎回のフック呼び出しにレイテンシが加わることもありません。永続的な状態を管理するフックを作る際に参考にしたいパターンです。

ENABLE_SECURITY_REMINDER 環境変数を使えば脱出口も用意されています。\"0\" に設定すれば、プラグインをアンインストールしなくてもフック全体を無効にできます。

Hookify:設定可能なルールエンジン

security-guidance が単一目的のフックだとすれば、hookify はフックを構築するためのフレームワークです。設定ローダー(マークダウン形式のルールファイルをパース)、6つの演算子を持つルール評価エンジン、そして各フックイベント用のエントリーポイントスクリプトという3層アーキテクチャを実装しています。

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 は PyYAML に依存せず、独自の YAML フロントマターパーサーを内包しています。これは意図的な設計判断です。フックは多様な環境で実行されるため、外部依存はそれだけで信頼性のリスクになります。plugins/hookify/core/config_loader.py#L87-L195 のパーサーは、ルールファイルで実際に使われる YAML のサブセット(シンプルなキーと値のペア、リスト、リスト内のネストされた辞書)に対応しています。

ルールファイルは .claude/hookify.*.local.md に配置します。.local.md という拡張子は慣習的に gitignore 対象となり、セッションローカルであることを示します。フロントマターにルール構造を定義します。

---
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 の評価エンジンは6つの演算子をサポートしています。

演算子 動作
regex_match フィールド値に対して正規表現検索
contains 部分文字列チェック
equals 完全一致
not_contains 部分文字列チェックの否定
starts_with プレフィックスチェック
ends_with サフィックスチェック

正規表現のコンパイルには @lru_cache(maxsize=128) を使用しており、フックが繰り返し呼び出されるたびにパターンを再コンパイルするオーバーヘッドを回避しています。

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)

ルールがマッチした場合、エンジンはフックイベントの種類に応じて異なる出力フォーマットを生成します。PreToolUse ではブロックルールが "permissionDecision": "deny" を設定し、Stop イベントでは "decision": "block" を設定します。警告ルールは systemMessage のみを含みます。

plugins/hookify/core/rule_engine.py#L60-L94

フェイルオープン設計

hookify で最も重要なアーキテクチャの判断は、フェイルオープンという思想です。plugins/hookify/hooks/pretooluse.py のエントリーポイントでは、main 関数全体を try/except でラップし、常に 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)

コメントがすべてを物語っています。フックのエラーによって操作をブロックしてはならない。壊れたルールファイルがあっても、開発者が Claude Code を使えなくなるべきではありません。エラーは systemMessage で報告されますが、終了コード 2 を使ったブロックは行いません。これは生産性重視のフックにとって正しいデフォルトです。セキュリティフックは逆の挙動が望ましい場合もあります。

ヒント: フックを構築する際は、エラー時の挙動を最初に決めておきましょう。生産性向上を目的としたフックはフェイルオープン(エラー時に exit 0)にすべきです。セキュリティフックはフェイルクローズ(エラー時に exit 2)にすべきです。Hookify がフェイルオープンを選んだ理由は明快です。YAML の typo のせいで開発者のワークフローがブロックされる方が、警告を見逃すより悪い結果だからです。

最もシンプルなフック:セッションコンテキストの注入

security-guidance と hookify の複雑さとは対照的に、explanatory-output-style フックの全体像を見てみましょう。わずか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

これだけです。additionalContext フィールドを持つ JSON をヒアドキュメントで出力し、exit 0 で終わる cat コマンドのみ。additionalContext はセッションに注入され、Claude を「explanatory モード」で動作させます。作業中に教育的な洞察を提供するようになります。

plugins/explanatory-output-style/hooks/hooks.json への登録も同様にシンプルで、SessionStart フックのエントリーが1つあるだけです。

ここには重要な教訓があります。フックは複雑である必要はありません。最もシンプルで実用的なフックは、JSON を出力して exit 0 するシェルスクリプトです。まずここから始め、ユースケースが要求するときにだけ複雑さを加えていきましょう。

フック出力フォーマットと終了コードプロトコル

フックのコミュニケーションプロトコルをリファレンステーブルとして整理しておきましょう。出力フォーマットはイベントタイプによって異なります。

イベント 主な出力フィールド Exit 0 の意味 Exit 2 の意味
PreToolUse permissionDecision(allow/deny/ask)、updatedInputsystemMessage ツールを許可 ツールをブロック
PostToolUse systemMessage stdout をトランスクリプトに表示 stderr を Claude にフィード
Stop decision(approve/block)、reasonsystemMessage 終了を許可 終了をブロックし reason を再注入
SubagentStop Stop と同じ サブエージェントの終了を許可 サブエージェントの終了をブロック
SessionStart hookSpecificOutput.additionalContext コンテキストを注入 エラー
UserPromptSubmit systemMessage プロンプトを許可 プロンプトをブロック

すべてのフックは共通フィールド(session_idtranscript_pathcwdpermission_modehook_event_name)を含む JSON を stdin で受け取ります。イベント固有のフィールドとしては、ツールイベント向けの 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)

終了コードは3種類、意味も3通りです。0 は成功・許可、2 はブロック(stderr が Claude に表示)、その他の値はノンブロッキングエラー(stderr はユーザーに表示されるが Claude には届かない)。この3方向プロトコルにより、フックは失敗の伝え方を細かく制御できます。

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 ワークフロー、TypeScript のライフサイクルスクリプト、そして Claude Code 自身を使って数千件の Issue を管理するセキュリティサンドボックスを取り上げます。Issue の作成から AI によるトリアージ・重複排除、陳腐化スイープ、自動クローズまでの流れを追い、AI エージェントを GitHub 上で安全に動作させるための3層セキュリティモデルを解説します。