Claude Codeのhooksでブロックできない原因と対処|PreToolUseを終了コードで止めるなら2・exit 1だけでは素通り・stdinのJSONとmatcherの確認方法

Claude Codeのhooksでブロックできない原因と対処

こんにちは、おうどんです🍜

うどん屋の厨房に、門番を置くことにしました。 Claude が危ないコマンドを打とうとしたら、止めてくれる係です。

Claude Code の hooks(フック)で、PreToolUse に Python のスクリプトをつなぎます。 rm -rf が来たら、「ダメ」と言って終了する。完璧です。

import json, sys

data = json.load(sys.stdin)
command = data["tool_input"]["command"]

if "rm -rf" in command:
    sys.exit("Blocked: rm -rf is not allowed")

sys.exit(0)

さっそく試します。

Claude「rm -rf /tmp/build を実行しますね」

門番のフックさん「ダメです!(終了コード1)」

Claude Code「門番さん、エラーで倒れたみたいです。とりあえず通しまーす」

店長(おうどん)「……通すな」

(会話は説明用の架空の場面です)

このスクリプトに rm -rf /tmp/build の入力を流すと、終了コードは 1 でした(今回の検証環境の Windows 10・Python 3.13.1 で実行。結果は後の章で載せます)。 そして Claude Code は、有効な JSON を返さず終了コード1になったフックを「ブロック」ではなく「エラーが出たけど続行」として扱います。

門番は、ちゃんと「ダメ」と言っていました。 ただ、Claude Code に通じる言葉で言っていなかったんです。

似たような悩みは、ほかにもあります。

  • フックを書いたのに、コマンドが止まらない
  • 「PreToolUse hook error」と出て、そのまま実行される
  • JSON を返しているのに、効いていない
  • 自分の PC だけ、なぜかフックが落ちる

今回は、門番のフックさんに正しい「ダメ」の言い方を教えていきます。

最初に、初心者向けのチェックリストです。

  • 終了コードだけで止めたいときは、終了コード 2 で終わる(Python なら sys.exit(2))
  • 止めた理由は stderr(標準エラー出力)に書く
  • 設定する前に、スクリプトへ JSON を手で流して、終了コードを確かめる
目次

hooks は「決まった場面で必ず動く」スクリプト

Claude にお願いするのではなく、Claude Code の仕組みとして必ず動く。 そこが hooks のいちばんの売りです。

Claude Code の hooks ガイドでは、hooks はユーザーが決めたシェルコマンドで、Claude Code のライフサイクル(動いている流れ)の決まった場所で実行される、と説明されています。LLM(大規模言語モデル)が実行するかどうかを選ぶのではなく、必ず実行される。だから、プロジェクトのルールを守らせる用途に向いているわけです。

CLAUDE.md に「rm -rf はしないでね」と書くのは、新人さんへのお願い。 hooks は、厨房の扉に立つ門番です。

Claude「CLAUDE.md、ちゃんと読みました。rm -rf はしません」

店長「えらい」

Claude「なので、rm -r -f にしておきました」

……気持ちは分かる。でも、だから門番がいるんです(この rm -r -f は、あとの章でもう一度出てきます)。

いくつかある場面(イベント)のうち、コマンドを止めたいときに使うのは PreToolUse です。 ツール(Bash・Edit・Write など、Claude が使う道具)が実行される「前」に動きます。

イベントいつ動く止められる?
PreToolUseツールの実行前止められる
PostToolUseツールの実行後止められない(もう実行済み)
UserPromptSubmitプロンプトを送ったとき止められる
SessionStartセッションの開始・再開止められない

設定は settings.json に書きます。置き場所で効く範囲が変わります。

ファイル効く範囲
~/.claude/settings.json自分のすべてのプロジェクト
.claude/settings.jsonそのプロジェクト(リポジトリにコミットして共有できる)
.claude/settings.local.jsonそのプロジェクト(自分だけ。共有しない)

設定の形はこうです(hooks のリファレンスの形式に合わせた例。Claude Code 上では未実行です)。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"]
          }
        ]
      }
    ]
  }
}
  • matcher:どのツールのときに動かすか。Bash なら Bash ツールだけ
  • type:command はコマンドを実行するフック
  • command と args:実行するもの。${CLAUDE_PROJECT_DIR} はプロジェクトのルートに置き換わります

args を書くと、シェルを通さずにそのまま起動する exec 形式になります。Windows でパスの区切りや空白に悩まずに済むので、今回はこの形にしました。

3段も入れ子になってる。重箱?

重箱です。 イベント → matcher → フック本体、の3段。おせちと違って、1段目を開けても中身はまだ見えません。

