

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 を題材に、設計意図・コード・落とし穴を並べておきます。
Claude Code の hook の入出力契約(stdin の JSON / exit 2 = block / exit 1 = warn / stderr の扱い)
SessionStart で起動時に context を 1 行注入し、「以降の会話全体」にリマインダーを席けるテクニック
PreToolUse で .env Read と terraform apply / git push --force / rm -rf / をブロックするパターン
PostToolUse で commit に「Co-Authored-By: Claude」が混入したら警告するパターン
permissions の deny と hook を二重化する意味
Claude Code をチームで使っていて、hook をこれから書こうとしている人
「permissions の deny だけだと何か不安」と感じている人
以前「hook を書いたが起動しているのか動いているのかわからない」でデバッグに熟した人
Claude Code の公式ドキュメント Hooks を一度眺めている
shell と jq の基本
連載第 1 回を読んで .claude/settings.json の hooks 配線を見ている
実装に入る前に、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 をそのまま進める |
| 1 | 警告。ターミナルに stderr を出すが、tool は進める(非ブロッキング) |
| 2 | ブロック。stderr のメッセージを Claude に返して tool を中止 |
タイムアウト: settings.json で timeout を指定できます(秒)。超えると hook は kill されるので、重い処理は絶対に入れてはいけません。Read の PreToolUse などは Claude がファイルを読むたびに走るので、timeout: 5 くらいに押さえます。
一番シンプルで、でも一番効いた 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 でフォールバックさせるのが安全です。
ここから本当の「ガード」です。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 として使い分けるのが安全です。
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 にして、体感速度を損ねないようにします。
bash-guard は permissions の deny とセットで考えると見通しがよくなります。
Bash(rm -rf /*) を deny — シンプルなコマンドを遮断
Bash(terraform apply:*) を deny — prefix マッチで拾えるケースを遮断
bash-guard.sh — シェルに埋め込まれたり、path 付きでのブロックをしたいケースを遮断
両方を重ねると「表層で拾えるやつは permissions、それをすり抜けるやつは hook」という二重ガードになります。
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 1) で transcript に警告を出す。
exit 1
fi
exit 0
この hook の設計意図で重要なのは「exit 2 ではなく exit 1 を使う」選択です。
exit 2 だと「commit 自体をなかったことにする」振る舞いにはできず(すでに実行後)、以降の tool 呼び出しを block してしまう
exit 1 なら tool 自体は進めつつ、stderr を会話の transcript に出て Claude に「amend しろ」と伝えられる
つまり「すでに起きてしまったことを警告してほしい」ケースでは exit 1 が正解で、PreToolUse で「それをやらせたくない」ケースでは exit 2 を使う、という使い分けです。
運用しているとたまに踏む 5 つの落とし穴を並べておきます。
hook が走っているかどうかわからない。Claude Code は hook の stderr をターミナルにそのままは出しません。デバッグ中は一時的に 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 の範囲で書くのが安全です。
hook の中で重いコマンドを走らせない。git log -1 や git rev-parse は OK 、grep -r . や find / は NG
stderr メッセージには hook 名を prefixして、Claude がどの hook からの警告かを誤認識しないようにする
PostToolUse では exit 1、PreToolUse では 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 1 で警告し、amend を促す
exit 2 は「止める」、exit 1 は「警告だけする」という選択を意識的に使い分ける
次回(第 3 回)は 拡張層 = subagents の設計に踏み込みます。
code-reviewer / security-reviewer / pipeline-debugger / test-writer のような用途特化 agent をどう切り分けるか
frontmatter の description がメイン Claude の「いつ呼ぶか」判断をどう動かすか
tools でツールを絞ると何が起きるか、model: inherit の使いどころ
メインの context window を汚さない subagent 運用のコツ