【実装】Claude Code の hook で事故を未然に防ぐ — PreToolUse / PostToolUse ガードの実装パターン(連載 第2回)

この記事でわかること
- hook の入出力契約(stdin の JSON、exit 2 = Claude に届く、exit 1 = ユーザーにしか見えない)
- SessionStart で起動時にルールを 1 行注入し、会話全体に効かせるテクニック
- PreToolUse で .env Read や terraform apply を「コマンド + ターゲット」の AND 条件で止める書き方
- permissions の deny と hook を二重化する意味
Claude Code の公式ドキュメント(Hooks)を一度眺めていること / shell と jq の基本 / 連載第1回を読んで .claude/settings.json の hooks 配線を見ていると読みやすいです
概要: 連載第1回で「.claude/ は土台・ガード・拡張の3層で設計する」と言いました。本記事ではその中で一番「事故防止」に効くガード層 = hooks に踏み込みます。.env の Read を二重で遮断、terraform apply を経路付きでブロック、commit に混ざった該当署名を検出、起動時に context を 1 行注入——という 4 つの hook をコード付きで見ていきます。
はじめに
Claude Code の permissions は便利ですが、deny ルールだけに頼ると漏れるケースがあります。
Bash(terraform apply:*)を deny しても、cd terraform/dev && terraform applyのようにシェルに埋められると matcher にひっかからないことがある.envは deny していても、Read以外の経路(Glob でパスを拾って cat されるなど)はロジックが違うgit commitはコマンド自体は無害なので permissions では手出ししにくいが、中身のメッセージに Claude の署名が混ざることがある
こういう「単純なコマンド名マッチングだけだと足りないケース」に効くのが hook です。hook は tool 呼び出しの前後に shell スクリプトを挿し、実際の tool input を JSON で受け取って判断できる仕組みです。
本記事では、実運用している 4 つの hook を題材に、設計意図・コード・落とし穴を並べておきます。
1. hook の入出力契約を最初に押さえる
実装に入る前に、hook が Claude Code とどう会話するかを押さえておきます。これを見誤ると「起動しているようだが何も起きない」というデバッグ不能状態にはまります。
+-----------------------+ +-----------------------+
| Claude Code | | .claude/hooks/xx.sh |
| | | |
| tool を呼び出したい | -----> | stdin で JSON を受け取る |
| (Read / Bash / ...) | | 中身を jq でパース |
| | <----- | exit code + stderr で返す |
+-----------------------+ +-----------------------+要点は 3 つだけです。
入力: stdin に JSON が流れてきます。中身は hook の種類と matcher によって違いますが、PreToolUse では少なくとも tool_input フィールドにその tool の入力が入っています。Read なら tool_input.file_path、Bash なら tool_input.command という具合に、jq で取り出します。
出力: exit code と stderr で伝えます。
| exit code | 意味 |
|---|---|
| 0 | OK。tool をそのまま進める。stderr は debug log にしか出ず、ユーザーにも Claude にも見えない |
| 1 | 非ブロッキング。tool は進む。stderr の 1 行目が transcript に出るが、見えるのはユーザーだけで Claude には渡らない |
| 2 | stderr のメッセージを Claude に渡す。PreToolUse ならそのうえで tool を中止する。PostToolUse は tool 実行後なので中止できず、警告として渡るだけ |
exit 1 と exit 2 の違いは「止まるかどうか」ではなく「Claude に届くかどうか」です。ここを取り違えると、警告を出しているつもりで Claude には何も伝わっていない hook ができあがります。止まるかどうかはイベント側(PreToolUse か PostToolUse か)で決まります。
タイムアウト: settings.json で timeout を指定できます(秒)。超えると hook は kill されるので、重い処理は絶対に入れてはいけません。Read の PreToolUse などは Claude がファイルを読むたびに走るので、timeout: 5 くらいに押さえます。
2. SessionStart — 起動時に context を 1 行注入する
一番シンプルで、でも一番効いた hook がこれです。SessionStart で現在のブランチ・dirty 状態・ENV マーカーを 1 行で stdout に出すと、Claude Code はそれを会話の先頭に注入します。
#!/usr/bin/env bash
# SessionStart hook — 起動時のコンテキストを 1 行で表示。絶対にブロックしない。
set -euo pipefail
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}"
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "no-git")
last_commit=$(git log -1 --pretty=format:'%h %s' 2>/dev/null || echo "no-commit")
dirty=""
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
dirty=" [DIRTY]"
fi
env_marker="local"
if [[ -f .env && "$(grep -E '^ENV=' .env 2>/dev/null | head -1)" == "ENV=development" ]]; then
env_marker="dev"
fi
cat <<EOF
my-monorepo | branch: ${branch}${dirty} | env: ${env_marker} | last: ${last_commit}
Reminder: commits do not include Co-Authored-By lines (see CLAUDE.md).
Reminder: terraform/dev and terraform/prod are read-only without explicit user approval.
EOFこの hook は「状態を見せる」というより、「会話の最初にルールを置く」ために使うのがコツです。とくに下 2 行の「Reminder:」は重要で、長い会話の途中でも Claude がこのルールを忘れにくくなります。もちろん CLAUDE.md も読まれますが、SessionStart の 1 行は「今さっき見た」という鮮度で効いてくれます。
ここでは絶対に exit 2 しないこと。SessionStart でブロックするとセッションを始められなくなり、デバッグが非常に面倒になります。set -euo pipefail も、「そもそも git リポジトリではないケース」に備えて || echo でフォールバックさせるのが安全です。
3. PreToolUse(Read) — .env / secrets を exit 2 で遮断する
ここから本当の「ガード」です。matcher: "Read" で hook を仕掛け、.env / secrets/ を読もうとしたらブロックします。
#!/usr/bin/env bash
# PreToolUse(Read) hook — env / secret ファイルの Read を遮断。
# settings.json の deny ルールとの二重化(defense-in-depth)。
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -z "$file_path" ]]; then
exit 0
fi
base=$(basename "$file_path")
case "$base" in
.env|.env.*|local.settings.json)
echo "blocked by secret-guard: refusing to read env / secret file ($file_path)" >&2
exit 2
;;
esac
case "$file_path" in
*/secrets/*|*/.ssh/*|*/credentials*)
echo "blocked by secret-guard: path looks sensitive ($file_path)" >&2
exit 2
;;
esac
exit 0ポイントを 3 つ整理します。
basename とフルパスの両方でチェックする。.env はどこにあろうと basename で拾えますが、.../secrets/db.json のようなパスパターンはフルパスで case マッチする必要があります。
exit 2 と同時に stderr に理由を出す。Claude に「なぜブロックされたか」を伝えておかないと、同じファイルを何度も読みに行ってループします。メッセージには blocked by secret-guard: のような prefix を付けて、どの hook がブロックしたかも明記します。
settings.json の deny と二重化する。hook と permissions を両方付けるのは一見冗長ですが、permissions は glob マッチングの仕様上、複雑なパスですり抜けることがあります。hook だと shell で任意の判定が書けるので、単純なパターンは deny、複雑な判定は hook という使い分けが安全です。
4. PreToolUse(Bash) — 破壊コマンドを path 付きでブロックする
Bash 向けの hook は「コマンド名だけじゃなく、実際のシェル文字列に対してパターンマッチ」できるのが価値です。terraform apply を deny しても cd terraform/dev && terraform apply はすり抜けるため、hook で拾うべきケースがあります。
#!/usr/bin/env bash
# PreToolUse(Bash) hook — 破壊コマンドを path 付きでブロック。
set -euo pipefail
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
if [[ -z "$cmd" ]]; then
exit 0
fi
# 1. terraform/dev or terraform/prod に対する mutation をブロック。
if [[ "$cmd" =~ terraform[[:space:]]+(apply|destroy|import|state[[:space:]]+rm) ]] && \
[[ "$cmd" =~ terraform/(dev|prod) ]]; then
echo "blocked by bash-guard: terraform mutation against terraform/dev or terraform/prod is not allowed" >&2
exit 2
fi
# 2. develop or main への force push をブロック。
if [[ "$cmd" =~ git[[:space:]]+push.*(--force|--force-with-lease|-f[[:space:]]) ]] && \
[[ "$cmd" =~ (origin[[:space:]]+(develop|main|master)|HEAD:(develop|main|master)) ]]; then
echo "blocked by bash-guard: force push to develop/main is not allowed" >&2
exit 2
fi
# 3. rm -rf / or ~ / $HOME をブロック。
if [[ "$cmd" =~ rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)[[:space:]]+(/|~|\$HOME) ]]; then
echo "blocked by bash-guard: rm -rf against / or \$HOME is never legitimate" >&2
exit 2
fi
exit 0この hook には 3 つのポイントがあります。
AND 条件で「コマンド + ターゲット」をセットで見る。terraform apply 自体はローカル modules では走らせたいこともあるので、「それが terraform/dev or terraform/prod に向いている」条件を並べるのがポイントです。同様に force push も develop / main / master への push だけを遮断して、feature ブランチの force push は許します。
正規表現は [[ ... =~ ... ]] で ERE を使う。bash の POSIX 拡張正規で、(apply|destroy) のような交代・[[:space:]] のような文字クラスが使えます。grep -E を pipe するよりも in-process の [[ =~ ]] のほうが高速で、タイムアウト上も安全です。
原則はセーフティネット。該当しなければブロックせず 0 で抜ける。hook は tool 呼び出しのたびに走るため、「該当しないコマンド」のケースを fast path にして、体感速度を損ねないようにします。
permissions の deny / ask との棲み分け
bash-guard は permissions の deny とセットで考えると見通しがよくなります。
Bash(rm -rf /*)を deny — シンプルなコマンドを遮断Bash(terraform apply:*)を deny — prefix マッチで拾えるケースを遮断bash-guard.sh— シェルに埋め込まれたり、path 付きでのブロックをしたいケースを遮断
両方を重ねると「表層で拾えるやつは permissions、それをすり抜けるやつは hook」という二重ガードになります。
5. PostToolUse(Bash) — commit 署名を警告で検出する
PostToolUse は tool 実行後に走る hook です。代表例は「commit メッセージに Claude の署名が混ざっていたら警告」です。
CLAUDE.md で「Co-Authored-By: Claude 等の署名を commit に含めない」と指示しても、長い会話ではしばしば忘れられます。そこで「人間が見る前に機械的に検出する」仕組みとして PostToolUse を設けます。
#!/usr/bin/env bash
# PostToolUse(Bash) hook — commit メッセージに Claude 署名が混ざったら警告。
set -euo pipefail
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
# git commit 以外は見ない。
if [[ ! "$cmd" =~ git[[:space:]]+commit ]]; then
exit 0
fi
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}"
# 直近の commit メッセージを見る。
last_msg=$(git log -1 --pretty=%B 2>/dev/null || true)
if echo "$last_msg" | grep -qiE '(Co-Authored-By:[[:space:]]*Claude|Generated with[[:space:]]+Claude)'; then
cat >&2 <<EOF
strip-claude-signature: the latest commit contains a Claude-Code authorship line.
CLAUDE.md forbids this. Amend the commit before pushing:
git commit --amend # remove Co-Authored-By: Claude lines
(Last message preview)
$(echo "$last_msg" | sed -n '1,5p')
EOF
# exit 2 で stderr を Claude に渡す。
# PostToolUse なので tool は止まらない(commit はすでに実行済み)。
exit 2
fi
exit 0この hook の設計意図で重要なのは「Claude に伝えたいなら exit 2 を使う」という点です。「止めたくないから exit 1」は直感的ですが誤りなので、公式ドキュメントの記述を引いておきます。
To surface a warning to Claude from a
PostToolUseorPostToolUseFailurehook, exit 2 instead so Claude sees the stderr even though the tool already ran.
exit 1(および exit 2 以外の非ゼロ)は non-blocking エラー扱いで、stderr の 1 行目が transcript にFailed with non-blocking status code:という prefix 付きで出ます。ただしこれはユーザーが見るもので、Claude のコンテキストには入りません。つまり「amend しろ」は Claude に届きませんexit 2は PostToolUse では tool をブロックしません(commit はすでに実行済みなので当然です)。公式のイベント別表でも PostToolUse はCan block? = No/Shows stderr to Claudeとされています。止めずに Claude へ伝える、がまさにここで欲しい振る舞いです
つまり「すでに起きてしまったことを Claude に警告してほしい」ケースでも exit 2 が正解で、PreToolUse の exit 2 との違いは「tool が止まるかどうか」だけ、という整理になります。
JSON で伝える書き方
stderr ではなく stdout に JSON を出す方法もあります。hookSpecificOutput.additionalContext は「エラー」ではなく「補足情報」として Claude のコンテキストに入るので、警告というより状況共有をしたいときはこちらが向いています。
# exit 0 のまま、JSON で Claude にコンテキストを渡す
msg="strip-claude-signature: the latest commit contains a Claude-Code authorship line. Amend it before pushing."
jq -nc --arg msg "$msg" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'
exit 0どちらを使うかは「Claude にエラーとして扱わせたいか(exit 2)/ 情報として渡したいか(JSON)」で選びます。
6. 落とし穴とデバッグ
運用しているとたまに踏む 5 つの落とし穴を並べておきます。
hook が走っているかどうかわからない。exit 0 で終わった hook の stdout / stderr は debug log にしか出ず、transcript には現れません(claude --debug で見られます)。デバッグ中は一時的に echo "hook fired: $0" >> /tmp/hook-debug.log のようにファイルへ残すと、実際に走っているかが分かります。
jq がない環境で壊れる。チームの CI や軽量なコンテナ環境では jq が未インストールのことがあります。set -e を踏まえていると hook 自体がクラッシュし、最悪のケースでは tool も進めなくなります。jq 依存をドキュメントに明記しておくのが安全です。
timeout を超える。git log や grep -r を hook で走らせると、大きいリポジトリで timeout 超過して kill されます。hook は「十数 ms で返る」を目安にして、重い処理を避けます。
set -euo pipefail の不用意なクラッシュ。例えば grep -q がマッチなしのときに exit 1 を返し、pipefail と組み合わさると hook 自体が落ちます。「マッチなし」を期待する部分は || true を付けるか、grep -qE ... && handle の形にして、予期しない終了を防ぎます。
個人の shell 設定でコマンドが違う。sed の BSD / GNU 違い、grep -P サポートの有無など、チームメンバーの環境差に踏まれることがあります。hook は「どこでも動く」を優先して、POSIX の範囲で書くのが安全です。
Tips
- hook の中で重いコマンドを走らせない。
git log -1やgit rev-parseは OK 、grep -r .やfind /は NG stderrメッセージには hook 名を prefixして、Claude がどの hook からの警告かを誤認識しないようにする- Claude に伝えたいなら、どちらのイベントでも exit 2。exit 1 はユーザー向けの警告で Claude には届かない。PreToolUse の exit 2 は tool を止め、PostToolUse の exit 2 は止めずに警告だけ渡す
- deny と hook は二重化する。permissions は高速だがシェルに埋まると拾えない、hook は何でも拾えるが重い — 表層を permissions、中身を hook で担当
CLAUDE.mdとセットで設計する。hook は LLM への「最終関門」、CLAUDE.mdは LLM への「意図伝達」。両輪で初めて事故が防げます
まとめ
- hook の本質は「stdin に JSON / exit code と stderr で返す」単純な契約。これを押さえるとデバッグが一気に楽になる
- SessionStart は起動時に context を 1 行注入し、Claude にルールを「さっき見た」鮮度で思い出させるために使う
- PreToolUse(Read) で .env / secrets / .ssh を exit 2 で遮断、settings.json の deny と二重化する
- PreToolUse(Bash) では「コマンド + ターゲット」の AND 条件で path 付きにブロックし、permissions で拾えないシェルパターンをカバーする
- PostToolUse(Bash) で commit メッセージに署名が混ざったら exit 2 で Claude に警告を渡し、amend を促す(PostToolUse の exit 2 は tool を止めない)
- exit 2 は「Claude に伝える」、exit 1 は「ユーザーにだけ見せる」。止まるかどうかはイベント側(PreToolUse か PostToolUse か)で決まる
次回予告
次回(第 3 回)は 拡張層 = subagents の設計に踏み込みます。
- code-reviewer / security-reviewer / pipeline-debugger / test-writer のような用途特化 agent をどう切り分けるか
- frontmatter の
descriptionがメイン Claude の「いつ呼ぶか」判断をどう動かすか toolsでツールを絞ると何が起きるか、model: inheritの使いどころ- メインの context window を汚さない subagent 運用のコツ
参考リンク
更新履歴
- 変換ミスの修正(訣ります・席ける・熟した・凗長・ボロック・佯備・迷わせる・不要意・最終関闢 ほか)。連載第1回へのリンクが Notion 内部 URL になっていたのを修正。参考リンクを現行 URL に変更
- hook の exit code の説明を訂正。§5 のサンプルを exit 1 → exit 2 に変更し、JSON 出力による代替手段を追加
この記事のタグ
Claude Code Harness
全4回Claude Code をチームで運用するための設計。共有する .claude/ の構成から、hook によるガード、subagent と skill の使い分けまで。