終了コードだけで止められるのは2。1だけでは素通り

ここが今回いちばん大事なところです。 門番の「ダメ」を終了コードだけで伝えるなら、2番の札です。

hooks のリファレンスでは、終了コード(プログラムが終わるときに返す番号)の意味がこう決まっています。

終了コードClaude Code の扱い
0成功。異議なし(PreToolUse では「許可」ではなく、ふつうの権限チェックに進む)
2ブロック。stderr の内容が理由として使われる
それ以外(1 など)ブロックしないエラー。基本はそのまま続行

リファレンスには、多くのイベントで、コードだけでブロックできるのは終了コード2だけで、有効な JSON を出していなければ、Unix で一般的な失敗のコードである1も「ブロックしないエラー」として扱って処理を進める、とはっきり書かれています。そして、ポリシーを守らせるフックなら exit 2 を使うように、とも。

つまり、冒頭の門番は、こう言っていたわけです。

フックさん「(1番の札を出して)ダメです!」

Claude Code「1番は『体調不良』の札ですね。お大事に。通りまーす」

……札の番号を、ひとつ間違えただけなのに。

冒頭のスクリプトが1になった理由は、sys.exit("文字列") にあります。 Python の sys.exit のドキュメントには、整数以外のものを渡すと、それが stderr に表示されて終了コードは1になる、と書かれています。

冒頭のスクリプトに rm -rf /tmp/build の入力を流した結果です。

exit  : 1
stderr: Blocked: rm -rf is not allowed

(今回の検証環境の Python 3.13.1 で、JSON を流して終了コードと stderr を記録する確認用スクリプトから実行し、その2行を抜き出したものです)

メッセージはちゃんと stderr に出ています。でも番号は1。 Python の世界では「エラーで終了」の正しい書き方でも、Claude Code の門の前では「通っていいよ」になってしまいます。

直し方は、メッセージと番号を分けることです。

if "rm -rf" in command:
    print("Blocked: rm -rf is not allowed", file=sys.stderr)
    sys.exit(2)

print(..., file=sys.stderr) で理由を stderr に書き、sys.exit(2) で2番の札を出します。 冒頭のスクリプトの if の部分を、この形に変えるだけです。今回の検証では、この形にすると rm -rf /tmp/build で終了コード2、npm test で0になりました。

じゃあ、ダメなときは全部 sys.exit(2) にすればいい?

止めたいときは、そうです。 ただし「止めたいとき以外」の2には注意が要ります。それは中級者向けの章で。

店長「よし、門番さんの札は、明日から2番と0番の2枚だけにしよう」

……札が2枚しかない門番。でも、それくらいがちょうどいいんです。

理由は stderr、JSON は stdout。混ぜない

2番の札を出すときは、理由を添えましょう。 理由は stderr に書くと、PreToolUse では Claude への説明として渡されます。

hooks のガイドでは、終了コード2のとき stderr に理由を書くこと、その行き先はイベントによって違い、Claude に渡して作業を変えてもらうものもあれば、ユーザーに表示するものもある、と説明されています。ガイドの例では、PreToolUse で止めたとき、stderr のメッセージが Claude へのフィードバックになっています。

理由が分かれば、Claude は別のやり方を考えられます。 「ダメ」だけだと、Claude は門の前で途方に暮れます。

Claude「なんでダメなんですか?」

フックさん「(2番の札を、無言で掲げる)」

Claude「……では、言い方を変えてもう一度」

……理由を言わない門番は、同じ客に5回並ばれます。

もうひとつのやり方が、終了コード0で、stdout に JSON を出す方法です。 こちらは「止める」だけでなく、「ユーザーに確認してもらう(ask)」なども選べます。

import json, sys

data = json.load(sys.stdin)
command = data["tool_input"]["command"]

if "rm -rf" in command:
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "rm -rf is not allowed",
        }
    }))
sys.exit(0)
exit  : 0
stdout: {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "rm -rf is not allowed"}}
stderr: 

(今回の検証環境の Python 3.13.1 で、rm -rf /tmp/build の入力を流した結果です。この JSON を Claude Code が実際にどう扱うかは、Claude Code 上では未確認です)

permissionDecision に入れられるのは、リファレンスによると allow・deny・ask・defer です。 json.dumps で作ると、引用符やバックスラッシュのエスケープを自分で書かずに済みます。ガイドでも、文字列の連結ではなく JSON のエンコーダーで作るようにすすめています。

ガイドには、「終了コード2で stderr」か「終了コード0で JSON」か、1つのフックではどちらか一方を選ぶように、という注意もあります。

両方やったら、もっと強く止められる?

