---
title: "付録 C hook の契約 —— イベント・exit code・JSON"
author: garplab
publisher: TypingTube
license: All rights reserved
license_scope: 記事の全体（序論・最終回は CC BY 4.0 の対象外）。引用は法の範囲で自由
canonical: https://typing-tube.net/articles/furoku-c-hook-contract
series: "付録"
language: ja
---


先に結論です。**hook は、Claude Code とあなたのスクリプトの間の契約で、面は 3 つあります。** いつ走るか（イベントと matcher）、何を受け取るか（stdin の JSON）、何を返すか（exit code と stdout・stderr）。そして、返したものが**どこへ届くか**は、イベントごとに決まっています。手元の hook 11 本が使っている契約はごく一部で、exit 2 と stderr、`decision: "block"` と `reason`、`UserPromptSubmit` の平文 stdout、`stop_hook_active` の 4 つでほぼ全部です。本編が「止めて案内する」「保存の後に突きつける」「一度だけ引き戻す」と書いたことは、この 4 つの契約が、そのイベントでどこへ届くかの話です。

付録 A.2 は、1 ターンの中で hook が割り込む位置を図にしました。A.4 は、hook の stdout が会話層に入るイベントが限られていることを表にしました。この章は契約の全体を並べます。33 種のイベントのどれが止められるか、exit 2 の stderr が AI に届くイベントと、あなたにしか届かないイベント、JSON の書き方がイベントで違う理由、状態をどこに持つか、案内文に何を書くか、hook 自身をどう自動テストで守るか。読み終えたら、本編の 9 本が「なぜその exit code で、なぜそのイベントで」動いているかを、契約の側から言い直せるはずです。

## C.1 契約の 3 つの面

```mermaid
flowchart LR
  E["イベントが起きる<br/>33 種"] --> M{"matcher<br/>tool_name など"}
  M -- 一致しない --> S["何もしない"]
  M -- 一致 --> I{"if<br/>Bash(git *) など"}
  I -- 一致 --> H["handler<br/>stdin ← JSON"]
  H --> X["exit code<br/>+ stdout + stderr"]
  X --> D{"イベントごとの行き先"}
  D --> C1["AI に届く<br/>deny の理由 / block の reason / additionalContext"]
  D --> C2["あなたに届く<br/>systemMessage / 一部の stderr"]
  D --> C3["デバッグログだけ<br/>exit 0 の stdout と stderr（多くのイベント）"]
```

hook はモデルの外で動きます。Claude Code がイベントのたびに handler を起動し、JSON を stdin に流し、終了を待って、exit code と出力を読みます。モデルは hook を呼びません。呼ばれたことも、handler の中で何が起きたかも知らず、届いた文字列だけを読みます。第 1 部 第 6 回の「読まれなくても落ちる」は、この構造の言い換えです。

イベントは 33 種あります（2026-09-05、Claude Code 2.1.260）。本編が使ったのは 6 種で、残りは本編の外です。exit 2 で止められるかどうかを列にして並べます。

| いつ | イベント | exit 2 で止められるか | 本編 |
|---|---|---|---|
| セッション | `SessionStart` / `Setup` / `SessionEnd` | いいえ（stderr はあなたにだけ） | `SessionStart`（第 1 部 第 7 回の基準の撮り直し） |
| ターン | `UserPromptSubmit` / `UserPromptExpansion` | はい（プロンプトを消す） | `UserPromptSubmit`（メモリの索引を流す） |
| ターン | `Stop` / `StopFailure` | `Stop` は はい（続けさせる）。`StopFailure` は出力ごと無視 | `Stop`（一度だけ引き戻す） |
| ツール呼び出し | `PreToolUse` | はい（ツールを走らせない） | 入口の hook 4 本 |
| ツール呼び出し | `PermissionRequest` / `PermissionDenied` | いいえ | — |
| ツール呼び出し | `PostToolUse` / `PostToolUseFailure` | いいえ（ツールは済んでいる。stderr は AI に届く） | `PostToolUse`（保存後の差分） |
| ツール呼び出し | `PostToolBatch` | はい（次のモデル呼び出しの前で止める） | — |
| サブエージェント・タスク | `SubagentStart` / `SubagentStop` | `SubagentStop` だけ はい | `SubagentStop`（完了の目印） |
| サブエージェント・タスク | `TaskCreated` / `TaskCompleted` / `TeammateIdle` | はい | — |
| 環境 | `InstructionsLoaded` / `ConfigChange` / `CwdChanged` / `DirectoryAdded` / `FileChanged` | `ConfigChange` だけ はい | — |
| 環境 | `WorktreeCreate` / `WorktreeRemove` / `PreCompact` / `PostCompact` | `WorktreeCreate`（0 以外全部）と `PreCompact` は はい | — |
| 環境 | `PreModelSwitch` / `PostModelSwitch` | `PreModelSwitch` だけ はい | — |
| 表示・MCP | `Notification` / `MessageDisplay` / `Elicitation` / `ElicitationResult` | `Elicitation` 系だけ はい | — |

表の読み方を 1 つ。**止められるのは「まだ起きていないこと」だけ**です。`PreToolUse` はツールがまだ走っていないので止められ、`PostToolUse` は済んでいるので止められません。第 1 部 第 7 回が保存の「後」に置いた hook は、止める hook ではなく読ませる hook です。止められないイベントで exit 2 を返しても、ツールは戻りません。戻るのは stderr だけで、それが AI に届くか、あなたにしか届かないかは、次の C.3 の表で分かれます。

## C.2 入力 —— stdin に何が載っているか

handler は stdin から JSON を 1 つ受け取ります。全イベントに共通の欄と、イベントごとの欄があります。

| 欄 | 何か | 本編での使い道 |
|---|---|---|
| `session_id` | セッションの識別子 | 起動の記録をセッションごとに区切る（第 1 部 第 8 回の状態ファイル） |
| `prompt_id` | いま処理しているプロンプトの UUID（v2.1.196 以降） | — |
| `transcript_path` | 会話の JSONL。**非同期に書かれるので、hook が走った時点で最新の発言が無いことがある** | — |
| `cwd` | hook が呼ばれた時点の作業ディレクトリ。worktree に入ると変わる | — |
| `permission_mode` | `default` / `plan` / `acceptEdits` / `auto` / `dontAsk` / `bypassPermissions` | — |
| `effort` | `{ "level": "..." }`。ツール呼び出しの文脈で走るイベントだけ | — |
| `hook_event_name` | 走らせたイベントの名前 | 同じスクリプトを `PreToolUse` と `SubagentStop` に登録して分岐する（第 1 部 第 5 回） |
| `agent_id` / `agent_type` | **サブエージェントの中で hook が走ったときだけ** `agent_id` が付く | サブエージェントからのテスト実行を止める（「サブエージェントにテストを走らせない」） |

