◉CLAUDE LABEN
●2.1.296 — サブエージェントごとに autoCompactWindow を指定でき、ワークフロー用のモデルも環境変数で固定できます●HAIKU 5.5 — 2.1.293(10/07)から claude-haiku-5-5 が Anthropic API の既定の Haiku モデルになりました●SONNET 4.5 — Claude API での提供終了は 11/30、残り47日。移行先は Sonnet 5.5●Q&A — Mod が「エラーなしで何も描かれない」ときに見る3か所が共有されています●AGENTS.MD — Codex と Claude Code を同じフォルダで使うとき、AGENTS.md と CLAUDE.md の入口をどう分けるかの整理が出ています●NEW — 「cd して git add して commit」の承認が毎回出るのは、設定の抜けではなく仕様でした●2.1.296 — サブエージェントごとに autoCompactWindow を指定でき、ワークフロー用のモデルも環境変数で固定できます●HAIKU 5.5 — 2.1.293(10/07)から claude-haiku-5-5 が Anthropic API の既定の Haiku モデルになりました●SONNET 4.5 — Claude API での提供終了は 11/30、残り47日。移行先は Sonnet 5.5●Q&A — Mod が「エラーなしで何も描かれない」ときに見る3か所が共有されています●AGENTS.MD — Codex と Claude Code を同じフォルダで使うとき、AGENTS.md と CLAUDE.md の入口をどう分けるかの整理が出ています●NEW — 「cd して git add して commit」の承認が毎回出るのは、設定の抜けではなく仕様でした
記事一覧/Claude Code
⟐ Claude Code/2026-10-14上級

onFailure を block にしても、jq が無い環境では公式例の rm -rf ブロックは素通りしました

PreToolUse の門番フックは、落ちたときに通すのか止めるのか。公式例を jq の無い環境で走らせると exit 0 で素通りしました。終了コード10通りの実測、直し方、settings.json を走査して素通りを洗い出すスクリプトをまとめます。

Claude Code264hooks21onFailurePreToolUse4セキュリティ21

公式ドキュメントにある 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 0exit 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 あり027 ms通る通る
doc 版 / rm -rf / jq なし0(出力なし)7 ms通る通る
strict 版 / rm -rf / jq あり227 ms拒否拒否
strict 版 / ls / jq あり027 ms通る通る
strict 版 / rm -rf / jq なし35 ms通る(止まらないエラー)止まる
存在しない .sh(シェル形式)1273 ms通る止まる
存在しない .js(node)143 ms通る止まる
例外で落ちる .js137 ms通る止まる
2 秒止まる(1 秒で打ち切り)1241,005 ms通る止まる

「onFailure なし」「block 指定時」の列は、ドキュメントの定義に私が終了コードを当てはめたものです。ここで見ていただきたいのは、太字の行です。doc 版は jq が無いと 7 ms で exit 0 を返します。速く、黙って、何も言わず通してしまいます。block を付けても結果は変わりません。

公式例が素通りしていた理由

仕組みは三段階です。

  1. COMMAND=$(jq ...) の jq が見つからず、代入は失敗しますが、代入の失敗はスクリプトを止めません(set -e が無いため)。
  2. COMMAND は空文字のまま、echo "" | grep -q 'rm -rf' は偽になります。
  3. 偽の枝は 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 から返してはいけません にまとめました。

セッションで確かめる手順

ここまでの結果は、スクリプト単体の終了コードと、公式ドキュメントの定義を突き合わせたものです。セッションでの挙動は、ドキュメントにある次の手順で確かめられます。

  1. 上の settings.json から check-command.js をわざと置かずに、Claude Code を起動します。
  2. Bash で ls を実行するよう頼みます。
  3. 呼び出しが止まり、failed; blocking because onFailure is "block" を含むエラーが出れば、block が働いています。
  4. onFailure の行を消して同じことをします。今度は ls が通り、止まらないエラーの通知だけが残ります。

この 4 手順は、門番を置いた日に一度だけ行えば足ります。門番を足すたびに、先に「壊した状態」で一度通しておくことを、私の決まりにしました。

結び

今日いちばん小さくできる確認は、node audit-hooks.js .claude/settings.json を一度走らせることです。数えた件数のうち、拒否や検証を担っているものだけに onFailure: "block" と timeout を足し、スクリプトの中身が失敗を握りつぶしていないかを読んでみていただければと思います。

門番は、動いているあいだは何も教えてくれません。転んだときにどちらへ倒れるかだけは、これからも自分の手で決めておくようにいたします。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ Claude Code2026-10-05
「コミットは私がします」と書いたのに git log に一行増えていた朝、指示を強めるのをやめました
Claude Code に「コミットしないで」と頼んだのにコミットされたとき、まず git reset --soft で戻し、permissions.deny と PreToolUse hook の二層で物理的に止める手順です。deny ルールが素通しする git -c の形と、hook の試し方を書き残します。
⟐ Claude Code2026-07-30
作業ルートを途中で足したとき、無視規則と deny はどこまで届くのか — DirectoryAdded で境界を測り直す
セッション途中で作業ルートを追加すると、無視規則も deny パターンもそのままでは新しいルートに届きません。git check-ignore と照合器の比較で境界を実測し、DirectoryAdded から呼ぶ差分監査スクリプトの実行結果まで記録しました。
⟐ Claude Code2026-09-05
拒否ルールが見ていたのはファイル名で、そこへ届く経路ではありませんでした
deny に書いた 1 行が、同じファイルへ届く別の書き方を素通りさせていました。経路を機械的に並べて、版を上げた日に効きを確かめる小さな検査表の作り方をお伝えします。