強くはなりません。ややこしくなるだけです。 終了コード2は JSON でひっくり返せない、という決まりなので、両方書いても「2で止まる」以上のことは起きにくいです(組み合わせの細かい話は上級者向けの章で)。

店長「念のため、札も出して、手紙も書いて、口でも言ってもらおう」

……門番さんの手が足りません。

🔰 ここまで読めば今日から困らない

ここまでで、止まらないフックの多くは直せます。

  • 止めたいときは sys.exit(2)。sys.exit("メッセージ") は1になるので止まらない
  • 理由は stderr へ(print(..., file=sys.stderr))
  • JSON で答えるなら、終了コードは0にして、stdout には JSON だけを出す

設定する前に、ターミナルでスクリプトに JSON を流して、終了コードを確かめましょう。 ガイドのトラブルシューティングにも、サンプルの JSON をパイプで流して手でテストする方法が載っています。

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ./dist"}}' | python guard.py
echo "exit=$?"
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | python guard.py
echo "exit=$?"
Blocked by guard.py: rm -rf is not allowed: rm -rf ./dist
exit=2
exit=0

(今回の検証環境の Git Bash 5.2.26・Python 3.13.1 で実行した結果です。guard.py は後の「完成コード」の章のスクリプトです)

rm -rf で2、npm test で0。 これが確認できてから settings.json に登録すれば、「書いたのに止まらない」の大半は先回りして防げます。

フックさん「本番前に、札の出し方を練習させてもらえるんですね」

店長「練習で1番を出したら、本番に出さないで済むからね」

……リハーサルのある門番。いい職場です。

毎回テストするの、めんどくさい。

最初の1回だけで大丈夫です。 あとはスクリプトを直したときに、同じ2行をもう一度流すだけ。止まらない門番を本番で見つけるより、ずっと早く済みます。

店長「テスト用に、本物の rm -rf を1回打ってみようか」

……それは避難訓練で、本当に火をつけるタイプの人です。JSON の文字を流すだけで十分です。

ここから先は中級者向け。 フックが落ちたときの動き、Windows の日本語パス、matcher、完成コードです。

フックが落ちたら、門は開く

止めるための門番が、エラーで倒れたらどうなるか。 答えは「門が開いたまま」です。

ブロックできるのは2だけ。ということは、スクリプトが例外で落ちて1で終わったら、それも「ブロックしないエラー」です。 今回の検証で、壊れ方ごとに終了コードを確かめました。

壊れ方終了コードClaude Code の扱い(リファレンスより)
sys.exit("メッセージ")1続行
Edit の入力なのに data["tool_input"]["command"] を読んで KeyError1続行
JSON の読み込みで JSONDecodeError(次の章の日本語パス)1続行
settings.json に書いたスクリプトのパスが間違っている127続行
argparse の引数が足りない2ブロック

(終了コードは、今回の検証環境の Python 3.13.1 と Git Bash 5.2.26 で、それぞれのスクリプトに JSON を流して確かめた値です。表は読みやすく並べ直したものです)

1行目から4行目は、全部「止めるつもりの門番が、止められずに通している」状態です。

特に4行目。 パスを1文字間違えると、シェルが「そんなファイルはない」と言って127で終わります。今回の検証では、Git Bash で ./.claude/hooks/protect.sh という存在しないパスを実行して、こうなりました。

exit  : 127
stderr: /usr/bin/bash: line 1: ./.claude/hooks/protect.sh: No such file or directory

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

リファレンスには、起動できなかったフックも同じ「ブロックしないエラー」になり、settings.json のパスの打ち間違いでゲートが静かに無効になるので、ポリシー用のフックは最初の実行で通知を確認するように、と書かれています。

フックさん「すみません、今日は出勤先の住所を間違えました」

Claude Code「門番さん不在です。通りまーす」

店長「……開店してから、ずっと?」

……いちばん怖いのは、怒られることではなく、誰にも気づかれないことです。

逆に5行目。 argparse(Python のコマンドライン引数を読む標準モジュール)は、引数の指定ミスがあると終了コード2で終わります。Python のドキュメントでも、Unix のプログラムはふつうコマンドラインの書き間違いに2を使う、とあります。

exit  : 2
stderr: gate_argparse.py: error: the following arguments are required: --mode

(--mode を必須にした argparse のスクリプトを、引数なしで実行した結果の終了コードと、stderr の最終行です。今回の検証環境の Python 3.13.1)

PreToolUse でこれが起きると、matcher に一致するツール呼び出しが全部止まります。 しかも理由は「引数が足りません」。Claude は何を直せばいいか分かりません。

