hookが「ブロックしてるつもりで素通り」していた
Claude Codeのhooksで安全ガードを書いた。rm -rf /をブロックするやつだ。テストした。動いた。安心した。
1週間後、ログを見て青くなった。ブロックしたはずのコマンドが全部通っていた。
原因はたった1文字。exit 1と書くべきところを……いや、exit 2と書くべきところをexit 1にしていた。
hookのexit codeは3つしかない。0、1、2。だが、1と2の意味が直感と真逆だ。この記事で完全に理解する。
3つのexit codeの全体像
| exit code | 意味 | 操作への影響 | stderrの行き先 |
|---|---|---|---|
| 0 | 許可(または意見なし) | そのまま続行 | 表示されない |
| 1 | エラー/警告 | そのまま続行 | ユーザーに表示 |
| 2 | ブロック | 操作を中止 | モデルに渡される |
最重要ポイント: exit 1はブロックではない。 続行される。ブロックできるのはexit 2だけだ。
exit 0 = 許可
hookを通過させる。自動承認hookはこれを返す。
実例: 読み取り専用コマンドの自動承認
#!/bin/bash
# auto-approve-readonly.sh
# TRIGGER: PreToolUse MATCHER: "Bash"
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
BASE=$(echo "$COMMAND" | awk '{print $1}' | sed 's|.*/||')
case "$BASE" in
cat|head|tail|less|wc|grep|rg|find|locate|\
ls|tree|stat|file|which|pwd|df|du|free|\
ps|pgrep|lsof|date|uptime|uname|whoami)
# 読み取り専用 → 自動承認
echo '{"decision":"approve","reason":"Read-only command"}'
exit 0
;;
esac
# 判定対象外 → exit 0で通過(他のhookに判断を委ねる)
exit 0
ポイントが2つある。
-
自動承認のexit 0:
echo '{"decision":"approve",...}'をstdoutに出してexit 0。これでユーザーへの確認プロンプトがスキップされる -
「意見なし」のexit 0: 末尾の
exit 0は「このhookでは判定しない」の意味。他のhookやデフォルト動作に判断が委ねられる
どちらもexit 0だが、stdoutにJSONを出すかどうかで挙動が変わる。
exit 1 = 警告(ブロックではない)
stderrに書いた内容がユーザーに表示される。だが操作は止まらない。
実例: ディスク容量の警告
#!/bin/bash
# disk-space-check.sh
# TRIGGER: Notification MATCHER: ""
USAGE=$(df / 2>/dev/null | awk 'NR==2 {gsub(/%/,""); print $5}')
[ -z "$USAGE" ] && exit 0
if [ "$USAGE" -ge 95 ]; then
echo "CRITICAL: Disk usage at ${USAGE}%. Operations may fail." >&2
exit 1 # 警告を表示するが、セッションは続行
elif [ "$USAGE" -ge 80 ]; then
echo "NOTE: Disk usage at ${USAGE}%." >&2
exit 1
fi
exit 0
exit 1の用途は限定的だ。「知らせたいが、止めたくはない」場面でのみ使う。
- ディスク容量が少ない → 警告だけ出して続行
- 非推奨パターンを検出した → 注意喚起だけして続行
- セッション時間が長い → リマインドだけして続行
exit 2 = ブロック(唯一の中止手段)
操作を完全に中止する。stderrの内容はモデルに渡され、モデルはそれを読んで行動を変える。
実例: 破壊的コマンドのブロック
#!/bin/bash
# destructive-guard.sh
# TRIGGER: PreToolUse MATCHER: "Bash"
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
# rm -rf / をブロック
if echo "$COMMAND" | grep -qE 'rm\s+(-[rf]+\s+)*\/'; then
echo "BLOCKED: Destructive rm on root path detected." >&2
exit 2 # ← これだけが操作を止める
fi
exit 0
実例: npm publishのブロック
#!/bin/bash
# npm-publish-guard.sh
# TRIGGER: PreToolUse MATCHER: "Bash"
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -qE '^\s*(npm\s+publish|npx\s+npm\s+publish)' \
&& ! echo "$COMMAND" | grep -qE '\-\-dry-run'; then
echo "BLOCKED: npm publish requires manual confirmation." >&2
exit 2
fi
exit 0
実例: 本番AWS操作のブロック
#!/bin/bash
# aws-production-guard.sh
# TRIGGER: PreToolUse MATCHER: "Bash"
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
echo "$COMMAND" | grep -qE '^\s*aws\s' || exit 0
BLOCKED_PATTERNS=(
"s3.*rm.*--recursive"
"ec2.*terminate-instances"
"rds.*delete-db"
"cloudformation.*delete-stack"
"dynamodb.*delete-table"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiE "aws\s+$pattern"; then
echo "BLOCKED: Destructive AWS operation detected." >&2
echo " Pattern: $pattern" >&2
exit 2
fi
done
exit 0
exit 2でブロックすると、モデルはstderrのメッセージを読む。だから「なぜブロックしたか」を書いておくと、モデルが別の方法を自分で考える。"BLOCKED: npm publish requires manual confirmation."と書けば、モデルは--dry-runを付けて再試行するかもしれない。
よくある間違い: exit 1でブロックしたつもり
これが最も危険なバグだ。
# NG: ブロックしたつもりだが素通りする
if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
echo "BLOCKED: dangerous command" >&2
exit 1 # ← ブロックされない!警告が出るだけ
fi
exit 1は多くのシェルスクリプトで「失敗」を意味する。だからブロックの意味だと思い込みやすい。だがClaude Codeのhooksでは**exit 1 = 警告(続行)、exit 2 = ブロック(中止)**だ。
正しくはこう書く。
# OK: 確実にブロックされる
if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
echo "BLOCKED: dangerous command" >&2
exit 2 # ← これが正解
fi
覚え方: 「2で止める」。1文字の違いがセキュリティホールになる。
テストで確認する方法
hookを書いたら、必ずexit codeを確認する。
# テスト用のJSONを流し込んでexit codeを確認
echo '{"tool_input":{"command":"rm -rf /"}}' | bash hook.sh > /dev/null 2>&1
echo $?
# 期待値: 2(ブロック)
# 安全なコマンドが通過することも確認
echo '{"tool_input":{"command":"ls -la"}}' | bash hook.sh > /dev/null 2>&1
echo $?
# 期待値: 0(許可)
3パターン全部テストする。
| テスト | 入力例 | 期待するexit code |
|---|---|---|
| ブロック対象 | rm -rf / |
2 |
| 警告対象 | (ディスク80%) | 1 |
| 通常通過 | ls -la |
0 |
テストせずにデプロイするhookは、鍵をかけ忘れたドアと同じだ。
まとめ: 判断フローチャート
hookを書くとき、こう考える。
-
この操作を止めたいか? →
exit 2 -
ユーザーに知らせたいが止めたくはないか? →
exit 1(+ stderr) -
何もしなくていいか? →
exit 0 -
自動承認したいか? → stdout にJSON +
exit 0
迷ったらexit 2を選べ。安全側に倒すのが正解だ。素通りさせてしまったら取り返しがつかない。
もっと学ぶ
-
446のhook例で実践:
npx cc-safe-setup(6,099テスト付き) - 体系的に学ぶ: Claude Code Hooks 実践ガイド(Zenn Book)
あなたのhookでexit 1とexit 2を間違えていた経験はありますか?コメントで教えてください。