ローカルで動いたフックを本番(チーム共有の設定やCIの手前)に載せた途端、「守ってくれるはずのガードレールが、肝心なときに素通りする」という事態が起きます。原因の多くは、コードのバグではなく、フックがいつ・どのタイムアウトで走り、失敗したときにどう扱われるかという挙動を把握していないことにあります。本稿は 2026-10-18 時点の公式ドキュメント(Claude Code のフック仕様)で逐語確認できた挙動だけを土台に、PreToolUse ガードレールをゼロから1本通し、失敗時にきちんとブロックする設計までを示します。captured 出力は Claude Code 2.1.302 で取得しています。
フック実行タイミングのモデル
まず全体像です。フックのイベントは、発火の「リズム(ケイデンス)」で3種類に分かれます。
Events fall into three cadences
セッション単位(SessionStart / SessionEnd)、ターン単位(UserPromptSubmit / Stop / StopFailure)、そしてエージェントループ内のツール呼び出しごと(PreToolUse / PostToolUse)です。同じイベントに複数のハンドラがマッチした場合、それらは直列ではなく並列で走ります。
タイムアウトはイベントとフック種別で既定値が異なります。command / http / mcp_tool は既定で600秒、prompt は30秒、agent は60秒です。
Defaults: 600 for command , http , and mcp_tool ; 30 for prompt ; 60 for agent .
さらに、特定のイベントでは既定値が引き下げられます。Claude Code は UserPromptSubmit・PreModelSwitch・PostModelSwitch で command / http / mcp_tool の既定を30秒に、MessageDisplay で10秒に下げます。SessionEnd はさらに短く、既定1.5秒です(終了・/clear・インタラクティブな /resume でのセッション切り替え時に適用)。PreModelSwitch はタイムアウトするとモデルスイッチをブロックします。
失敗時のデフォルトは「通す」
本番で最も重要なのはここです。PreToolUse のフックがタイムアウトしたとき、既定ではツール呼び出しをブロックしません。
doesn't block the tool call, except for hooks whose failure blocks the action
つまり、ネットワーク遅延やスクリプトのハングでフックが時間切れになると、ガードレールは「素通り」します。これはセキュリティ目的のフックにとって危険な既定です。意図的にブロックへ倒すには、command / http フックに onFailure: "block" を設定します(Requires Claude Code v2.1.295 or later)。
終了コードの扱いも把握が必要です。終了コードが0でも2でもなく、stdout が plain text または空の場合、失敗時にアクションをブロックする設定でない限り「非ブロッキングエラー」となり、アクションは継続されます。
本番ガードレールをゼロから作る(通し実装)
空のディレクトリから、危険な Bash コマンドをブロックする PreToolUse フックを1本作ります。
mkdir -p claude-hooks-guard/.claude/hooks
cd claude-hooks-guard
.claude/settings.json を置きます。静的な deny ルール(既知の正確なコマンドを拒否)と、動的な PreToolUse フック(パターンで拒否)を二層にします。
{
"permissions": {
"deny": [
"Bash(echo hello-from-deny-test)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
"timeout": 10,
"onFailure": "block"
}
]
}
]
}
}
timeout は既定の600秒ではなく10秒に下げています(fail-fast)。onFailure: "block" でタイムアウト・クラッシュ時もブロックに倒します。
.claude/hooks/guard.sh を置きます。判定は必ず同期的に出し、プロセスを fork して先に終了しない設計にします。
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
cmd="$(printf '%s' "$payload" \
| python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))')"
if printf '%s\n' "$cmd" | grep -Eq 'rm[[:space:]]+-rf[[:space:]]+/'; then
echo "guard.sh: blocked dangerous command by PreToolUse policy" >&2
exit 2
fi
exit 0
実行ビットを立てます。
chmod +x .claude/hooks/guard.sh
動作確認
まずフック単体を標準入力でテストします。危険コマンドを渡すと、スクリプト自身が stderr に理由を出し、終了コード2(ブロックシグナル)で終わります。
$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./.claude/hooks/guard.sh; echo "exit=$?"
guard.sh: blocked dangerous command by PreToolUse policy
exit=2
安全なコマンドなら何も出さず、終了コード0で通します。
$ echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | ./.claude/hooks/guard.sh; echo "exit=$?"
exit=0
次に Claude Code 内で、deny ルールのセンチネル(echo hello-from-deny-test)を実行させます。許可されず、端末にはこう出ます。
Permission to use Bash with command echo hello-from-deny-test has been denied.
Permission to use Bash with command echo hello-from-deny-test has been denied.
設計判断:本番で効くフックの3原則
ここは公式ドキュメントの引き写しではなく、上記挙動から導いた運用指針です(事実と意見を区別して書きます)。
第一に、判定は同期的に出すこと。非同期でないフックに対して、Claude Code はプロセス終了直後に出力の読み取りを終えます。
finishes reading the hook's output shortly after the hook's own process exits
したがって、コマンドフックがバックグラウンドプロセスに判定を委ねて先に終了すると、その後に出力された deny は読み取り済みのため効きません。「fork して後で deny」は本番で最も踏みやすい地雷です。
第二に、セキュリティ目的のフックは onFailure: "block" を必須にすること。前述の通り既定は非ブロッキングで、ハング時に素通りします。守るためのフックが沈黙して通すのは、無いより危険だ、というのが私の判断です。
第三に、timeout はイベント既定より短く設定すること。PreToolUse の既定600秒をそのまま使うと、ハングしたフックがエージェントを最大10分stallさせ得ます。5〜10秒に絞って fail-fast にします。
補足として、Windows では Git Bash 未インストールかつ shell を powershell にしていないコマンドフックは、終了コード0/2以外で verdict が確定せずブロックされ得ます。クロスプラットフォームなら shell を明示します。
Stop フックと運用上の上限
Stop フックで継続をブロックし続けても無限ループにはなりません。8回連続までで、それを超えると Claude Code が次のブロックを無効化してターンを終了します。
Claude Code overrides the next block and ends the turn.
その他の既知の制約として、async: true のフックにはタイムアウトが強制されない(レイテンシを縛れない)、additionalContext / systemMessage / initialUserMessage と plain stdout は各10,000文字が上限、StopFailure の cloud_credential_error マッチングは v2.1.267 以降が必要(それ以前は server_error / unknown 扱い)、があります。