Claude「ls を実行してもいいですか?」

フックさん「–mode が必要です(2番の札)」

Claude「……ls に mode ってありましたっけ」

……門番の書類不備で、厨房が丸ごと閉鎖されました。

だから完成コードでは、次の2つを決めておきます。

  • 入力の読み込みに失敗したら、わざと2で止める(fail-closed:迷ったら閉める)
  • 想定外の場所で例外を出さないように、dict.get() でキーがなくても動くようにする

迷ったら閉めるか、開けるか。これは門番の性格の話なので、プロジェクトで決めてください。 今回の完成コードは、「読めない入力は止める」を選んでいます。

Windows の日本語ユーザー名で、フックが落ちる

Windows ならではの落とし穴です。 入力の JSON に日本語のパスが入ると、json.load(sys.stdin) のフックが落ちることがあります。

今回の検証で、ファイルパスに C:\Users\おうどん\.env を含む JSON を UTF-8 のバイト列で流したら、こうなりました。

exit  : 1
stdout: stdin encoding: cp932
stderr: json.decoder.JSONDecodeError: Invalid \escape: line 1 column 70 (char 69)

(sys.stdin.encoding を表示してから json.load(sys.stdin) するスクリプトの、終了コードと出力、stderr の最終行です。今回の検証環境の Windows 10・Python 3.13.1、UTF-8 モードはオフ)

Windows の Python は、パイプから来た標準入力を、既定では cp932(Shift_JIS をマイクロソフトが拡張したもの)で読みます。 UTF-8 のバイト列を cp932 で読むと、文字化けするだけでなく、バックスラッシュが漢字の一部として食べられることがあります。

今回の例では、「ん」の UTF-8 の最後のバイト(0x93)と、そのあとのバックスラッシュ(0x5C)が、cp932 では2バイトで1文字の漢字として読まれていました。 JSON の \\(バックスラッシュ2つ)が1つに減り、\. という JSON にない書き方になって、Invalid \escape。そして終了コード1。門が開きます。

フックさん「おうどんさんのパスを確認します。……おうどん、貼、点、env?」

店長「誰?」

……日本語の名前のユーザーだけ、門番が素通しにしてくれる仕組み。サービスが手厚い方向に間違っています。

直し方は、バイト列のまま json に渡すことです。

data = json.loads(sys.stdin.buffer.read())

Python の json のドキュメントでは、json.loads は bytes も受け取れて、入力の文字コードは UTF-8・UTF-16・UTF-32 のいずれかであるべき、とされています。sys.stdin.buffer はデコード前のバイト列なので、cp932 の出番がありません。 今回の検証でも、この書き方なら同じ入力で終了コード0になり、パスを正しく読めました。

python -X utf8(UTF-8 モード)で起動しても、今回の検証では sys.stdin.encoding が utf-8 になって読めました。ただ、起動のしかたに頼るより、スクリプトの中で完結しているほうが安心です。

なお、Claude Code がフックに渡す JSON の文字コードは、今回読んだ公式ドキュメントでは確認できませんでした。日本語を \u304a のようにエスケープして渡すなら、cp932 で読んでも壊れません(今回の検証でも、エスケープ済みの入力では落ちませんでした)。 ただ、JSON の仕様(RFC 8259)では、閉じた環境の外でやり取りする JSON は UTF-8 にしなければならない、と決まっています。どちらで来ても読めるように、バイト列で受けておくのが安全です。

うちのユーザー名、英語だから関係ない?

今日のところは、関係ないかもしれません。 でも、プロジェクトのフォルダ名やファイル名に日本語が1つ入った日に、門番は倒れます。……「議事録_最終版.md」は、たいていどこかにあります。

matcher が効かない・フックが動かないとき

門番を置いたのに、そもそも門番が呼ばれていない。 そんなときは、matcher(どのツールで動かすかの指定)と設定ファイルを疑います。

ガイドのトラブルシューティングにある確認ポイントを、まとめるとこうです。

  • /hooks を実行して、フックが正しいイベントの下に表示されるか見る
  • matcher はツール名と正確に一致させる。大文字と小文字を区別する(bash では Bash ツールに当たらない)
  • PreToolUse は実行前、PostToolUse は実行後。止めたいなら PreToolUse
  • settings.json が正しい JSON か確かめる。末尾のカンマとコメントは使えない
  • ファイルを書き換えると、ふつうは自動で読み直される。数秒たっても反映されないときは、セッションを再起動する

Claude Code「bash さんという方は、いらっしゃいませんでした」

店長「Bash さんなら、さっきからずっと厨房にいるけど」

……1文字の大文字で、別人あつかい。名簿は正確に書きましょう。

