1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code hookのexit code完全ガイド——0, 1, 2を正しく使い分ける

1
Posted at

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つある。

  1. 自動承認のexit 0: echo '{"decision":"approve",...}'をstdoutに出してexit 0。これでユーザーへの確認プロンプトがスキップされる
  2. 「意見なし」の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を書くとき、こう考える。

  1. この操作を止めたいか?exit 2
  2. ユーザーに知らせたいが止めたくはないか?exit 1(+ stderr)
  3. 何もしなくていいか?exit 0
  4. 自動承認したいか? → stdout にJSON + exit 0

迷ったらexit 2を選べ。安全側に倒すのが正解だ。素通りさせてしまったら取り返しがつかない。

もっと学ぶ


あなたのhookでexit 1exit 2を間違えていた経験はありますか?コメントで教えてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?