公式ドキュメントにある rm -rf を止めるフックを、自分の設定へ写しているときのことです。動くことを確かめたあと、念のため PATH から jq を外して、もう一度走らせてみました。
終了コードは 0。標準出力は空で、標準エラーに jq: command not found が一行出ただけでした。止めるために置いた門番が、道具が見つからないという理由だけで、何も言わずに扉を開けたのです。
最初にお伝えしたいのは、v2.1.295 で入った onFailure の block は、この種類の素通りを拾ってくれない、という点です。拾えるのは「非 0 で落ちた」フックだけで、静かに exit 0 を返したフックには届きません。Next.js と Cloudflare Workers で自分の Web サイトを運用している立場から、10 通りの失敗を実際に走らせた記録と、その直し方を書き残します。
結論を先に: block が拾うもの、拾わないもの
"onFailure": "block" を付けると、PreToolUse のフックが失敗したとき、ツール呼び出しは止まります。付けなければ、失敗しても呼び出しは通ります。公式ドキュメントの定義では、次のどれかが「失敗」に数えられます。
| 失敗の種類 | 具体例 | onFailure: block で止まるか |
|---|---|---|
| 起動できない | スクリプトや実行ファイルが存在しない | 止まる |
| 0 と 2 以外の終了コード | 例外で exit 1、シェルの 127 | 止まる(allow の JSON を出していても失敗扱い) |
| タイムアウト | timeout 秒に達した | 止まる |
| 出力が不正 | JSON として読めない、スキーマ違反 | 止まる |
| HTTP フックの接続失敗・2xx 以外 | 検証サーバーが落ちている | 止まる |
| exit 0 で何も言わずに通す | 取得に失敗した値が空のまま分岐に入る | 止まらない(失敗ではないため) |
表の最後の行が、今回の話の中心です。onFailure は、フックが「失敗した」と自己申告するか、Claude Code が失敗を検出できたときにだけ働きます。スクリプトの中で失敗を握りつぶして exit 0 にしてしまえば、設定でいくら block と書いても、扉は開いたままです。
公式の表を、門番の目線で読み直す
ドキュメントには、標準出力と終了コードの組み合わせで結果が決まる表があります。門番として使う場合に限って、私なりに読み替えるとこうなります。
| 標準出力 | exit 0 | exit 2 | それ以外の終了コード |
|---|---|---|---|
| 検証を通る JSON | 成功(JSON の内容で決まる) | ブロック(JSON では覆せない) | 成功扱い。ただし block 指定時は失敗 |
| 読めない JSON | 止まらないエラー | ブロック(stderr が理由) | 止まらないエラー |
| 平文、または空 | 成功 | ブロック(stderr が理由) | 止まらないエラー |
読んで手が止まったのは、右下の「止まらないエラー」でした。Unix の感覚では、exit 1 は「失敗」の標準です。ところが門番としては、exit 1 は「何も決められなかったので、通します」と同じ意味になります。ドキュメントにも「ポリシーを強制したいなら exit 2 を使う」と明記されています。
もうひとつ、タイムアウトの扱いも見逃せません。コマンドフックの既定は 600 秒です。止まったフックは、決定を返さないまま取り消され、呼び出しは通常の許可フローへ進みます。門番が固まっても、止めてはくれないのです。
手元で 10 通りを走らせた記録
読んだだけでは不安だったので、スクリプト単体を 10 通りの条件で走らせました。環境は手元の Linux(jq 1.6、Node 22)です。Claude Code のセッションそのものを動かした記録ではなく、フックスクリプトが返す終了コードと標準出力を測ったものだと、先にお断りしておきます。セッション側の確認手順は後半に置きました。
まず、公式ドキュメントの例を写したスクリプトと、失敗を握りつぶさないように書き直したスクリプトを用意します。
#!/bin/bash
# block-rm-doc.sh — 公式ドキュメントの例を写したもの(比較用)
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Destructive command blocked by hook"}}'
else
exit 0
fi#!/bin/bash
# block-rm-strict.sh — 取得に失敗したら、非 0 で落とす
set -euo pipefail
command -v jq >/dev/null || { echo "jq not found" >&2; exit 3; }
COMMAND=$(jq -er '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo "Destructive command blocked by hook" >&2
exit 2
fi
exit 0次に、条件ごとに終了コードと所要時間を並べる小さな実行器です。jq を外す条件は、必要なコマンドだけをシンボリックリンクした空の PATH で作りました。
#!/bin/bash
# run_matrix.sh — 各シナリオの終了コード・所要 ms・出力の先頭を表示する
IN_RM='{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"}}'
IN_OK='{"tool_name":"Bash","tool_input":{"command":"ls"}}'
NOJQ_PATH=$(mktemp -d)
for b in bash sh cat echo grep env node; do p=$(command -v $b) && ln -s "$p" "$NOJQ_PATH/$b"; done
run() {
local name="$1" input="$2"; shift 2
local s=$(date +%s%N)
out=$(printf '%s' "$input" | "$@" 2>/tmp/hk_err); code=$?
local ms=$(( ($(date +%s%N)-s)/1000000 ))
printf '%-40s exit=%-3s %4sms\n' "$name" "$code" "$ms"
}
run "doc版 / rm -rf / jq あり" "$IN_RM" ./block-rm-doc.sh
run "doc版 / rm -rf / jq なし" "$IN_RM" env PATH="$NOJQ_PATH" ./block-rm-doc.sh
run "strict版 / rm -rf / jq なし" "$IN_RM" env PATH="$NOJQ_PATH" ./block-rm-strict.sh
run "存在しない .js" "$IN_OK" node ./nothing.js
run "2 秒止まる(1 秒で打ち切り)" "$IN_OK" timeout 1 sleep 2実測の結果は次のとおりです(ミリ秒は 1 回ずつの値で、傾向を見るためのものです)。
| 条件 | 終了コード | 所要 | onFailure なしの結果 | block 指定時の結果 |
|---|---|---|---|---|
| doc 版 / rm -rf / jq あり | 0(deny の JSON) | 54 ms | 拒否 | 拒否 |
| doc 版 / ls / jq あり | 0 | 27 ms | 通る | 通る |
| doc 版 / rm -rf / jq なし | 0(出力なし) | 7 ms | 通る | 通る |
| strict 版 / rm -rf / jq あり | 2 | 27 ms | 拒否 | 拒否 |
| strict 版 / ls / jq あり | 0 | 27 ms | 通る | 通る |
| strict 版 / rm -rf / jq なし | 3 | 5 ms | 通る(止まらないエラー) | 止まる |
| 存在しない .sh(シェル形式) | 127 | 3 ms | 通る | 止まる |
| 存在しない .js(node) | 1 | 43 ms | 通る | 止まる |
| 例外で落ちる .js | 1 | 37 ms | 通る | 止まる |
| 2 秒止まる(1 秒で打ち切り) | 124 | 1,005 ms | 通る | 止まる |
「onFailure なし」「block 指定時」の列は、ドキュメントの定義に私が終了コードを当てはめたものです。ここで見ていただきたいのは、太字の行です。doc 版は jq が無いと 7 ms で exit 0 を返します。速く、黙って、何も言わず通してしまいます。block を付けても結果は変わりません。
公式例が素通りしていた理由
仕組みは三段階です。
COMMAND=$(jq ...)の jq が見つからず、代入は失敗しますが、代入の失敗はスクリプトを止めません(set -eが無いため)。COMMANDは空文字のまま、echo "" | grep -q 'rm -rf'は偽になります。- 偽の枝は
exit 0です。「該当なし、通常の許可フローへ」という意味の終了コードを、障害の場面でも返してしまいます。
この流れで怖いのは、正常系の exit 0 と、障害時の exit 0 が見分けられないことです。ドキュメントの例は、動作を説明する最小の書き方で、本番の門番としての堅牢さまでは目指していないのだと思います。私が写したときも、そこを読み飛ばしておりました。
門番が転んだとき、扉を開けたままにするか閉めるかは、転ぶ前に私が決めておく。 この線引きを、フックごとに書き分けるのが、本記事の意思決定です。
直し方 1: 失敗を握りつぶさない門番にする
直すのは、スクリプトの側です。bash なら set -euo pipefail と、依存コマンドの存在確認を入れます。上の strict 版がその形で、jq が無ければ exit 3、拒否したいときだけ exit 2、と使い分けています。
Node で書く場合は、入力の検証に失敗したら例外のまま落とします。例外は exit 1 になり、onFailure: "block" が受け止めてくれます。
// .claude/hooks/check-command.js — 失敗はすべて非 0 で落とし、onFailure に受けさせる
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const cmd = JSON.parse(raw).tool_input?.command; // 壊れた入力は例外 → exit 1
if (typeof cmd !== "string") {
throw new Error("tool_input.command が文字列ではありません"); // 想定外も exit 1
}
if (/\brm\s+-rf\b/.test(cmd)) {
process.stderr.write("Destructive command blocked by hook\n");
process.exit(2); // 意図した拒否は 2
}
process.exit(0);
});4 つの入力で確かめた結果は、rm -rf /tmp/x が 2、ls が 0、JSON ではない文字列が 1、tool_input が空のオブジェクトが 1 でした。「拒否は 2、想定外は 1、問題なしは 0」と、終了コードが三つの意味をきちんと分けています。
この形にしておくと、onFailure の有無で挙動を選べます。block を付ければ想定外は止まり、付けなければ通ります。判断を設定に出せるところが、握りつぶす書き方との違いです。
直し方 2: settings.json に onFailure と timeout を入れる
設定側では、門番として置くフックに onFailure と、短めの timeout を足します。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"timeout": 5,
"onFailure": "block"
}
]
}
]
}
}timeout を 5 秒にしたのは、既定の 600 秒だと、止まった門番を最長 10 分待つ形になるからです。block と組み合わせれば、5 秒で見切りをつけて止められます。パスには ${CLAUDE_PROJECT_DIR} を使い、args で渡しています。args を使うと、各要素が引用符の処理なしで一つの引数になるため、空白を含むパスでも壊れません。
意思決定: どのフックを閉じる側に置くか
では、すべてのフックに block を付ければよいのでしょうか。私は、そうはしない方針です。門番が壊れたときに失うものと、閉じたときに失うものを、フックごとに秤にかけています。
| フックの役割 | 失敗時の方針 | 理由 |
|---|---|---|
| 破壊的コマンド・秘密ファイルの拒否(PreToolUse) | 閉じる(block) | 素通りの被害が、止まる手間より大きい |
| 本番デプロイ前の検証(PreToolUse) | 閉じる(block) | 検証が無いまま進むほうが危ない |
| 整形・lint の自動実行(PostToolUse) | 開ける(continue) | すでに編集は終わっていて、止める対象がない |
| ログ記録・通知 | 開ける(continue) | 記録の失敗で作業を止めたくない |
| プロンプトへの文脈追加(UserPromptSubmit) | 基本は開ける | block だとプロンプトそのものが届かなくなる |
| Stop・SubagentStop・TaskCompleted | 設定しても効かない | ドキュメントで「効果なし」と明記されている |
| async・asyncRewake のフック | 設定しても効かない | バックグラウンド実行のため |
閉じる側の代償は、はっきりしています。門番スクリプトを壊した瞬間に、その matcher に当たるすべての呼び出しが止まります。Bash を matcher にした門番を壊せば、ls ひとつ通りません。そのため、閉じる門番にだけ、次の二つを守らせる方針にしています。
- 門番スクリプトは外部コマンドへの依存を最小にします(jq をやめて Node の標準機能だけにする、など)。
- 設定を直すための経路を、Claude Code の外に確保しておきます。エディタで settings.json を直接開けば、止まったセッションでも復旧できます。
「閉じる」と決めたときほど、「閉じたまま動かなくなる」ことへの備えが要るのです。
洗い出しスクリプト: 素通りする門番を数える
設定が増えてくると、どれが門番で、どれに onFailure が付いているのか、目で追えなくなります。そこで、settings.json を走査して、未指定の門番を数えるスクリプトを書きました。PreToolUse と UserPromptSubmit の command・http フックのうち、onFailure が block ではないものを列挙します。
#!/usr/bin/env node
// 使い方: node audit-hooks.js .claude/settings.json [...]
const fs = require("fs");
const GATES = ["PreToolUse", "UserPromptSubmit"]; // 失敗時に止める意味を持つイベント
let open = 0;
for (const file of process.argv.slice(2)) {
let cfg;
try { cfg = JSON.parse(fs.readFileSync(file, "utf8")); }
catch (e) { console.error(`ERROR ${file}: ${e.message}`); process.exitCode = 2; continue; }
for (const ev of GATES) {
for (const group of cfg.hooks?.[ev] ?? []) {
for (const h of group.hooks ?? []) {
if (!["command", "http"].includes(h.type)) continue;
if (h.async || h.asyncRewake) continue; // onFailure が効かない種類
if (h.onFailure === "block") continue;
open++;
const target = h.command ? [h.command, ...(h.args ?? [])].join(" ") : h.url;
console.log(`OPEN ${ev}[${group.matcher ?? "*"}] ${target} (timeout=${h.timeout ?? "既定"})`);
}
}
}
}
console.log(open ? `\n失敗時に素通りする門番: ${open} 件` : "\n未指定の門番なし");
process.exitCode = open ? 1 : (process.exitCode ?? 0);PreToolUse の Bash・Edit|Write と、PostToolUse を一つずつ含むサンプル設定で走らせると、block-rm-doc.sh と http://localhost:8080/validate の 2 件が OPEN と出て、終了コードは 1 でした。block を付けた node 版と、async のログ用フック、PostToolUse の整形は対象外として数えられません。存在しないファイルを渡したときは ERROR を出して終了コード 2 になります。
このスクリプトが見つけるのは「onFailure を付け忘れた門番」までです。「付けたのに exit 0 で握りつぶす門番」は、スクリプトの中身を読むしかありません。そちらは、先ほどの実行器で jq を外した条件を 1 行足せば確かめられます。
端末に残ったログ(再実行で同じ終了コードを確認)
表の数字は一度きりの値だったため、後日あらためて同じ実行器をもう一度走らせ、端末の出力をそのまま残しました。終了コードは 10 通りすべて一致し、ミリ秒だけが数 ms から十数 ms ずれました。傾向を見る用途には十分だと考えております。
$ uname -sr; jq --version; node -v
Linux 6.8.0-138-generic
jq-1.6
v22.23.2
$ ./run_matrix.sh
doc版 / rm -rf を含む / jq あり exit=0 48ms stdout={ "hookSpecificOutput": { "hookEventNa
doc版 / ls / jq あり exit=0 29ms stdout= stderr=
doc版 / rm -rf を含む / jq なし exit=0 7ms stdout= stderr=./block-rm-doc.sh: line 3: jq: com
strict版 / rm -rf を含む / jq あり exit=2 27ms stdout= stderr=Destructive command blocked by hoo
strict版 / ls / jq あり exit=0 27ms stdout= stderr=
strict版 / rm -rf を含む / jq なし exit=3 5ms stdout= stderr=jq not found
存在しない .sh(シェル形式) exit=127 4ms stdout= stderr=sh: 1: ./nothing.sh: not found
存在しない .js(node) exit=1 35ms stdout= stderr=node:internal/modules/cjs/loader:1
例外で落ちる .js exit=1 38ms stdout= stderr=[eval]:1throw new Error("boom")^Er
2 秒止まる .sh(timeout 1 秒想定) exit=124 1004ms stdout= stderr=3 行目の exit=0 ... 7ms が、jq の無い環境で素通りした行です。標準エラーに command not found が出ているのに、終了コードは 0 のままでした。
落とし穴
- exit 1 は止まりません。 JSON を出さない exit 1 は止まらないエラーです。拒否したいときは exit 2 にします。
- exit 2 の JSON は拒否を覆せません。 2 で終わる場合、標準出力に allow を書いても、ブロックは変わりません。
- allow の JSON を出して exit 1 だと、block 指定時は失敗になります。 判断を JSON で返すなら、必ず exit 0 で終えます。
- バックグラウンド処理の出力に注意が要ります。 決定を出力したあとに子プロセスを起こすなら、その標準出力と標準エラーをリダイレクトしておきます。そうしないと、呼び出しが止められることがあります。
onFailureは v2.1.295 以降が前提です。 ドキュメントにそう明記されているため、古い版が混ざる環境ではclaude --versionを確かめてから使います。- 出力が多いフックは別の問題を起こします。 門番の出力量は 検証スクリプトの出力を、そのまま hook から返してはいけません にまとめました。
セッションで確かめる手順
ここまでの結果は、スクリプト単体の終了コードと、公式ドキュメントの定義を突き合わせたものです。セッションでの挙動は、ドキュメントにある次の手順で確かめられます。
- 上の settings.json から
check-command.jsをわざと置かずに、Claude Code を起動します。 - Bash で
lsを実行するよう頼みます。 - 呼び出しが止まり、
failed; blocking because onFailure is "block"を含むエラーが出れば、block が働いています。 onFailureの行を消して同じことをします。今度はlsが通り、止まらないエラーの通知だけが残ります。
この 4 手順は、門番を置いた日に一度だけ行えば足ります。門番を足すたびに、先に「壊した状態」で一度通しておくことを、私の決まりにしました。
結び
今日いちばん小さくできる確認は、node audit-hooks.js .claude/settings.json を一度走らせることです。数えた件数のうち、拒否や検証を担っているものだけに onFailure: "block" と timeout を足し、スクリプトの中身が失敗を握りつぶしていないかを読んでみていただければと思います。
門番は、動いているあいだは何も教えてくれません。転んだときにどちらへ倒れるかだけは、これからも自分の手で決めておくようにいたします。