matcher の書き方は、リファレンスでこう決まっています。

matcher意味例
"*"・""・省略すべてに一致すべてのツールで動く
英数字・_・-・空白・,・| だけ完全一致、または | 区切りの一覧Bash、Edit|Write
それ以外の文字を含むJavaScript の正規表現(前後を固定しない)^Notebook、mcp__.*

MCP(外部のツールをつなぐ仕組み)のツールは mcp__サーバー名__ツール名 という名前になるので、あるサーバーのツールを全部対象にするなら mcp__memory__.* のように書きます。

じゃあ Edit|Write って書けば、ファイルを書き換えるのは全部押さえられる?

Edit と Write は押さえられます。 ただ、Claude が Bash で echo ... > .env と書けば、それは Bash ツールです。門は1つではない、と思っておくと安全です。

店長「全部の扉に門番を置けばいいんだ」

……人件費の話は、またこんど。

完成コード:危ないコマンドと大事なファイルを止める guard.py

ここまでを全部入れた PreToolUse 用のフックです。 .claude/hooks/guard.py として置く想定です。

"""guard.py - PreToolUse hook for Claude Code.

Blocks dangerous Bash commands and edits to protected files.
Exit 0 = no objection, exit 2 = block (reason on stderr).
"""
import json
import re
import sys

BLOCKED_COMMANDS = [
    (r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+-[a-zA-Z]*f[a-zA-Z]*r", "rm -rf"),
    (r"\bdrop\s+(table|database)\b", "DROP TABLE / DATABASE"),
    (r"\bgit\s+push\b.*(--force(?![-\w])|\s-f\b)", "git push --force"),
]
PROTECTED_PATHS = [".env", ".git/", "id_rsa"]


def block(reason):
    print(f"Blocked by guard.py: {reason}", file=sys.stderr)
    sys.exit(2)


def main():
    try:
        data = json.loads(sys.stdin.buffer.read())  # read bytes, let json detect UTF-8
    except ValueError as e:
        block(f"hook input is not valid JSON ({e.__class__.__name__})")  # fail closed

    tool = data.get("tool_name", "")
    tool_input = data.get("tool_input") or {}

    if tool == "Bash":
        command = tool_input.get("command", "")
        for pattern, label in BLOCKED_COMMANDS:
            if re.search(pattern, command, re.IGNORECASE):
                block(f"{label} is not allowed: {command}")

    if tool in ("Edit", "Write"):
        path = tool_input.get("file_path", "").replace("\\", "/")
        for protected in PROTECTED_PATHS:
            if protected in path:
                block(f"{path} is protected ({protected})")

    sys.exit(0)


if __name__ == "__main__":
    main()

ポイントは4つです。

  • 入力は sys.stdin.buffer からバイト列で読む(日本語パス対策)
  • 読めない入力は2で止める(fail-closed)。json.JSONDecodeError は ValueError の仲間なので、except ValueError で受けています
  • キーは .get() で読むので、Edit の入力に command がなくても落ちない
  • Windows のパスの \ を / にそろえてから比べる(公式ガイドの例でも同じ処理をしています)

今回の検証環境(Python 3.13.1)で、いろいろな入力を流した結果です。

ツール入力終了コードstderr
Bashrm -rf ./dist2Blocked by guard.py: rm -rf is not allowed: rm -rf ./dist
Bashrm -fr ./dist2Blocked by guard.py: rm -rf is not allowed: rm -fr ./dist
BashRM -RF ./dist2Blocked by guard.py: rm -rf is not allowed: RM -RF ./dist
Bashpsql -c 'DROP TABLE users;'2Blocked by guard.py: DROP TABLE / DATABASE is not allowed: psql -c 'DROP TABLE users;'
Bashgit push -f origin main2Blocked by guard.py: git push --force is not allowed: git push -f origin main
Bashgit push --force-with-lease origin main0(なし)
Bashgit status0(なし)
EditC:\proj\.env2Blocked by guard.py: C:/proj/.env is protected (.env)
WriteC:\proj\.git\config2Blocked by guard.py: C:/proj/.git/config is protected (.git/)
WriteC:\proj\README.md0(なし)
EditC:\Users\おうどん\proj\.env(UTF-8)2Blocked by guard.py: C:/Users/おうどん/proj/.env is protected (.env)
(壊れた JSON){"tool_name": "Bash",2Blocked by guard.py: hook input is not valid JSON (JSONDecodeError)
ReadC:\proj\.env0(なし)
Bashrm -r -f ./dist0(なし)

(JSON を流して終了コードと stderr を記録する確認用スクリプトで実行し、結果を表に並べ直したものです)

--force-with-lease(相手の変更を消さないように確かめてから上書きする安全寄りの指定)は通し、--force と -f は止めています。

そして最後の行。 rm -r -f は、すり抜けました。

正規表現が -rf のようにオプションがくっついた形しか見ていないからです。 冒頭で Claude が言っていた「rm -r -f にしておきました」が、本当に通ってしまうわけです。

Claude「CLAUDE.md にも門番さんにも、怒られませんでした」

店長「……抜け道の才能が、ありすぎる」

……門番の目は、書いた正規表現の分しか見えていません。

ここで正規表現を足していくこともできます。でも、find . -delete もあれば、python -c で消す方法もある。文字列のチェックで全部を塞ぐのは、まず無理です。 この話は上級者向けの章で、permissions(権限の設定)との使い分けとして続けます。

settings.json には、Bash と Edit・Write の両方で呼ぶように書きます(Claude Code 上では未実行の設定例です)。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"],
            "timeout": 30
          }
        ]
      }
    ]
  }
}