ツール系のイベント（`PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `PermissionRequest` / `PermissionDenied`）は `tool_name` と `tool_input` と `tool_use_id` を持ちます。`tool_input` の中身はツールごとに違い、本編の hook が読んだのは 2 種類です。

| ツール | `tool_input` の欄 | 本編の hook が読んだもの |
|---|---|---|
| `Bash` | `command` / `description` / `timeout` / `run_in_background` | `command` の文字列（入口の hook 3 本） |
| `Agent` | `prompt` / `description` / `subagent_type` / `model`（+ 手元の版では `name`） | 全部（用途カタログの hook） |
| `Write` / `Edit` / `Read` | `file_path`（**常に絶対パス**。`~` も相対も展開済み） | — |
| `AskUserQuestion` | `questions` の配列 | —（C.11 で触れる） |

`PostToolUse` にはさらに `tool_response`（ツールが返したもの）と `duration_ms` が付きます。`Agent` ツールが前景で完了したときの `tool_response` には、サブエージェントの最終テキストと、`totalTokens`・`totalToolUseCount`・`resolvedModel` などの数が入ります。第 2 部 第 4 回の「ツール呼び出し 68〜97 回」は、この経路で数えられる数です。

`Stop` は `stop_hook_active`、`last_assistant_message`、`background_tasks`、`session_crons` を持ちます。`stop_hook_active` は「いま続いているのは Stop hook が続けさせたからだ」という目印で、`true` なら hook は退くべきです。`last_assistant_message` は AI の最後の応答の本文で、transcript を読むより確実です。`SubagentStop` は同じ 4 つに加えて `agent_id`、`agent_type`、`agent_transcript_path` を持ちます。`UserPromptSubmit` は `prompt`（あなたが打った文字列）を、`SessionStart` は `source`（`startup` / `resume` / `clear` / `compact` / `fork`）を持ちます。

本編の hook が JSON をどう読んでいるかは、1 行で足りています。

```bash
command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
```

`jq` でも同じですが、hook を配る先に `jq` があるとは限らないので、私は python3 で読んでいます。JSON が壊れていたら空文字を返して `exit 0` に落とす（`|| exit 0`）のも、本編の hook 全部に共通の形です。hook 自身の事故で作業を止めないためです。

## C.3 出力 —— exit code と、stdout・stderr の行き先

exit code は 3 通りに読まれます。

| exit code | 意味 | stdout | stderr |
|---|---|---|---|
| 0 | 異議なし。JSON で細かく制御するときもこの code | `{` で始まり `}` で終われば JSON として読む。平文なら、`UserPromptSubmit` / `UserPromptExpansion` / `SessionStart` / `PostModelSwitch` の 4 つでだけ会話層に足す。**他のイベントではデバッグログ行き** | デバッグログだけ。AI は見ない |
| 2 | 止める。JSON の `allow` でも覆せない唯一の code | 有効な JSON なら読む | **止めた理由として使う。**届く先はイベントごと（下の表） |
| それ以外（1、127 など） | **止めない。**非ブロッキングのエラー | 有効な JSON なら code を無視して JSON だけで決める。平文か空なら、action は進み、transcript に `<hook 名> hook error` と stderr の 1 行目が出る | 1 行目だけ通知に出る |

**exit 1 は止めません。** Unix の慣習では失敗ですが、hook の契約では非ブロッキングです。方針を守らせる hook は `exit 2` でなければ、方針は守られません。公式ドキュメントはこれを Warning の枠で書いています。

**起動できなかった hook も止めません。** `settings.json` のパスを打ち間違えると、シェルが 127 で終わり、`Failed with non-blocking status code: /bin/sh: ...: No such file or directory` の通知が 1 度出て、ツールはそのまま走ります。公式は「方針の hook を置いたら最初の 1 回でこの通知を見張れ。タイプミス 1 つで門は黙って開いたままになる」と書いています。第 1 部 第 4 回の「止める仕組みは、止まらない方向に壊れる」は、比喩ではなく契約の仕様です。

**timeout も止めません。** `command` hook の既定は 600 秒で、`UserPromptSubmit` では 30 秒に下がります。時間切れの hook は出力ごと捨てられ、`PreToolUse` ではツールが通常の permission の流れへ進みます。止める hook を重くしてはいけない理由がここにあります。

exit 2 の stderr がどこへ届くかは、イベントごとに 4 つに分かれます。

| 届く先 | イベント |
|---|---|
| **AI に届く**（止めた理由として） | `PreToolUse`（ツールは走らない）/ `Stop`・`SubagentStop`（続ける理由として）/ `TaskCreated`・`TaskCompleted`・`TeammateIdle` |
| **AI に届く**（止められないが、読ませる） | `PostToolUse` / `PostToolUseFailure`（ツールは済んでいる） |
| **あなたにだけ届く** | `UserPromptSubmit`（プロンプトは消える）/ `SessionStart` / `SubagentStart` / `SessionEnd` / `CwdChanged` / `FileChanged` / `PostCompact` / `PreModelSwitch` / `PostModelSwitch` |
| **無視される** | `PermissionRequest` / `PermissionDenied` / `StopFailure` / `Notification` / `Setup` / `InstructionsLoaded` / `MessageDisplay`（`DirectoryAdded` と `WorktreeRemove` はデバッグログ） |

本編の入口の hook 4 本が `exit 2` と stderr だけで書けているのは、`PreToolUse` が 1 行目にいるからです。第 1 部 第 7 回の `PostToolUse` は 2 行目にいて、`exit 2` の stderr でも AI に届くので、JSON は必須ではありませんでした。JSON を選んだ理由は C.4 に書きます。`UserPromptSubmit` で `exit 2` を返すとプロンプトが消え、理由はあなたにしか届きません。3 行目のイベントに「AI への案内」を書いても、AI は読みません。

出力には上限があります。**hook の出力文字列は 10,000 字まで**で、`additionalContext`、`systemMessage`、平文 stdout の全部に同じ上限が効きます。超えた分はファイルに退避され、AI には先頭の抜粋とファイルのパスが渡ります。付録 A.5 のツール出力と同じ扱いです。この上限に、私の hook が 1 本掛かっていました（C.11）。

## C.4 JSON —— decision の書き方は、イベントで違う

exit code は「止める」か「黙る」かの 2 択です。それより細かい制御は、`exit 0` で JSON を stdout に出します。書き方は 1 つではなく、イベントが 3 つの型に分かれています。

| 型 | イベント | 鍵になる欄 |
|---|---|---|
| トップレベルの `decision` | `UserPromptSubmit` / `PostToolUse` / `PostToolUseFailure` / `PostToolBatch` / `Stop` / `SubagentStop` / `ConfigChange` / `PreCompact` | `"decision": "block"` と `reason`。値は `block` だけで、通すなら欄を省く |
| `hookSpecificOutput.permissionDecision` | `PreToolUse` / `PreModelSwitch` | `allow` / `deny` / `ask` / `defer`、`permissionDecisionReason`、`updatedInput`、`additionalContext` |
| `hookSpecificOutput` の個別の欄 | `PermissionRequest`（`decision.behavior`）/ `PermissionDenied`（`retry`）/ `SessionStart`・`SubagentStart`（`additionalContext` だけ。止められない） | — |

どの型でも使える欄が 4 つあります。`continue: false` は AI を完全に止め、`stopReason` をあなたに見せます。`systemMessage` は**あなたに見せる警告**で、AI には届きません。`suppressOutput` は受け付けられますが、何もしません。`terminalSequence` は端末への通知です。第 1 部 第 7 回の「画面向けの通知はモデルに届かない」は、`systemMessage` のことで、公式の記述と私の実測（2026-08-28）が一致しています。

`additionalContext` は、文字列を system reminder として会話に差し込む欄です。差し込まれる位置はイベントで決まっています。`SessionStart` と `SubagentStart` は会話の先頭、`UserPromptSubmit` は打ったプロンプトの隣、`PreToolUse` / `PostToolUse` はツール結果の隣、`Stop` はターンの末尾です。複数の hook が返せば全部届き、10,000 字を超えた分はファイルに退避されます。公式は書き方についても線を引いています。**命令文ではなく事実の文で書け**。「デプロイ先は production です」は文脈として読まれ、「system: 直ちに〜せよ」の形は prompt injection の防御に掛かって、AI が文面をあなたに見せに来ます。

イベントごとに、本編が使った欄を確かめます。

**`PreToolUse`** —— `deny` はツールを走らせず、`permissionDecisionReason` を **AI に**渡します。`allow` と `ask` の理由は**あなたにだけ**見えます。`allow` は permission のプロンプトを飛ばしますが、settings の deny ルールは飛ばせません。`ask` は auto モードでもプロンプトを強制します。`updatedInput` はツールの入力を**丸ごと**置き換えます（変えない欄も含めて返す）。複数の hook が違う答えを返したら、`deny` > `defer` > `ask` > `allow` の順に厳しいほうが勝ちます。`exit 2` の stderr は `deny` の理由と同じ経路です。かつてトップレベルの `decision: "approve" | "block"` でしたが、このイベントでは非推奨で、`allow` / `deny` に読み替えられます。

**`PostToolUse`** —— `"decision": "block"` は、ツール結果の隣に `reason` を足します。**AI は元の出力もそのまま見ます。** 出力を差し替えたいなら `updatedToolOutput`（ツールの出力の形に合わせる。`Bash` なら `stdout` / `stderr` / `interrupted` / `isImage`）。第 1 部 第 7 回の hook が `decision: block` + `reason` + `systemMessage` の 3 つを返しているのは、AI に読ませる文（`reason`）と、あなたに見せる 1 行（`systemMessage`）を 1 つの出力で分けるためです。`exit 2` + stderr でも AI には届きます。

**`Stop` / `SubagentStop`** —— `"decision": "block"` は止めさせず、`reason`（必須）を「続ける理由」として AI に渡します。`hookSpecificOutput.additionalContext` でも続けさせられ、こちらは transcript に「hook feedback」として出て、エラーの通知が出ません。どちらも同じループ防止が効きます。`stop_hook_active` と、**8 回連続で止めたら Claude Code が hook を無視してターンを終える**上限です（`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` で変えられる）。

**`UserPromptSubmit`** —— 平文 stdout と `additionalContext` のどちらでも文脈を足せます。どちらも transcript には見えない system reminder で、hook の名前から始まります。`decision: block` はプロンプトを処理させず、**会話から消し**、`reason` はあなたに見えます。`sessionTitle` でセッションに名前を付けられます。

**`SessionStart`** —— 平文 stdout が会話層に足されます。`additionalContext`、`initialUserMessage`（`-p` で最初のターンを作る）、`watchPaths`（`FileChanged` の監視対象）、`reloadSkills`（hook が置いたスキルを同じセッションで使う）。

**`SubagentStart`** —— 止められませんが、`additionalContext` を**サブエージェントの会話の先頭**に差し込めます。付録 B.5 の「載らないもの」を、hook で載せる経路です。親の `MEMORY.md` の索引をここで流せば、サブエージェントも索引を持って動きます。私は置いていません。用途カタログの `requires` で渡すものを縛るほうを選んだので、載せるものを増やす経路は作らなかった、というのが理由です（付録 E）。

## C.5 matcher・`if`・置き場所・種類

**matcher** は、含む文字で評価の仕方が変わります。英数字と `_` `-` 空白 `,` `|` だけなら完全一致（`|` か `,` で並べれば OR）、それ以外の文字を含めば JavaScript の正規表現で、アンカー無しです。`Edit.*` は `NotebookEdit` にも当たります。`""` か `"*"` か省略なら全部に当たります。matcher が何を見るかはイベントで違い、ツール系は `tool_name`、`SessionStart` は `source`、`SubagentStart` / `SubagentStop` は `agent_type`、`PreCompact` は `manual` / `auto`。`UserPromptSubmit`、`Stop`、`PostToolBatch` などは matcher を持たず、書いても黙って無視されます。

**`if`** は handler ごとの絞り込みで、permission ルールの構文（`Bash(git *)`、`Edit(*.ts)`）を使います。ツール系のイベントでだけ効き、他のイベントに書くと **その handler は一度も走りません**。Bash の `if` は best-effort です。先頭の `VAR=value` は剥がしてから照合し、`&&` で繋いだ各コマンドと `$()` の中身も見ますが、`$TOOL git push` のようにコマンド名が展開しないと分からない形では**照合できないので走らせる**側に寄ります。公式は「硬い allow / deny は hook ではなく permission システムで」と書いています。本編の入口の hook が `command` の文字列を自分で grep しているのは、`if` を使っていないからで、grep も同じ best-effort です（C.11）。

**置き場所**は 7 つあります。

| 場所 | 効く範囲 | 配れるか |
|---|---|---|
| `~/.claude/settings.json` | あなたの全プロジェクト | いいえ |
| `.claude/settings.json` | そのプロジェクト | **はい。コミットしてチームに配る** |
| `.claude/settings.local.json` | そのプロジェクト | いいえ（gitignore） |
| 管理者の managed settings | 組織全体 | はい |
| プラグインの `hooks/hooks.json` | プラグインが有効な間 | はい |
| スキルの frontmatter `hooks:` | **呼ばれてからセッションの終わりまで**。`once: true` で最初の成功で外れる | はい |
| サブエージェントの frontmatter `hooks:` | そのサブエージェントが走っている間。`Stop` は `SubagentStop` に読み替え | はい |

同じ handler が複数の settings に書かれていれば 1 回だけ走ります。プラグインとスキルの写しは別に走ります。**settings の hook は、サブエージェントの中でも走ります。** サブエージェントが `Bash` を呼べば、同じ `PreToolUse` の hook が同じ 3 本走り、入力に `agent_id` が付きます。「サブエージェントにテストを走らせない」は、この性質が無ければ hook では書けません。

**種類**は 5 つ。`command`（シェル）、`http`（POST）、`mcp_tool`、`prompt`（Claude モデルに 1 回問う。既定は Haiku）、`agent`（ツールを持つサブエージェントに検証させる。実験的）。本編は全部 `command` です。`prompt` と `agent` は `{ "ok": true | false, "reason": ... }` を返す型で、`Stop` で「全部終わったか」をモデルに判定させる例が公式にあります。第 1 部 第 12 回が「問いを手順に組み込まない」と言った案は、契約の上ではこの `prompt` hook で書けます（C.11）。

`command` hook には exec form と shell form があります。`args` を書くと exec form で、シェルを通さず `command` を直接 spawn します。書かなければ shell form で、`sh -c` に渡されます。`${CLAUDE_PROJECT_DIR}` のような置き場所の変数を使う hook は exec form か、shell form なら二重引用符で囲むのが公式の推奨です。私の `settings.json` は shell form で引用符を付けていません。パスに空白が無いので動いていますが、推奨の形ではありません。

**並列**です。一致した hook は全部並列に走り、全部の完了を待ってから結果を合成します。`deny` を返した hook が隣の hook を止めることはなく、隣の hook の副作用（ログへの追記など）は起きます。`PreToolUse` の permission の答えは厳しいほうが勝ち、`additionalContext` は全部届き、`updatedInput` は**最後に終わった hook のものが効く**（順序は不定なので、同じツールの入力を 2 本の hook で書き換えてはいけない）。

**`async: true`** は背景で走らせ、`decision` などの制御は効きません。結果の `additionalContext` と `systemMessage` は次のターンに届き、`asyncRewake` なら exit 2 で AI を起こせます。

**信頼**の線引きを 1 つ。対話セッションでは、フォルダの workspace trust を受け入れるまで settings の hook は走りません。`-p` と SDK では**ダイアログを出さずに信頼済みとして扱う**ので、リポジトリの `.claude/settings.json` に書かれた hook は、あなたが一度も信頼していないフォルダでも走ります。他人のリポジトリで `claude -p` を回す前に `.claude/` を読むか、`--settings '{"disableAllHooks": true}'` を付けるのが公式の助言です。hook を配る側から言えば、**`.claude/settings.json` の hook は、clone した全員のマシンで、その人の権限で走る**ということです。

## C.6 手元の 11 本

本編の 3 作で置いた hook を、2026-09-05 の `.claude/settings.json` の形で並べます。契約のどの欄を読み、どの経路で返しているかの一覧です。

| # | イベント | matcher | handler | 読むもの | 返すもの |
|---|---|---|---|---|---|
| 1 | `UserPromptSubmit` | — | `cat .../memory/MEMORY.md` | （stdin は読まない） | 平文 stdout → 会話層。⚠️ 10,000 字の上限に注意（C.11） |
| 2 | `PreToolUse` | `Bash` | `block_raw_rails_test.sh` | `tool_input.command`、`agent_id` | `exit 2` + stderr。逃げ道 `SKIP_TEST_GUARD=1` |
| 3 | `PreToolUse` | `Bash` | `require_git_hooks_installed.sh` | `tool_input.command`、`git rev-parse --git-path hooks` | `exit 2` + stderr。逃げ道 `--no-verify` / `SKIP_HOOK_CHECK=1` |
| 4 | `PreToolUse` | `Bash` | `require_visual_verification_declaration.sh` | `tool_input.command`、`tmp/visual_verification.md` の存在・Q1 / Q2・禁句・mtime | `exit 2` + stderr。判定を tsv に 1 行追記。逃げ道 `SKIP_VISUAL_GUARD=1` |
| 5 | `PreToolUse` | `Agent` と `SendMessage` | `guard_subagent_launch.py` | `tool_name`、`tool_input.prompt` / `name` / `model` / `subagent_type` / `to`、`session_id`、用途カタログ、起動の記録 | `exit 2` + stderr。通したら記録に 1 行 |
| 6 | `PostToolUse` | `Write` `Edit` `Bash` の 3 つ | `audit_memory_write.py` | **実ファイル**とスナップショットの差（stdin は `stop_hook_active` 以外読まない） | JSON `decision: block` + `reason` + `systemMessage`。未報告の差分を `pending.json` に積む |
| 7 | `PostToolUse` | `Bash` | `require_verbatim_numbers.py` | `tool_response` の中の文字列（印の付いた行だけ） | 出力なし。観測の行を積むだけ |
| 8 | `SubagentStop` | — | `guard_subagent_launch.py` | `hook_event_name`、`session_id` | 出力なし。起動の記録に完了の目印 |
| 9 | `SessionStart` | — | `audit_memory_write.py --prime` | — | 出力なし。スナップショットを撮り直し、`pending.json` を消す |
| 10 | `Stop` | — | `audit_memory_write.py --stop` | `stop_hook_active`、`pending.json`、`blocked.flag` | JSON `decision: block` + `reason`。**一度だけ** |
| 11 | `Stop` | — | `require_verbatim_numbers.py --stop` | `last_assistant_message`、積んだ観測の行 | JSON `decision: block` + `reason`。**一度だけ**。行が逐語で入っていなければ引き戻す |

11 本のうち、7 と 8 と 9 は状態を進めるだけで AI に何も返しません。1 は文脈を流すだけで止めません。入口で止めるのは 2〜5 の 4 本で、6 と 10 と 11 は止めずに読ませます。止める 4 本は、全部「`command` の文字列」か「ファイルの実在」か「記録の数」で決めていて、AI の言葉を解釈する行がありません。第 2 部 第 4 回の表で「回避できない」に目印が付いた判定は、ファイルの実在と `model` の許可リストで、どちらも入力の欄をそのまま見ています。

## C.7 状態の持ち方 —— hook は毎回、新しいプロセス

handler は呼ばれるたびに新しいプロセスとして起動し、終われば消えます。前回の呼び出しで見たものは覚えていません。「同じ名前で 2 度目の起動」「保存の前と後の差」「一度だけ引き戻す」は、どれも 1 回の呼び出しの中では判定できず、**disk に状態を持って初めて書けます**。本編の hook が持っている状態は 4 つです。

| 状態 | 置き場所 | 区切り | 何のため |
|---|---|---|---|
| 起動の記録 | `tmp/subagent_launches.tsv` | `session_id` ごとに行を持ち、読むときも自分の `session_id` の行だけ | 同名の再起動・並列数・総数（第 1 部 第 5・8 回） |
| メモリのスナップショット | `~/.claude/.memory-audit/<path の sha256>` | メモリのファイルごと | 保存後の差分（第 1 部 第 7 回） |
| 未報告の差分 | 同じディレクトリの `pending.json` | セッションをまたがない（`SessionStart` の `--prime` で消す） | 終わり際の引き戻し |
| 引き戻した目印 | 同じディレクトリの `blocked.flag` | 同上 | 引き戻しを一度だけにする |

置き場所で 2 度、踏みました。1 度目は `TMPDIR` です。macOS では対話シェルの `TMPDIR` が `/var/folders/...` で、hook のプロセスの環境と食い違い、基準を作ったつもりのディレクトリと hook が読むディレクトリが別になって、**状態が 2 か所に割れて黙って壊れました**（2026-08-28）。hook の環境は「親の環境を継いだもの」で、あなたの端末の環境と同じとは限りません。状態の置き場所は `$HOME` からの固定パスにしています。2 度目は監視対象との重なりで、スナップショットを memory の glob（`~/.claude/projects/*/memory/*.md`）の中に置くと、スナップショット自身が差分に出ます。監視対象の外に置くのは、第 1 部 第 7 回のコードの `# ⚠️ 監視対象の外に置く` の 1 行です。

**`session_id` で区切る理由**は、同じディレクトリで 2 セッションが並列実行するからです。起動の記録を 1 ファイルで共有しても、行に `session_id` があれば互いの起動を数えません。逆に、区切らないと**前のセッションの起動が残っていて、正当な起動を「再起動」として止めます**。第 1 部 第 8 回の「状態ファイルがセッションをまたいで残ると偽陽性」がこれで、`session_id` は共通の欄なので、どのイベントでも同じ鍵が使えます。メモリのスナップショットは `session_id` で区切っていません。メモリは複数のセッションが同じファイルを書くので、差分に**別のセッションの書き込みが混ざる**のは仕様として受け入れ、案内文に「自分が書いたものでなければそう言え」を入れています。

**完了の信号**は自己申告ではなく、Claude Code が出すイベントで取ります。並列数を「起動した時刻の窓」で数えると、10 分で終わった 1 体が 45 分ぶん枠を塞ぎます。`SubagentStop` で記録に完了の目印を付ければ「まだ走っている数」で数えられ、信号を取りこぼしたときの保険として時間の窓を残しています。取りこぼすと止まる側に寄る、という向きです。

**一度だけ**は、2 つの目印の OR で書いています。自分で立てた `blocked.flag` と、Claude Code が入れてくる `stop_hook_active`。どちらかが立っていれば退きます。公式の 8 回の上限だけに頼ると、8 ターンぶん引き戻してから終わることになり、第 1 部 第 7 回が「毎回止めるとセッションが終われなくなる」と書いた状態が 8 回まで続きます。`stop_hook_active` は「hook が続けさせた直後」の目印なので、hook が引き戻した次のターンで `true` になります。`blocked.flag` は、その前に自分で確かめる目印です。

## C.8 案内文の設計 —— 止めた画面に、次の一手を書く

`PreToolUse` の `deny` で AI が受け取るのは、理由の文字列だけです。ツール呼び出しは失敗として返り、AI は次の応答をその文字列から組み立てます（付録 A.2）。案内文が hook の本体だと言ってよく、入口で止める hook（C.6 の 2〜5）の stderr は、同じ 5 つの要素でできています。

1. **何を止めたか、1〜2 行。** 「素の `rails test` は使わないでください」。理由は 2〜3 行まで（数字と日付があると、AI は一般論ではなく個別の事故として読みます）
2. **置き換え先。** 止めた操作の代わりに何を打つか、コマンドの形で。`scripts/rails_test.sh <同じ引数>`。宣言 hook が足したのは、TODO の書き換え先まで書くことでした。「撮影で確認」を「grep / 単体テストで確認」に**書き換えて**消化する。置き換え先の無い禁止は、止めた瞬間に検証ごと放棄されます
3. **逃げ道と、その条件。** `SKIP_TEST_GUARD=1`、`--no-verify`。案内した逃げ道は hook が必ず通します。条件を添えるのは、逃げ道を「先を急ぐ AI が使う」形にしないためで、テストの並走ロックは PID の生存を確かめてから消し方を出します
4. **偽陽性の出口。** 「このメッセージはコマンド文字列に含まれているだけでも出ます（ドキュメントを cat したときなど）。その場合はファイル編集ツールで扱ってください」。文字列の grep は必ず誤作動するので、誤作動したときに読める場所に出口を書いておきます。宣言 hook で私自身が止められた話がこれです
5. **短く。** 案内文は全部が会話層に入り、10,000 字で切られます。手元の案内文は、長いもので 20 行あまりです

`ask` の理由はあなたにしか届かないので、AI への案内は `deny` か `exit 2` にだけ書きます。`PostToolUse` の `reason` は「ツールは成功している」ことを先に書きます。書かないと AI は失敗と読み、同じ保存をやり直します。第 1 部 第 7 回の案内文の 2 行目「**保存は済んでいます**」は、そのための行です。

案内文を hook に直書きせず `cat` する形（第 1 部 第 8 回）は、方針の更新を hook に触らずに済ませるためです。ただし `cat` する先も 10,000 字の上限の内側です。

## C.9 hook を自動テストで検証する

hook は自動テストの外側にいるので、テストスイートは hook の壊れを教えてくれません。hook 自身の自動テストは、手元に 4 本あります（`scripts/tests/test_visual_verification_guard.sh`、`test_subagent_guard.sh`、`test_memory_audit.sh`、`.claude/hooks/audit_memory_write_test.py`）。型は 1 つです。

**stdin の JSON を偽造して、exit code と stderr の両方を見る。** hook は stdin と環境変数と disk しか見ないので、Claude Code 無しで走らせられます。

```bash
run() { printf '{"tool_input":{"command":"%s"}}' "$1" | bash "$HOOK" 2>"$WORK/err.txt"; }
run "ls -la";                                 check "非対象は通る" 0 $?
run "bash scripts/screenshot.sh /";           check "宣言なしは止まる" 2 $? "書き換えて"
write_decl "SP 表示の崩れの検証" "computed style は実描画でしか分からないため"
run "bash scripts/screenshot.sh /";           check "正しい宣言は通る（陽性対照）" 0 $?
run "SKIP_VISUAL_GUARD=1 bash scripts/screenshot.sh /"; check "逃げ道は通る" 0 $?
```

急所は 4 つです。**止まった理由の文字列まで見る**（`check` の 4 つ目の引数）。exit 2 だけ見ると、別の理由で止まった hook が合格になり、狙った判定が空回りしていることに気づけません。**陽性対照を必ず併置する**。止まることしか確かめない自動テストは、hook が全部を止める壊れ方を映せません。**逃げ道が通ることも確かめる**。案内した逃げ道が通らない hook は、誤作動の日にガードレールごと壊されます。**変異注入を 1 本入れる**。`sed` で禁句の正規表現を潰した写しを作り、その写しでは素通りすることを見る。これで「その行を見ている」ことが確かめられます。宣言 hook で、この自動テストが hook 自身の bash のバイト数の bug を先に見つけました。

環境は `CLAUDE_PROJECT_DIR` と `HOME` で差し替えます。hook は `${CLAUDE_PROJECT_DIR:-$(pwd)}` で root を決めるので、自動テストは `mktemp -d` の砂場を root にして、用途カタログの写しと空の `requires` を置いて走らせます。メモリの監査は `HOME` を偽の木に向けて、本物のメモリに触れずに走らせます。`Stop` の hook は `{"stop_hook_active": true}` を流して退くことを、`{}` を流して引き戻すことを、両方見ます。

**自動テストが見られないもの**が 1 つあります。`settings.json` の配線です。パスの打ち間違いは hook を 127 で落とし、C.3 のとおり止めません。hook の自動テストは hook のファイルを直接呼ぶので、配線の間違いは通ります。配線は別に見ます（C.12 の 5）。

## C.10 本編との対応

| 本編 | 契約の側から言い直すと |
|---|---|
| 第 1 部 第 1・4 回「入口の hook」 | `PreToolUse` + `exit 2`。stderr は AI に「止めた理由」として届き、ツールは走らない。案内文が hook の本体 |
| 第 1 部 第 5 回・第 2 部 第 4 回「用途カタログ」 | `Agent` ツールの `tool_input`（`prompt` / `model` / `subagent_type`）を読む `PreToolUse`。`SubagentStop` で完了を取る |
| 第 1 部 第 7 回「保存の後に突きつける」 | `PostToolUse` は止められない。`decision: block` の `reason` はツール結果の隣に届き、元の出力も残る。`systemMessage` は AI に届かない |
| 第 1 部 第 8 回「瞬間に差し込む」 | 状態は disk、`session_id` で区切る。`deny` の理由に方針を `cat` する |
| 第 1 部 第 7 回・第 12 回「一度だけ引き戻す」 | `Stop` の `decision: block` + `stop_hook_active` + 8 回の上限 |
| テストの並走ロックと宣言 hook（本書の章には無い。次の連載で扱う） | settings の hook はサブエージェントの中でも走り、`agent_id` で区別できる。hook 自身の自動テストは stdin の偽造 + 陽性対照 + 変異注入 |

## C.11 本編が言っていない注意

**hook の平文 stdout は、10,000 字で切られます。** 超えた分はファイルに退避され、AI に届くのは「Output too large … Full output saved to: …」の 1 行と、先頭 2KB の抜粋と、退避先のパスだけです。残りは AI が退避先を自分で開かない限り読まれません。**同じ退避は、ツールの出力にも起きます** —— `PostToolUse` の `tool_response` に載るのは先頭の抜粋だけで、末尾に書いた行はそこにありません。⚠️ **出力の末尾を見る hook は、退避先のパスから辿らないと空振りします**（第 1 部 第 10 回の `require_verbatim_numbers.py` は「`saved to:` の行にパスがあれば開く」を持っています）。⚠️ **もう 1 段あります** —— 実行ラッパーが画面に要約だけを出力する作りなら、その要約に載らない行は退避以前に消えます。**数えることと、数えた行が人に届くことは別**です。auto memory の索引が 200 行 / 25KB で切られる話（付録 D）とは別の上限で、`UserPromptSubmit` で索引を流す構成に効きます。私の索引（87 行・27,908 字）がこれに当たっていて、付録 B.8 で「毎ターン索引を流している」と書いた実態は先頭の十数行でした（この章を書いた 2026-09-05 に気づきました）。守りは 2 つ。索引を 10,000 字の内側に保つか、hook で流すのをやめて auto memory の経路（`autoMemoryEnabled`）に戻すか。（2026-09-11 追記）**3 つ目を取りました。** 全文を流すのをやめ、hook が出すのは**行動ルールの節だけ**にして、残りは「作業に入る前に Read すること」とパスを言わせています。そして**末尾に目印の 1 行**を置きました —— 切られたことは、切られた側からは見えません。目印が無ければ、届いていないと分かります。

**その断り書きは、`PostToolUse` の `tool_response` には入っていません。** 退避の 1 行（「Output too large … saved to: …」）は hook の**後に**モデル向けへ付くもので、hook が受け取るのは `{"stdout": …, "stderr": …}` の形です。⚠️ **だから「切られたか」を文字列で判定する hook は、一度も鳴りません。**2026-09-11 に置いた `require_full_read.py` がこれでした —— 自己検査は緑のまま、本物の経路で 0 回。payload をダンプして初めて分かりました。hook の側から判定できるのは**大きさ**だけです（`len(stdout)` が表示の上限に届いていれば、モデルが見るのは抜粋だと分かる）。⚠️ ここは第 1 部 第 3 回の「落ちた変異は何も保証しない」と同じ形で、**検査が本物の入力の形を見ていなければ、緑は何も意味しません**。

**`SessionStart` は、圧縮のたびにも走ります。** matcher を付けなければ `startup` / `resume` / `clear` / `compact` / `fork` の全部で走り、私の `--prime` は `pending.json` を消します。つまり、未報告のメモリの差分を抱えたまま自動圧縮が走ると、終わり際の引き戻し（C.7）は起きません。第 1 部 第 7 回の「一度だけ引き戻す」は、圧縮をまたぐと 0 回になります。matcher を `startup|resume|clear` に絞れば塞がります。これも、この章を書きながら契約の表を読んで気づいた抜け穴です。

**hook は permission mode の外にいます。** `PreToolUse` はどの permission mode でも、`bypassPermissions` と `--dangerously-skip-permissions` でも走り、`deny` は通ります。「自律運転にすると hook が外れる」ことはありません。逆は成り立たず、hook の `allow` は settings の deny ルールを飛ばせません。hook は締められますが、緩められません。本編が「hook の正しさが全体の上限になる」（第 1 部 第 9 回の波及）と書いたのは、この非対称の上に立っています。

**文字列の grep は、`if` と同じ best-effort です。** `bash -c '...'`、`docker compose run web rails test`、エイリアス、変数経由は、`command` の文字列を grep する hook をすり抜けます。私の hook はコメントに「正しさは L1（テストプロセス側のロック）が担保する」と書いていて、hook は一番外側の網です。第 2 部 第 3 回の「範囲の隙間に善意の迂回路が通る」は、hook の matcher にも当てはまります。matcher が `Bash` だけなら、`Write` / `Edit` で同じファイルを書く経路は通ります。逆に `PostToolUse` の `Edit|Write` は、`Bash` が同じファイルを書き換えたときには走りません。第 1 部 第 7 回の hook が `Write|Edit|Bash` の 3 つに掛けて**実ファイルを比べる**形なのは、この隙間を埋めるためです。2.1.260 には `FileChanged`（誰が書いたかによらず、disk 上の変化で走る。止められない）があり、同じ隙間を別の向きから塞げます。

**hook は、サブエージェントの 1 呼び出しごとにも走ります。** テストの並走ロックの hook がサブエージェントを止められるのは、settings の hook がサブエージェントの中でも走るからですが、同じ理由で、13 体が並列実行すれば `Bash` の `PreToolUse` 3 本が 13 体ぶん走ります。hook を重くすれば、サブエージェントの 1 手ごとに遅れが乗ります。

**`Stop` hook は、人の一言と同じ位置には立ちません。** 第 1 部 第 12 回の「不足点はないですか」を `Stop` hook で機械的に出す案を、本編は推測のまま退けました。契約の側から見えることを 3 つ足します。`Stop` は「AI が応答を終えるたび」に走り、作業の完了で走るのではありません。人が「終わり」と判定してから打つ一言と違い、短い答えのたびに問いが刺さります。届く形も違い、hook の `reason` や `additionalContext` はターンの末尾に足される文字列で、人の一言は新しい user ターンです（付録 A.4 の部分の違い。重みの違いは付録 I）。一方で、`Stop` hook にしかできないことがあります。`last_assistant_message` を読んで、完了報告の中の「確認しました」の類を機械で照合できること、`git diff --stat HEAD` と `git status --porcelain` と `tmp/test_last.log` の件数を `reason` に**数字のまま**書けることです。第 1 部 第 9 回の観測点は、`Stop` hook で出せば AI の要約を経由しません。私は置いていません。問いの代わりではなく、数字の運び手としてなら置く価値があると考えています。

**`AskUserQuestion` はツール呼び出しで、permission prompt は別物です。** 第 3 部 第 10 回の「AI から飛んでくる問い」は、Claude Code では `AskUserQuestion` ツールの呼び出しで、`PreToolUse` が `tool_input.questions` と一緒に走ります。契約の上では、hook がこの呼び出しを `deny` して理由を返す（「引き受け先を先に見よ」の一覧を `cat` する）ことも、`-p` で `updatedInput` に `answers` を入れて答えることも、`defer` で呼び出し元に渡すこともできます。permission prompt は別の経路で、`PermissionRequest` が走り、auto モードの拒否は `PermissionDenied` に `retry` で返せます。hook の `ask` は auto モードでもプロンプトを強制します。第 3 部 第 10 回が「置くものはありません」と書いた回なので、私はどれも置いていません。

**`UserPromptSubmit` の `exit 2` は、AI に届きません。** プロンプトが消え、理由はあなたに見えるだけです。打った言葉を機械で検閲する hook は書けますが、AI に何かを教える hook にはなりません。

**resume は hook を走らせ直しません。** `PostToolUse` や `UserPromptSubmit` が足した文脈は transcript に保存され、`--resume` では**保存された文字列が再生されます**。hook が出した日時や commit の SHA は古いままです。`SessionStart` だけが `source: resume` で走り直します。transcript の側では、hook の出力は `attachment` の行（`"type": "hook_success"`、`hookName`、`content`）として残ります。この章の 10,000 字の観測も、その行から取りました。

**バージョン依存。** 上の挙動は 2026-09-05 に Claude Code 2.1.260 のドキュメントと手元で確認したものです。exit 2 と不正な JSON の組み合わせが止めるようになったのは v2.1.214、exit 0 以外での JSON の解析エラーが通知されるのは v2.1.248、matcher のハイフンが完全一致になったのは v2.1.195、`if` の `Edit(src/**)` の意味が変わったのは v2.1.214 です。10,000 字の上限、8 回の上限、既定の timeout も版で変わりうる値です。

## C.12 検証手順

止まるべきものが止まり、通るべきものが通る。両方向を 1 度ずつ確かめます。

**1. exit code の 3 通りを対照する。** 環境変数で exit code を変える hook を 1 本置き、`Bash` の `PreToolUse` に掛けます。

```bash
mkdir -p .claude/hooks
cat > .claude/hooks/probe_exit.sh <<'EOF'
#!/bin/bash
cat >/dev/null                                  # stdin の JSON は読み捨てる
echo "MARKER_${1:-EXIT}_${PROBE_EXIT:-0}" >&2   # $1 = 登録ごとの印、PROBE_EXIT = 返す exit code
exit "${PROBE_EXIT:-0}"
EOF
chmod +x .claude/hooks/probe_exit.sh
```

`settings.json` の `PreToolUse` に `{"matcher": "Bash", "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/probe_exit.sh PRE"}]}` を足し、`PROBE_EXIT=0 claude`、`PROBE_EXIT=1 claude`、`PROBE_EXIT=2 claude` の 3 回起動して、AI に `ls` を 1 回打たせ、「いま実行した `ls` は成功したか、止められたか。`MARKER_` で始まる語が見えていれば書き出して」と頼みます。期待するのは、0 と 1 では `ls` が走り（1 では transcript に `hook error` の通知が出る）、`MARKER_` は見えないこと。2 では `ls` が走らず、`MARKER_PRE_2` を AI が書き出すことです。1 で `ls` が止まったら、あなたの版は exit 1 を止める側に変えています。

**2. stderr の行き先を、イベントで対照する。** 同じ 1 本を `PostToolUse`（`Bash`、引数 `POST`）と `SessionStart`（引数 `START`）にも登録し、手順 1 の `PreToolUse` の登録は外して、`PROBE_EXIT=2 claude` で起動して `ls` を打たせ、`MARKER_` を全部書き出させます。期待するのは、`MARKER_POST_2` は AI が書き出し、しかも `ls` は走っていること（止められないが、読ませる）。`MARKER_START_2` はあなたの画面に `hook error` として出るだけで、AI は書き出さないことです。

**3. 10,000 字の上限を踏む。** `UserPromptSubmit` に、先頭と末尾に目印を持つ 12,000 字を出す hook を掛けます。

```bash
cat > .claude/hooks/probe_cap.sh <<'EOF'
#!/bin/bash
cat >/dev/null
echo MARKER_CAP_TOP_3a1f
python3 -c 'print(("この行は上限を踏むための埋め草です。" * 10 + "\n") * 60)'   # ≈ 12,000 字
echo MARKER_CAP_END_8c4e
EOF
chmod +x .claude/hooks/probe_cap.sh
```

登録して 1 ターン打ち、「`MARKER_CAP_` で始まる語を、見えているものだけ書き出して。退避先のファイルは開かないで」と頼みます。期待するのは、`TOP` だけが書き出され、`END` は退避先のパスと一緒に「見えていない」側に回ることです。両方出たら、あなたの版は上限が違うか、埋め草が足りていません。次に「退避先を開いて `END` を探して」と頼めば見つかります。届かなかったのであって、失われたのではありません。

**4. `stop_hook_active` と 8 回の上限を踏む。** 必ず止める `Stop` hook を置きます。

```bash
cat > .claude/hooks/probe_stop.sh <<'EOF'
#!/bin/bash
input=$(cat)
active=$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("stop_hook_active", False))')
[ "$active" = "True" ] && [ -n "${PROBE_RESPECT:-}" ] && exit 0
echo "MARKER_STOP_$(date +%S): continue once more" >&2
exit 2
EOF
chmod +x .claude/hooks/probe_stop.sh
```

`PROBE_RESPECT` 無しで起動して 1 ターン打つと、AI は 8 回続けてから「Stop hook blocked too many consecutive times」の警告と一緒に終わります。`PROBE_RESPECT=1` で起動すると、1 回引き戻されて終わります。2 回目の起動で 8 回続いたら、あなたの版は `stop_hook_active` を入れていません。

**5. 配線を機械で確かめる。** hook の自動テスト（C.9）が見ない部分です。`settings.json` の `command` が実在して実行可能かを、テストスイートの 1 本にします。

```bash
python3 - <<'EOF'
import json, os, shlex, sys
root = os.getcwd()
s = json.load(open(".claude/settings.json"))
bad = []
for event, groups in s.get("hooks", {}).items():
    for g in groups:
        for h in g.get("hooks", []):
            if h.get("type") != "command": continue
            first = shlex.split(h["command"].replace("$CLAUDE_PROJECT_DIR", root).replace("${CLAUDE_PROJECT_DIR}", root))[0]
            if "/" in first and not os.access(first, os.X_OK):
                bad.append(f"{event}: {h['command']}")
print("\n".join(bad) or "all hook commands exist and are executable")
sys.exit(1 if bad else 0)
EOF
```

陽性対照として、`settings.json` の 1 本のパスをわざと 1 文字変えて、上のスクリプトが落ちることと、その状態で AI に対象の操作をさせると `Failed with non-blocking status code` の通知と一緒に**操作が通ってしまう**ことを見ます。門が黙って開く壊れ方を、一度は目で見ておく手順です。

**6. matcher の外側を踏む。** `Bash` だけに掛けた `PreToolUse` hook が、`Edit` ツールで同じ結果を作る経路を通すことを見ます。

```bash
cat > .claude/hooks/probe_guard.sh <<'EOF'
#!/bin/bash
command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
case "$command" in *probe_secret.txt*) echo "MARKER_GUARD: probe_secret.txt is protected" >&2; exit 2 ;; esac
exit 0
EOF
chmod +x .claude/hooks/probe_guard.sh
echo "line 1" > probe_secret.txt
```

`PreToolUse` の `Bash` に登録し、AI に「Bash で `probe_secret.txt` に 1 行追記して」と頼むと止まります。次に「Edit ツールで同じファイルに 1 行追記して」と頼むと通ります。matcher が `tool_name` しか見ていないので、`Edit` の `tool_input.file_path` は誰も見ていません。逆向きも踏みます。`PostToolUse` の `Edit|Write` に `echo MARKER_POST_EDIT >&2; exit 2` の hook を掛け、AI に「Bash の `echo >> probe_secret.txt` で追記して」と頼むと、この hook は走りません。最後に `FileChanged` に `"matcher": "probe_secret.txt"` で同じ `echo MARKER_FILE_CHANGED` を掛け、Bash と Edit のどちらで書いても走る（ただし止められない）ことを見ます。宣言 hook が `command` の文字列を見ている限り、塞いでいるのは `Bash` の入口だけです。

**7. hook 自身を、砂場で壊す。** C.9 の型を、あなたの hook 1 本に当てます。stdin の JSON を偽造して exit code と stderr の文字列を見る自動テストを 4 項目（止まる・通る・逃げ道・変異）書き、hook の判定行を 1 行コメントアウトして自動テストが失敗になること、戻して合格になることを見ます。失敗にならなければ、その自動テストは hook を見ていません。

## C.13 出典（2026-09-05 確認）

- [Hooks reference](https://code.claude.com/docs/en/hooks) —— 33 種のイベント、共通の入力欄、exit code の意味、exit 2 のイベント別の表、JSON の 3 つの型、`additionalContext` の位置、matcher の評価、`if` の best-effort、置き場所 7 つ、exec form と shell form、並列と合成、`async`、workspace trust、10,000 字の上限、版ごとの変更（v2.1.195 / 214 / 248）
- [Automate actions with hooks](https://code.claude.com/docs/en/hooks-guide) —— 「LLM が選ぶことに頼らず必ず起きる」、複数 hook の合成（`deny` は隣を止めない）、permission mode との関係（`bypassPermissions` でも `deny` は通る）、`stop_hook_active` と 8 回の上限、`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`、shell の profile が JSON を壊す話
- [Permission modes](https://code.claude.com/docs/en/permission-modes) —— auto モードで hook の `ask` がプロンプトを強制すること、classifier の位置
- [Tools available to Claude](https://code.claude.com/docs/en/tools-reference) —— 出力の上限と退避（付録 A.5 と同じ仕組みが hook の出力にも効く）
- [Skills](https://code.claude.com/docs/en/skills) / [Subagents](https://code.claude.com/docs/en/sub-agents) —— frontmatter の `hooks:` の寿命、`once`、サブエージェントの `Stop` → `SubagentStop`
- [Run Claude Code programmatically](https://code.claude.com/docs/en/headless) —— `-p` で `AskUserQuestion` が止まること、`defer` と `--resume`
- [Environment variables](https://code.claude.com/docs/en/env-vars) —— `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`、`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`
- 手元の実測 —— `.claude/settings.json` の hook 11 本（C.6）と、transcript の `attachment` 行（`hook_success`。10,000 字の退避の観測もここから）
