フックの実践 — セキュリティモニタリング、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)、updatedInput、systemMessage |
ツールを許可 | ツールをブロック |
| PostToolUse | systemMessage |
stdout をトランスクリプトに表示 | stderr を Claude にフィード |
| Stop | decision(approve/block)、reason、systemMessage |
終了を許可 | 終了をブロックし reason を再注入 |
| SubagentStop | Stop と同じ | サブエージェントの終了を許可 | サブエージェントの終了をブロック |
| SessionStart | hookSpecificOutput.additionalContext |
コンテキストを注入 | エラー |
| UserPromptSubmit | systemMessage |
プロンプトを許可 | プロンプトをブロック |
すべてのフックは共通フィールド(session_id、transcript_path、cwd、permission_mode、hook_event_name)を含む JSON を stdin で受け取ります。イベント固有のフィールドとしては、ツールイベント向けの 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)
終了コードは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層セキュリティモデルを解説します。