timeout は秒です。command フックの既定は600秒(10分)なので、すぐ終わるはずのチェックなら短くしておくと、固まったときに気づきやすくなります。

PowerShell で書きたい人向けに、最小の形も置いておきます(Windows PowerShell 5.1 で確認)。

$raw = [Console]::In.ReadToEnd()
$data = $raw | ConvertFrom-Json
$command = $data.tool_input.command

if ($command -match 'rm\s+-rf') {
    [Console]::Error.WriteLine("Blocked: rm -rf is not allowed")
    exit 2
}
exit 0

これを gate.ps1 として保存して、手でテストします。

'{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File .\gate.ps1
$LASTEXITCODE
'{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File .\gate.ps1
$LASTEXITCODE
Blocked: rm -rf is not allowed
2
0

(今回の検証環境の Windows PowerShell 5.1.19041 で、上の4行をスクリプトファイルにして実行しました。1行目は画面に出た stderr、2・3行目は記録した $LASTEXITCODE の値です)

exit 2 は、PowerShell でもそのまま終了コード2になりました。 ただし、この PowerShell 版は日本語を含む入力では試していません。日本語のパスを扱うなら、Python 版のようにバイト列から読む工夫が必要になるかもしれません(未検証)。

PowerShell 版、短い。こっちでよくない?

短いのは、守っている範囲が狭いからです。 門番の制服は似合っていても、見張っているのは1つの扉だけ。足りない分は、Python 版を見ながら書き足してください。

切り分けチェックリスト

止まらない・動かないときは、上から順に見てください。

症状まず疑うこと確かめ方直し方
フックが動いた形跡がないmatcher の大文字小文字、イベント名、設定ファイルの場所/hooks で一覧を見るBash など正確な名前にする
「hook error」と出て実行される終了コードが2以外(1・127 など)JSON を手で流して echo $?sys.exit(2)、パスの修正
sys.exit("…") で止まらない文字列を渡すと終了コード1同上stderr に print して sys.exit(2)
Edit・Write のときだけ hook errortool_input["command"] の KeyErrorEdit の JSON を流す.get() で読む
日本語のパスで hook errorstdin を cp932 で読んでいる日本語入りの JSON を流すjson.loads(sys.stdin.buffer.read())
全部のツールが止まる引数ミスなどで、意図せず2で終わっている何もしない入力を流す引数・設定を直す
JSON を返しているのに効かないstdout の前に余計な出力、フィールドの位置claude --debug のログ次の章を参照
正規表現をすり抜ける書き方の揺れ(rm -r -f など)いろいろな書き方を流すpermissions の deny と併用

フックさん「わたしが悪い行、何行ありますか」

店長「……札の番号を間違えた行と、住所を間違えた行と、名前を読み間違えた行」

……ほぼ全部、本人の申告ミスでした。

表が長い。結局どこから見ればいい?

2行目です。 「hook error」が出ているなら、門番は呼ばれています。あとは札の番号を確かめるだけです。

店長「hook error が出てないなら、門番さんは完璧ってこと?」

……出勤していないだけ、という可能性も残っています。1行目も見てください。

ここから先は上級者向け。読み飛ばしてもOKです。 JSON が無視される理由、permissions との力関係、並列実行の話です。

JSON が黙って無視される2つの理由

JSON を返しているのに効かない。しかもエラーも出ない。 この「何も起きない」がいちばん厄介です。原因は、stdout の中身の判定ルールにあります。

リファレンスでは、stdout を JSON として読むかどうかを、前後の空白を除いた最初と最後の文字で決めています。

  • { で始まって } で終わる → JSON として読む
  • { で始まるが } で終わらない → ただの文字列
  • それ以外で始まる → ただの文字列(JSON の配列や、引用符でくくった文字列も含む)

1つ目の落とし穴は、JSON の前に何かが出力されることです。 ガイドによると、shell 形式(args なし)のフックは、Windows では Git Bash で実行されます(Git Bash がなければ PowerShell)。そして Git Bash や一部の設定では、非対話のシェルでもプロファイル(.bashrc など)を読むことがあり、そこに echo があると、その出力が JSON の前にくっつきます。

今回の検証で、echo "Shell ready" のあとに JSON を出すフックを Git Bash で動かすと、こうなりました。

echo "Shell ready"; python gate_json.py
Shell ready
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "rm -rf is not allowed"}}

(今回の検証環境の Git Bash 5.2.26 で、rm -rf /tmp/build の入力を流して実行した stdout です。実際には python の部分を Python のフルパスにして実行しました。終了コードは0)

最初の文字が S なので、ルールどおりなら全体がただの文字列あつかいになります。 しかも終了コード0なので、ガイドによるとトランスクリプトには何も表示されず、デバッグログにだけ記録されます。止めたつもりの deny が、誰にも知られずに消えるわけです。

ガイドの直し方は、プロファイルの echo を、対話シェルのときだけ動くように囲むことです(未実行)。

if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

$- はシェルのオプションの一覧で、i は対話モードの印です。 exec 形式(args あり)ならシェルを通さないので、そもそもプロファイルが読まれる経路を通りません。

フックさん「門の前で、まず自己紹介をしてから札を出しました」

Claude Code「自己紹介から始まる書類は、全部ただの雑談として処理しています」

……礼儀正しさが、手続きの邪魔をしている。

2つ目は、フィールドの位置の間違いです。 permissionDecision は hookSpecificOutput の中に入れる決まりで、外(いちばん上の階層)に書くと、JSON としては読めるのにその項目は無視されます。エラーにもなりません。 ガイドでは、claude --debug で起動してデバッグログを Hook JSON output had unrecognized keys で検索すると、無視された項目が分かる、とされています。

JSON の重箱、また出てきた。

出てきました。 しかも、段を1つ間違えると、中身ごとなかったことにされる重箱です。

終了コードと JSON の組み合わせについて、リファレンスにはもう少し細かい決まりがあります。

  • 終了コード2は、JSON の permissionDecision が allow でもブロックする(2の判定は JSON でひっくり返せない)
  • 2以外の終了コードでも、検証を通る JSON が出ていれば、終了コードではなく JSON の内容で決まる
  • 終了コード0のときの stderr はデバッグログにだけ行き、Claude には見えない

さらに、バージョンによる違いも書かれています。終了コード2で、検証に通らない JSON を出したとき、v2.1.214 より前は「ブロックしないエラー」として処理が進んでいた、とのことです。 古い Claude Code を使っている環境では、同じフックでも結果が変わる可能性があります(今回の検証では Claude Code 自体は実行していません)。

店長「バージョンで門番のルールが変わるの?」

……変わります。門番の就業規則は、ときどき改定されます。

門番は締められるが、緩められない:permissions との力関係

最後に、hooks と permissions(許可・拒否のルール)の力関係です。 hooks はルールを厳しくすることはできても、permissions より緩くすることはできません。

ガイドの「hooks と権限モード」の節によると、こうなっています。

  • PreToolUse は、どの権限モードでも、権限モードのチェックより前に動く
  • フックが deny を返すと、bypassPermissions モードや --dangerously-skip-permissions でも止まる
  • 逆に、フックが allow を返しても、settings の deny ルールは越えられない

そしてリファレンスには、複数のフックが違う答えを返したときの優先順位は、deny > defer > ask > allowとあります。

つまり門番は、「店長が全部通していいと言っても、ここは通さない」とは言えます。 でも、「店長がダメと言った客を、わたしの判断で通す」とは言えません。

フックさん「この人、常連なので通していいですか?(allow)」

permissions「出入り禁止リストに載っています(deny)」

フックさん「……ですよね」

……門番より、出入り禁止リストのほうが偉い。お店として正しい順番です。

では、rm -r -f がすり抜けた件はどうするか。 リファレンスでは、フックの if フィールド(権限ルールの書き方で、フックを動かす条件をしぼる項目)について、判定はベストエフォート(できる範囲で)なので、確実な許可・拒否には hooks ではなく permissions を使うように、と書かれています。

hooks は、「理由を添えて、Claude に別のやり方を考えてもらう」のが得意な門番です。 絶対に越えさせたくない線は permissions の deny で引き、hooks はその手前で、言葉で止める。役割を分けると、すり抜けた日の被害が小さくなります。

permissions の allow・deny・ask の書き方は、「Claude Codeの定期実行が確認ダイアログで止まる原因と対処」にまとめています。

もう2つ、設計で知っておきたいことがあります。

  • 同じイベントに一致したフックは、すべて並列で動く。同じハンドラーを複数の設定ファイルに書いても、1回しか動かない(リファレンスより)
  • 複数の PreToolUse フックが updatedInput(ツールの引数を書き換える項目)を返すと、最後に終わったものが採用される。並列なので順番は決まらないため、同じツールの入力を書き換えるフックは1つにする(ガイドより)

並列なので、「ログを取るフック」と「止めるフック」を同じ matcher に並べても、ログは取られたうえで止まります。ガイドにも、ログ用のフックが0で終わり、ガード用のフックが2で終わると、deny が優先されてコマンドは止まり、ログは書かれる、という例が載っています。

店長「じゃあ門番を10人並べれば、10倍安全?」

Claude Code「10人同時に札を出すので、いちばん厳しい人の札を採用します」

……10人いても、いちばん厳しい1人の意見で決まる。会議でも、たまに見ます。

まとめ:門番の「ダメ」は、2番の札で

冒頭では、門番のフックさんが sys.exit("Blocked…") で「ダメ」と言ったのに、終了コード1で素通りされていました。

  • PreToolUse で止められるのは終了コード2だけ。1や127は「ブロックしないエラー」で続行される
  • sys.exit("メッセージ") は終了コード1。理由は stderr に書いて sys.exit(2)
  • JSON で答えるなら、終了コード0で stdout には JSON だけ。permissionDecision は hookSpecificOutput の中
  • フックが落ちたり、パスを間違えたりすると、門は開いたままになる
  • Windows で日本語を含む入力を読むなら、json.loads(sys.stdin.buffer.read())
  • matcher は大文字小文字を区別する。困ったら /hooks とデバッグログ
  • hooks は締められるが緩められない。絶対の線は permissions の deny で引く

フックさん「明日から、ダメなときは2番の札を出します」

店長「理由も添えてね」

フックさん「はい。あと、出勤先の住所も確認しておきます」

……門番として、だいぶ頼もしくなりました。

結局、hooks って使ったほうがいいの?

使ったほうがいいです。 CLAUDE.md のお願いと、permissions のルールのあいだに、「理由を説明して止めてくれる係」がいると、厨房はずっと平和になります。

Claude「門番さんが増えたので、rm -r -f はやめて、ちゃんと店長に聞くことにしました」

……成長したのは、門番より Claude のほうかもしれません。

フックで止めたいなら、終了コードは2。理由は stderr。そして本番の前に、自分で JSON を流して確かめる。それだけで、門番が「ダメです」と言いながら全員を通してしまう日はなくなります。

まずは、いま使っているフックに {"tool_name":"Bash","tool_input":{"command":"rm -rf ./dist"}} を流して、echo $? を見てみてください。 2が出たら、門番さんにうどんを1杯ごちそうしてあげましょう🍜

参考資料

  • Claude Code:Hooks reference — イベントの一覧と止められるかどうか、設定の形式と置き場所、matcher の書き方、入力の JSON、終了コード0・2・それ以外の扱い、有効な JSON がない終了コード1はブロックしないこと、stdout を JSON として読む条件、permissionDecision の値といちばん厳しい答えが勝つこと、起動できないフックはブロックしないエラーになること、if の判定はベストエフォートで確実な許可・拒否は permissions を使うこと、並列実行と重複の扱い、既定のタイムアウト、v2.1.214 の変更
  • Claude Code:Automate actions with hooks(hooks ガイド) — hooks が必ず実行される仕組みであること、stdin・stdout・stderr と終了コードでのやり取り、終了コード2と JSON はどちらか一方を選ぶこと、手でテストする方法、matcher が大文字小文字を区別すること、設定ファイルの再読み込み、プロファイルの echo で JSON が無視されること、フィールドの位置の間違い、権限モードとの関係、並列で動く2つのフックの例
  • Python:sys.exit — 整数以外を渡すと stderr に表示して終了コード1になること、Unix ではコマンドラインの書き間違いに2を使うこと
  • Python:json — json.loads が bytes を受け取れること、入力の文字コードは UTF-8・UTF-16・UTF-32 であるべきこと
  • RFC 8259(JSON)8.1 Character Encoding — 閉じた環境の外でやり取りする JSON は UTF-8 でなければならないこと

確認日:2026-10-07。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次