diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 8e9679c4..b3147361 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -155,6 +155,25 @@ steps = [ { cmd = "npx", args = ["--no-install", "markdownlint-cli2", "{file}"], fix = false }, ] +# ─── Stop: tool call leak 検知 (ADR-053、試験運用) ─── +# +# ツール呼び出しが tool_use block ではなくテキスト領域に `` XML として +# 出力され、実行されないまま turn が終了する不具合を検知し、block で再実行を促す。 +# +# ADR-039 3 点セット: +# - Config opt-in (default OFF): code default は enabled = false (unwrap_or(false))。 +# 本 repo は dogfood のため enabled = true。派生プロジェクト deploy 時は default OFF。 +# - Kill-switch: 恒久停止は enabled = false。緊急バイパスは env +# STOP_TOOL_CALL_LEAK_OVERRIDE=1 (truthy 値、FILE_LENGTH_CHECK_OVERRIDE と同 pattern)。 +# - Bounded lifetime: 根本原因は上流 (Claude Code / モデルのシリアライズ) の不具合。 +# 上流修正の確認、または leak 4 週間非観測で撤去を判定する。 +# +# ループ防止: stop_hook_active skip ではなく連続 leak カウント上限で fail-open +# (ADR-004 からの意図的逸脱、再 leak 実績があるため。詳細は ADR-053)。 +[stop_tool_call_leak] +enabled = true +max_consecutive_blocks = 3 # 連続 block 上限 (到達で fail-open) + # ─── Stop: 品質ゲート ─── [stop_quality] diff --git a/.claude/settings.local.json.template b/.claude/settings.local.json.template index 49be4a5b..f5832e8e 100644 --- a/.claude/settings.local.json.template +++ b/.claude/settings.local.json.template @@ -67,6 +67,11 @@ "Stop": [ { "hooks": [ + { + "type": "command", + "command": "\"{{PROJECT_DIR}}\\.claude\\hooks-stop-tool-call-leak.exe\"", + "timeout": 5 + }, { "type": "command", "command": "\"{{PROJECT_DIR}}\\.claude\\hooks-stop-quality.exe\"", diff --git a/CLAUDE.md b/CLAUDE.md index b3e5ae7f..1d1b372d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ - [ADR-050: multi-iteration workflow の decision criteria scope 明示](docs/adr/adr-050-iteration-aware-decision-criteria.md) *(試験運用)* - [ADR-051: クロスシステム設定 coupling パターン — 内部設定と外部 SaaS 設定の論理結合の設計規律](docs/adr/adr-051-cross-system-config-coupling.md) *(試験運用)* - [ADR-052: 自律実行境界の 2 クラス分類(ADR-028 の 2 段化)](docs/adr/adr-052-autonomy-execution-boundary-classes.md) *(試験運用)* +- [ADR-053: Stop hook による tool call leak 検知](docs/adr/adr-053-stop-tool-call-leak-detection.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/Cargo.lock b/Cargo.lock index b91f6f34..b972ab1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,17 @@ dependencies = [ "toml", ] +[[package]] +name = "hooks-stop-tool-call-leak" +version = "0.1.0" +dependencies = [ + "lib-subprocess", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "hooks-user-prompt-feedback-recovery" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9a7eec66..cd67cc3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "src/hooks-session-start", "src/hooks-stop-feedback-dispatch", "src/hooks-stop-quality", + "src/hooks-stop-tool-call-leak", "src/hooks-user-prompt-feedback-recovery", "src/lib-jj-helpers", "src/lib-ollama-client", diff --git a/docs/adr/adr-053-stop-tool-call-leak-detection.md b/docs/adr/adr-053-stop-tool-call-leak-detection.md new file mode 100644 index 00000000..23a80a34 --- /dev/null +++ b/docs/adr/adr-053-stop-tool-call-leak-detection.md @@ -0,0 +1,177 @@ +# ADR-053: Stop hook による tool call leak 検知 + +## ステータス + +試験運用 (2026-07-12) + +> 本 ADR は [ADR-039 (試験運用標準パターン)](adr-039-experimental-feature-standard-pattern.md) に従う。 +> Config opt-in / kill-switch / bounded lifetime の 3 点を満たす。 + +## コンテキスト + +Claude Code がツール呼び出しを正規の tool_use block ではなく**テキスト領域に生の +XML として出力**し、ツールが実行されないまま turn が終了する不具合が多発している。 +transcript 上では assistant メッセージの `type: "text"` content block に以下の形で残る: + +```text +(日本語の説明文) + +court + +pnpm push 2>&1 + +``` + +直近 4 セッションの transcript 調査 (2026-07-12) で **197 件**の実 leak を確認した: + +| セッション (先頭8桁) | leak 件数 | 化けたプレフィックス行 | +|---|---|---| +| 87387df2 | 59 | `court` | +| 931b72e4 | 90 | `court` | +| 05c197f1 | 35 | `count` | +| 025e5aeb | 13 | `code` | + +問題の構造: + +1. **turn がそのまま終了する**。leak 発生時はツールが動かないため後続がなく、 + Stop が発火して作業が停止する。 +2. **ハーネスの自動リカバリは不十分**。`isMeta: true` の + `Your tool call was malformed and could not be parsed. Please retry.` 自動注入は + 4 セッション計 15 件程度しか発動せず、大半はユーザーが手動で指摘して再開させていた。 +3. **リトライ後の再 leak が実在する**。指摘を受けて「正しい形式で再実行します」と + 応答しながら再度 leak するケースを複数観測した。 + +根本原因は上流 (Claude Code / モデルのシリアライズ) にあり本リポジトリでは修正 +できないため、Stop hook による検知 + 再実行誘導で被害 (作業停止) を止める。 +これは [ADR-042 (ルール vs 仕組み化)](adr-042-rule-vs-mechanism-boundary.md) の +「仕組み化」側の対応である (決定論的に判定可能、CLAUDE.md ルールでは防げない)。 + +## 決定 + +新規 Stop hook exe `hooks-stop-tool-call-leak` (`src/hooks-stop-tool-call-leak/`) を +作成し、`settings.local.json.template` の Stop 配列の**先頭** (品質ゲートの前) に +登録する。leak 検知時は `{"decision": "block", "reason": "..."}` を返し、正規の +ツール呼び出しでの再実行を促す。 + +hooks-stop-quality への統合ではなく独立 exe とする +([ADR-022 (責務分離)](adr-022-automation-responsibility-separation.md)、 +hooks-stop-feedback-dispatch と同じ先例)。 + +### 検知条件 (実データ 198 件で検証済み) + +stdin の `transcript_path` から transcript JSONL の末尾 200 行を読み、 +`isSidechain` でない**最後の assistant エントリ**の `type: "text"` block を検査する: + +1. markdown code fence (```` ``` ```` / `~~~`) 内の行を除外する +2. 残る行に**行頭 (空白許容) `` 構造) に固有。 + 上流の不具合の形が変われば取り逃がす (その場合は fixture を追加して条件を拡張) +- 連続 3 回で fail-open するため、収束しない leak は最終的にユーザー介入が必要 + (無限ループ防止との意図的なトレードオフ) +- fence 内の leak は検知しない (誤検知回避を優先。実データでは fence 内 leak は 0 件) +- 本 hook の block reason 自体が ` **このファイルは一時的な作業計画ファイルです。作業完了時 (WP-7) に削除します。** +> セッション破損・別モデルへの引き継ぎを想定し、実装に必要な調査結果と設計判断を +> 自己完結的に記録しています。 + +## 背景 / 問題 + +Claude Code がツール呼び出しを正規の tool_use block ではなく**テキスト領域に +生テキストとして出力**し、ツールが実行されないまま turn が終了する不具合が多発している。 +出力は化けたプレフィックス行 (例: `court`) + `...` の形。 +turn がそのまま終了するため作業が停止し、ユーザーが都度指摘して再開させる必要がある。 + +対策として、Stop hook で直前の assistant 出力を検査し、leak を検知したら +`decision: block` で即座にエラーフィードバックを返して正規のツール呼び出しでの +再実行を促すカスタムリンター exe を新規作成する。 + +## 調査結果 (2026-07-12 実施、実データ根拠) + +セッションログ (`%USERPROFILE%\.claude\projects\c--Users-owner-work-claude-code-hook-test\` 配下の +`*.jsonl`) を調査した。主要ログ: `87387df2-e72e-488a-8548-9a1dd68b7948.jsonl`。 + +### 発生規模 + +| セッション (先頭8桁) | leak 件数 | プレフィックス行 | +|---|---|---| +| 87387df2 | 59 | `court` | +| 931b72e4 | 90 | `court` | +| 05c197f1 | 35 | `count` | +| 025e5aeb | 13 | `code` | + +計 197 件の真の leak + 1 件の正当引用 (後述) = ` +pnpm push 2>&1 + +``` + +931b72e4 では 49 件が `` の後に英語の自己言及テキスト +(例: `I keep failing. Let me use the correct format only.`) を伴う。 +**末尾アンカー (`` で終端) だけの検知は不可**。 + +### 検知設計の根拠となる観測事実 + +1. **正常な tool calling は `type: "tool_use"` block** に記録される (87387df2 で 203 件 + 確認)。`text` block のみ検査すれば正常呼び出しへの誤爆は構造的に起きない。 +2. **プレフィックス語はセッション間で変動** (`court` / `count` / `code`)。 + プレフィックス語に依存した検知は不可。`` 等) は +テキストとして表示されただけで**実行されていない**。呼び出し記法が壊れている。 +同じ内容を正規のツール呼び出しとして直ちに再実行せよ。テキスト領域に +`` の XML を書いてはならない」 + +### ループ防止 — `stop_hook_active` skip は採用しない (ADR-004 からの意図的逸脱) + +理由: (a) 品質ゲートブロック後の retry 中に発生した leak を取り逃がす、 +(b) 再 leak の実績があり 1 回で打ち切ると不十分。 + +代替: transcript 末尾から後方に走査し、**連続する leak 判定 assistant エントリ数**を +カウント。`max_consecutive_blocks` (デフォルト 3) 到達で fail-open +(stderr 警告 + 停止許可)。非 assistant エントリ (queue-operation / last-prompt / +ai-title / isMeta user 等) は走査中スキップし、非 leak の assistant エントリで +カウント打ち切り。後方走査はエントリ 50 件で上限。 +正しく再実行されれば最終 assistant エントリが変わるため自然収束する。 + +### エラー処理 — fail-open (ADR-043 からの意図的逸脱) + +transcript 読み取り失敗 / JSON パース失敗 / `transcript_path` 欠落時は +stderr 警告 + 停止許可 (exit 0)。fail-closed (block) にすると、読み取り失敗が +持続する環境では連続カウントも取得できず無限ブロックに陥るため。 +本 hook はセキュリティゲートではなく UX 復旧装置であり、逸脱は ADR に明記する。 + +### 設定 (ADR-039 experimental feature 標準パターン) + +`hooks-config.toml` に追加: + +```toml +[stop_tool_call_leak] +enabled = true # code default は false (opt-in)。本 repo は dogfood のため true +max_consecutive_blocks = 3 # 連続ブロック上限 (到達で fail-open) +``` + +- Kill-switch: `enabled = false` で恒久停止。緊急バイパスは env + `STOP_TOOL_CALL_LEAK_OVERRIDE=1` (FILE_LENGTH_CHECK_OVERRIDE と同パターン) +- Bounded lifetime: 根本原因は上流 (Claude Code / モデルのシリアライズ) の不具合。 + **上流修正の確認、または leak が 4 週間観測されなくなった時点で撤去を判定** +- 派生プロジェクト配布 (`pnpm deploy:hooks`) は code default OFF なので安全 + +## 実装 WP + +- [x] **WP-1**: crate 骨格。`src/hooks-stop-tool-call-leak/` (Cargo.toml + main.rs)、 + workspace members へ追加、`pnpm build:all` 対象化 (ADR-010 のビルド戦略に従う) +- [x] **WP-2**: 検知ロジック実装 + 単体テスト (`src/detect.rs` / `src/transcript.rs`)。 + fixture は実ログから sanitize して抽出 (ADR-049 incident→eval 準拠): + court/count/code 3 変種、trailing 自己言及テキスト付き、正当引用 (非検知)、 + tool_use のみ (非検知)、fence 内引用 (非検知) +- [x] **WP-3**: 設定読み込み + kill-switch env + 連続カウント fail-open + fail-open + エラー処理。それぞれ単体テスト (計 36 unit tests) +- [x] **WP-4**: `.claude/settings.local.json.template` の Stop 配列先頭に登録 + (timeout 5s)、`.claude/hooks-config.toml` に `[stop_tool_call_leak]` 追記。 + ※ template の実体パスは `.claude/settings.local.json.template` + (`pnpm build:hooks-settings` が参照) +- [x] **WP-5**: E2E テスト (`tests/e2e.rs`、7 tests)。実 exe を `CARGO_BIN_EXE` で + spawn し、一時 jsonl + stdin JSON で block/fail-open/kill-switch を検証 +- [x] **WP-6**: ADR-053 起草 + (`docs/adr/adr-053-stop-tool-call-leak-detection.md`、試験運用マーク)、 + CLAUDE.md 索引に追加。 + ※ 当初 ADR-052 で起草したが、master 側 PR #260 が ADR-052 + (自律実行境界の 2 クラス分類) を先に使用したため rebase 時に 053 へ採番変更 +- [ ] **WP-7**: 実データ検証 → 品質ゲート通過 → push / PR → **本ファイル削除** + - [x] 実データ検証 (2026-07-12 実施): メインリポジトリ全セッションログに対し + 実装済み `text_block_has_leak` で **検知 198 / 非検知 2** + (198 = 調査時の 197 + 検証当日に 87387df2 で新規発生 1 件。 + 非検知 2 は両方とも正当な言及で、誤検知 0・取り逃がし 0) + - [x] 品質ゲート: cargo test --workspace 全パス (新規 43 tests 含む)、 + cargo clippy --workspace -D warnings クリーン、pnpm lint / lint:md / test / build + パス、pnpm build:all で exe 配置 + settings.local.json 再生成、 + 配置済み release exe のスモークテスト (block JSON / 無出力) 確認済み + - [ ] push / PR (ADR-028 ゲートによりユーザー承認待ち) + - [ ] 本ファイル削除 + +## 検証方法 + +1. `cargo test -p hooks-stop-tool-call-leak` (単体 + E2E) +2. 実ログ 4 セッションに対する分離検証 (WP-7。leak 197 検知 / 正当引用 1 非検知) +3. `pnpm build:all` → `.claude/` に exe 配置 → 実セッションで dogfood diff --git a/package.json b/package.json index aa1e42f3..90ec971f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:hooks-post-tool-comment-lint-rust": "cargo build --release -p hooks-post-tool-comment-lint-rust && cp target/release/hooks-post-tool-comment-lint-rust.exe .claude/hooks-post-tool-comment-lint-rust.exe", "build:hooks-stop-quality": "cargo build --release -p hooks-stop-quality && cp target/release/hooks-stop-quality.exe .claude/hooks-stop-quality.exe", "build:hooks-stop-feedback-dispatch": "cargo build --release -p hooks-stop-feedback-dispatch && cp target/release/hooks-stop-feedback-dispatch.exe .claude/hooks-stop-feedback-dispatch.exe", + "build:hooks-stop-tool-call-leak": "cargo build --release -p hooks-stop-tool-call-leak && cp target/release/hooks-stop-tool-call-leak.exe .claude/hooks-stop-tool-call-leak.exe", "build:hooks-user-prompt-feedback-recovery": "cargo build --release -p hooks-user-prompt-feedback-recovery && cp target/release/hooks-user-prompt-feedback-recovery.exe .claude/hooks-user-prompt-feedback-recovery.exe", "build:cli-push-runner": "cargo build --release -p cli-push-runner && cp target/release/cli-push-runner.exe .claude/cli-push-runner.exe", "build:cli-pr-monitor": "cargo build --release -p cli-pr-monitor && cp target/release/cli-pr-monitor.exe .claude/cli-pr-monitor.exe", @@ -23,7 +24,7 @@ "build:cli-merge-pipeline": "cargo build --release -p cli-merge-pipeline && cp target/release/cli-merge-pipeline.exe .claude/cli-merge-pipeline.exe", "build:cli-docs-lint": "cargo build --release -p cli-docs-lint && cp target/release/cli-docs-lint.exe .claude/cli-docs-lint.exe", "build:hooks-settings": "node -e \"const fs=require('fs');const t=fs.readFileSync('.claude/settings.local.json.template','utf8');const p=process.cwd().replace(/\\\\/g,'\\\\\\\\');fs.writeFileSync('.claude/settings.local.json',t.replace(/\\{\\{PROJECT_DIR\\}\\}/g,p))\" && echo settings.local.json generated", - "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:hooks-settings", + "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-stop-tool-call-leak && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:hooks-settings", "push": ".\\.claude\\cli-push-runner.exe && .\\.claude\\cli-pr-monitor.exe --monitor-only", "create-pr": ".\\.claude\\cli-pr-monitor.exe", "mark-notified": ".\\.claude\\cli-pr-monitor.exe --mark-notified", diff --git a/src/hooks-stop-tool-call-leak/Cargo.toml b/src/hooks-stop-tool-call-leak/Cargo.toml new file mode 100644 index 00000000..0370a39e --- /dev/null +++ b/src/hooks-stop-tool-call-leak/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hooks-stop-tool-call-leak" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" + +[dev-dependencies] +tempfile = "3" +# serde_json / lib-subprocess: exe-spawn E2E test (tests/e2e.rs) 用。 +# integration tests は [dev-dependencies] のみ参照するため再宣言する +# (hooks-post-tool-linter の incident_eval と同 pattern)。 +serde_json = "1.0" +lib-subprocess = { path = "../lib-subprocess" } + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/hooks-stop-tool-call-leak/src/detect.rs b/src/hooks-stop-tool-call-leak/src/detect.rs new file mode 100644 index 00000000..4009c3be --- /dev/null +++ b/src/hooks-stop-tool-call-leak/src/detect.rs @@ -0,0 +1,170 @@ +//! Leak 検知ロジック (単一テキスト block に対する判定)。 +//! +//! 「leak」= ツール呼び出しが正規の tool_use block ではなく assistant の +//! テキスト領域に `...` の生 XML として出力され、 +//! 実行されないまま turn が終了する不具合 (ADR-053)。 +//! +//! 判定条件の根拠 (4 セッション 198 件の実データ調査、ADR-053 §調査結果): +//! - 実 leak 197 件は全て「行頭 (空白許容) `` の例を正当に +/// 書くケースを検知対象から除外する。fence が閉じられていない場合、以降の行は +/// fence 内とみなす (誤検知より取り逃がしを許容する方向に倒す)。 +fn lines_outside_fences(text: &str) -> Vec<&str> { + let mut in_fence = false; + let mut result = Vec::new(); + for line in text.lines() { + if is_fence_line(line) { + in_fence = !in_fence; + continue; + } + if !in_fence { + result.push(line); + } + } + result +} + +/// テキスト block が leak (テキスト領域に書かれたツール呼び出し XML) を含むか。 +/// +/// 条件: fence 外に「行頭 ` bool { + if !text.contains("") || trimmed.starts_with(" Option { + for line in lines_outside_fences(text) { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("\npnpm push 2>&1\nPush fix\n"; + + /// 実 leak の再現 fixture (05c197f1 セッション、`count` プレフィックス変種) + const LEAK_COUNT: &str = "確認します。\n\ncount\n\nC:\\work\\x.rs\n"; + + /// 実 leak の再現 fixture (025e5aeb セッション、`code` プレフィックス変種) + const LEAK_CODE: &str = "wakeup を予約します。\n\ncode\n\n15 21 11 7 *\n"; + + /// 実 leak の再現 fixture (931b72e4 セッション、`` 後に自己言及テキスト) + const LEAK_TRAILING_TEXT: &str = "court\n\na.toml\n\n\nI keep failing. Let me use the correct format only."; + + /// 正当引用の再現 fixture (05c197f1 セッション、インラインコードでの言及) + const LEGIT_INLINE_MENTION: &str = "これは UI の不具合ではなく、私の出力ミスです。壊れた書式 (`count` や `` で始まるテキスト) で書いてしまっていました。"; + + /// 正当引用 fixture (fence 内にツール呼び出し例を記載するドキュメント執筆ケース) + const LEGIT_FENCED_EXAMPLE: &str = "検知対象の例:\n\n```text\ncourt\n\npnpm push\n\n```\n\n以上が leak の構造です。"; + + #[test] + fn detects_leak_with_court_prefix() { + assert!(text_block_has_leak(LEAK_COURT)); + } + + #[test] + fn detects_leak_with_count_prefix() { + assert!(text_block_has_leak(LEAK_COUNT)); + } + + #[test] + fn detects_leak_with_code_prefix() { + assert!(text_block_has_leak(LEAK_CODE)); + } + + #[test] + fn detects_leak_with_trailing_self_talk() { + assert!(text_block_has_leak(LEAK_TRAILING_TEXT)); + } + + #[test] + fn skips_legit_inline_mention() { + assert!(!text_block_has_leak(LEGIT_INLINE_MENTION)); + } + + #[test] + fn skips_legit_fenced_example() { + assert!(!text_block_has_leak(LEGIT_FENCED_EXAMPLE)); + } + + #[test] + fn skips_plain_text() { + assert!(!text_block_has_leak("PR #259 が作成されました。監視を開始します。")); + } + + #[test] + fn skips_invoke_open_line_without_structure() { + assert!(!text_block_has_leak("壊れた出力:\n\nだけの断片")); + } + + #[test] + fn detects_leak_with_indented_lines() { + let text = " court\n \n ls\n "; + assert!(text_block_has_leak(text)); + } + + #[test] + fn unclosed_fence_suppresses_detection() { + let text = "例:\n```\ncourt\n\nls\n"; + assert!(!text_block_has_leak(text)); + } + + #[test] + fn extracts_tool_name_from_leak() { + assert_eq!(extract_tool_name(LEAK_COURT).as_deref(), Some("Bash")); + assert_eq!(extract_tool_name(LEAK_COUNT).as_deref(), Some("Read")); + assert_eq!(extract_tool_name(LEAK_CODE).as_deref(), Some("CronCreate")); + } + + #[test] + fn tool_name_absent_for_plain_text() { + assert_eq!(extract_tool_name("通常のテキストです"), None); + } + + #[test] + fn tool_name_ignores_fenced_example() { + assert_eq!(extract_tool_name(LEGIT_FENCED_EXAMPLE), None); + } + + #[test] + fn fence_with_tilde_is_recognized() { + let text = "~~~\n\nls\n~~~"; + assert!(!text_block_has_leak(text)); + } +} diff --git a/src/hooks-stop-tool-call-leak/src/main.rs b/src/hooks-stop-tool-call-leak/src/main.rs new file mode 100644 index 00000000..2065ed40 --- /dev/null +++ b/src/hooks-stop-tool-call-leak/src/main.rs @@ -0,0 +1,294 @@ +//! Stop tool call leak 検知フック (ADR-053) +//! +//! Claude Code がツール呼び出しを正規の tool_use block ではなくテキスト領域に +//! `...` の生 XML として出力し、実行されないまま +//! turn が終了する不具合を Stop 時に検知し、`decision: block` で正規の +//! ツール呼び出しによる再実行を促す。 +//! +//! 設計判断 (ADR-053): +//! - **`stop_hook_active` skip は不採用** (ADR-004 からの意図的逸脱)。 +//! 品質ゲート block 後の retry 中に発生した leak を取り逃がさないため、および +//! 再 leak 実績 (実データ) があるため。無限ループ防止は連続 leak カウント上限 +//! (`max_consecutive_blocks`、既定 3) 到達での fail-open で担保する。 +//! - **エラー時 fail-open** (ADR-043 からの意図的逸脱)。transcript 読み取り不能で +//! fail-closed (block) にすると連続カウントも取得できず無限ブロックに陥るため。 +//! 本 hook はセキュリティゲートではなく UX 復旧装置である。 +//! - **ADR-039 experimental pattern**: config opt-in (code default OFF) + +//! kill-switch (`enabled = false` / env `STOP_TOOL_CALL_LEAK_OVERRIDE`) + +//! bounded lifetime (上流修正確認 or leak 4 週間非観測で撤去判定)。 + +use serde::{Deserialize, Serialize}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +mod detect; +mod transcript; + +use transcript::{parse_tail_entries, scan_tail, TailScan}; + +/// 緊急バイパス用 env var (kill-switch)。truthy 値で検査を skip する。 +const OVERRIDE_ENV_VAR: &str = "STOP_TOOL_CALL_LEAK_OVERRIDE"; + +/// 連続 block 上限の既定値。到達で fail-open (stderr 警告 + 停止許可)。 +const DEFAULT_MAX_CONSECUTIVE_BLOCKS: u32 = 3; + +/// transcript 末尾から読むエントリ行数。leak は必ず turn 終端に位置するため +/// (ADR-053 §調査結果)、末尾のみで判定できる。 +const TAIL_LINES: usize = 200; + +/// Stop hook 入力 (必要なフィールドのみ) +#[derive(Deserialize)] +struct HookInput { + transcript_path: Option, +} + +/// block 判定の出力 +#[derive(Serialize)] +struct BlockDecision { + decision: String, + reason: String, +} + +/// hooks-config.toml のうち本 hook が参照する section のみ部分デシリアライズ +#[derive(Deserialize, Default)] +struct ConfigFile { + stop_tool_call_leak: Option, +} + +/// `[stop_tool_call_leak]` section (ADR-039: code default は disabled) +#[derive(Deserialize, Default)] +struct LeakConfig { + enabled: Option, + max_consecutive_blocks: Option, +} + +fn main() { + if kill_switch_active() { + return; + } + let config = load_config().stop_tool_call_leak.unwrap_or_default(); + if !config.enabled.unwrap_or(false) { + return; + } + let Some(transcript_path) = read_transcript_path_from_stdin() else { + return; + }; + let max_blocks = config + .max_consecutive_blocks + .unwrap_or(DEFAULT_MAX_CONSECUTIVE_BLOCKS); + run_check(Path::new(&transcript_path), max_blocks); +} + +/// override env の受理値判定 (FILE_LENGTH_CHECK_OVERRIDE と同 pattern) +fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +/// kill-switch env が設定されていれば skip (stderr に明示) +fn kill_switch_active() -> bool { + match std::env::var(OVERRIDE_ENV_VAR) { + Ok(value) if is_truthy(&value) => { + eprintln!( + "[stop-tool-call-leak] {} が設定されているため検査を skip します", + OVERRIDE_ENV_VAR + ); + true + } + _ => false, + } +} + +/// exe と同じディレクトリの hooks-config.toml パス (hooks-stop-quality と同方式) +fn config_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("hooks-config.toml") +} + +/// 設定を読み込む。読み込み / parse 失敗時は default (= disabled) を返す +fn load_config() -> ConfigFile { + let Ok(content) = std::fs::read_to_string(config_path()) else { + return ConfigFile::default(); + }; + toml::from_str(&content).unwrap_or_default() +} + +/// stdin の Stop hook 入力 JSON から transcript_path を取り出す。 +/// 読み取り / parse 失敗、field 欠落は fail-open (stderr 警告 + None)。 +fn read_transcript_path_from_stdin() -> Option { + let mut input = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut input) { + eprintln!("[stop-tool-call-leak] stdin 読み込み失敗 (fail-open): {}", e); + return None; + } + match serde_json::from_str::(&input) { + Ok(hook_input) => { + if hook_input.transcript_path.is_none() { + eprintln!("[stop-tool-call-leak] transcript_path 欠落 (fail-open)"); + } + hook_input.transcript_path + } + Err(e) => { + eprintln!("[stop-tool-call-leak] 入力 JSON parse 失敗 (fail-open): {}", e); + None + } + } +} + +/// transcript を読み、leak 判定と連続カウントに基づいて block / fail-open を決定する +fn run_check(transcript_path: &Path, max_blocks: u32) { + let content = match std::fs::read_to_string(transcript_path) { + Ok(c) => c, + Err(e) => { + eprintln!( + "[stop-tool-call-leak] transcript 読み込み失敗 (fail-open): {}: {}", + transcript_path.display(), + e + ); + return; + } + }; + let entries = parse_tail_entries(&content, TAIL_LINES); + let scan = scan_tail(&entries); + if scan.consecutive_leaks == 0 { + return; + } + if scan.consecutive_leaks >= max_blocks { + eprintln!( + "[stop-tool-call-leak] 連続 {} 回 leak を検知しましたが上限 ({}) に達したため停止を許可します (fail-open)", + scan.consecutive_leaks, max_blocks + ); + return; + } + emit_block(&build_reason(&scan, max_blocks)); +} + +/// block reason を組み立てる。ツール名と検知回数を明示して再実行を促す +fn build_reason(scan: &TailScan, max_blocks: u32) -> String { + let tool = scan.last_tool_name.as_deref().unwrap_or("不明"); + format!( + "ツール呼び出しがテキストとして出力され、実行されていません。\n\n\ + 直前の応答は、ツール呼び出し (ツール名: {}) を正規の tool_use block ではなく\ + テキスト領域に生の XML として出力しました。この呼び出しは解釈されず、\ + コマンドは一切実行されていません。\n\n\ + 対処: 直前に意図したツール呼び出しを、正規のツール呼び出し機構で\ + 直ちに再実行してください。応答テキストに XML を書き直してはいけません。\n\n\ + (stop-tool-call-leak 検知 {} 回目 / 上限 {} 回)", + tool, scan.consecutive_leaks, max_blocks + ) +} + +/// block 判定を stdout に出力する +fn emit_block(reason: &str) { + let decision = BlockDecision { + decision: "block".to_string(), + reason: reason.to_string(), + }; + if let Ok(json) = serde_json::to_string(&decision) { + println!("{}", json); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_to_disabled() { + let config = ConfigFile::default().stop_tool_call_leak.unwrap_or_default(); + assert!(!config.enabled.unwrap_or(false)); + } + + #[test] + fn config_parses_section() { + let toml_str = r#" +[stop_tool_call_leak] +enabled = true +max_consecutive_blocks = 5 +"#; + let config: ConfigFile = toml::from_str(toml_str).unwrap(); + let leak = config.stop_tool_call_leak.unwrap(); + assert!(leak.enabled.unwrap()); + assert_eq!(leak.max_consecutive_blocks.unwrap(), 5); + } + + #[test] + fn config_section_missing_yields_disabled() { + let config: ConfigFile = toml::from_str("[stop_quality]\nstep_timeout = 60\n").unwrap(); + assert!(config.stop_tool_call_leak.is_none()); + } + + #[test] + fn config_max_blocks_defaults_to_three() { + let leak = LeakConfig::default(); + assert_eq!( + leak.max_consecutive_blocks + .unwrap_or(DEFAULT_MAX_CONSECUTIVE_BLOCKS), + 3 + ); + } + + #[test] + fn is_truthy_accepts_standard_values() { + for value in ["1", "true", "TRUE", " yes ", "on"] { + assert!(is_truthy(value), "{:?} は truthy であるべき", value); + } + } + + #[test] + fn is_truthy_rejects_falsy_values() { + for value in ["", "0", "false", "off", "no", "2"] { + assert!(!is_truthy(value), "{:?} は falsy であるべき", value); + } + } + + #[test] + fn hook_input_parses_with_extra_fields() { + let json = r#"{ + "session_id": "abc", + "transcript_path": "C:\\tmp\\t.jsonl", + "stop_hook_active": false, + "hook_event_name": "Stop" + }"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!(input.transcript_path.as_deref(), Some("C:\\tmp\\t.jsonl")); + } + + #[test] + fn build_reason_includes_tool_name_and_counts() { + let scan = TailScan { + consecutive_leaks: 2, + last_tool_name: Some("Bash".to_string()), + }; + let reason = build_reason(&scan, 3); + assert!(reason.contains("Bash")); + assert!(reason.contains("2 回目")); + assert!(reason.contains("上限 3 回")); + assert!(reason.contains("実行されていません")); + } + + #[test] + fn build_reason_handles_unknown_tool_name() { + let scan = TailScan { + consecutive_leaks: 1, + last_tool_name: None, + }; + assert!(build_reason(&scan, 3).contains("不明")); + } + + #[test] + fn block_decision_serializes_correctly() { + let decision = BlockDecision { + decision: "block".to_string(), + reason: "re-run".to_string(), + }; + let json = serde_json::to_string(&decision).unwrap(); + assert!(json.contains(r#""decision":"block""#)); + assert!(json.contains(r#""reason":"re-run""#)); + } +} diff --git a/src/hooks-stop-tool-call-leak/src/transcript.rs b/src/hooks-stop-tool-call-leak/src/transcript.rs new file mode 100644 index 00000000..d4a4e33b --- /dev/null +++ b/src/hooks-stop-tool-call-leak/src/transcript.rs @@ -0,0 +1,254 @@ +//! transcript JSONL の tail 解析。 +//! +//! Stop hook 入力の `transcript_path` が指すセッション JSONL を末尾から走査し、 +//! 「最後の main-session assistant エントリが leak か」と「連続 leak 回数」を求める。 +//! +//! 連続 leak カウントの設計 (ADR-053 §ループ防止): +//! - leak 検知で block すると Claude が再試行し、再 leak し得る (実データで確認済み)。 +//! block は `max_consecutive_blocks` 回まで許容し、超えたら fail-open する。 +//! - block reason は `isMeta: true` の user エントリとして transcript に記録される +//! (実データで確認済み) ため、isMeta user はチェーンを切らずにスキップする。 +//! - 実ユーザーの発話 (isMeta でない user エントリ、tool_result を除く) は +//! 新しい試行の起点とみなしてチェーンをリセットする。 + +use crate::detect::{extract_tool_name, text_block_has_leak}; +use serde_json::Value; + +/// 末尾走査の対象エントリ数上限 (後方走査の暴走防止) +const MAX_SCAN_ENTRIES: usize = 50; + +/// tail 走査の結果 +pub(crate) struct TailScan { + /// 末尾から連続する leak assistant エントリ数 (0 = 最後の assistant は正常) + pub(crate) consecutive_leaks: u32, + /// 最新の leak から抽出したツール名 (block reason での提示用) + pub(crate) last_tool_name: Option, +} + +/// JSONL 文字列の末尾 `tail_lines` 行をパースする。パース不能な行はスキップ。 +pub(crate) fn parse_tail_entries(content: &str, tail_lines: usize) -> Vec { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(tail_lines); + lines[start..] + .iter() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +/// main セッションの assistant エントリか (sidechain = サブエージェントを除外) +fn is_main_assistant(entry: &Value) -> bool { + entry.get("type").and_then(Value::as_str) == Some("assistant") + && entry.get("isSidechain").and_then(Value::as_bool) != Some(true) +} + +/// assistant エントリの text block 群を返す。 +/// +/// `message.content` は通常 block 配列だが、文字列形式にもフォールバック対応する。 +fn assistant_text_blocks(entry: &Value) -> Vec<&str> { + match entry.pointer("/message/content") { + Some(Value::String(s)) => vec![s.as_str()], + Some(Value::Array(blocks)) => blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect(), + _ => Vec::new(), + } +} + +/// user エントリの content に tool_result block が含まれるか +fn user_content_is_tool_result(entry: &Value) -> bool { + match entry.pointer("/message/content") { + Some(Value::Array(blocks)) => blocks + .iter() + .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")), + _ => false, + } +} + +/// 連続 leak チェーンを打ち切る「実ユーザーの発話」か。 +/// +/// isMeta エントリ (Stop hook feedback / ハーネス自動注入) と tool_result は +/// チェーンを切らない。実ユーザーの発話は新しい試行の起点なのでリセットする。 +fn is_chain_breaking_user_entry(entry: &Value) -> bool { + if entry.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + if entry.get("isSidechain").and_then(Value::as_bool) == Some(true) { + return false; + } + if entry.get("isMeta").and_then(Value::as_bool) == Some(true) { + return false; + } + !user_content_is_tool_result(entry) +} + +/// エントリ列を末尾から走査し、連続 leak 数と最新 leak のツール名を求める。 +/// +/// 走査規則: +/// - 実ユーザーの発話に到達したら打ち切り (チェーンリセット) +/// - assistant 以外 (queue-operation / isMeta user / tool_result 等) はスキップ +/// - 非 leak の assistant に到達したら打ち切り +pub(crate) fn scan_tail(entries: &[Value]) -> TailScan { + let mut consecutive_leaks = 0u32; + let mut last_tool_name: Option = None; + for entry in entries.iter().rev().take(MAX_SCAN_ENTRIES) { + if is_chain_breaking_user_entry(entry) { + break; + } + if !is_main_assistant(entry) { + continue; + } + let blocks = assistant_text_blocks(entry); + if !blocks.iter().any(|text| text_block_has_leak(text)) { + break; + } + if consecutive_leaks == 0 { + last_tool_name = blocks.iter().find_map(|text| extract_tool_name(text)); + } + consecutive_leaks += 1; + } + TailScan { + consecutive_leaks, + last_tool_name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const LEAK_TEXT: &str = "court\n\npnpm push\n"; + + fn assistant_text_entry(text: &str) -> Value { + json!({"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}) + } + + fn assistant_tool_use_entry() -> Value { + json!({"type": "assistant", "message": {"content": [ + {"type": "text", "text": "実行します。"}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}} + ]}}) + } + + fn meta_user_entry(text: &str) -> Value { + json!({"type": "user", "isMeta": true, "message": {"role": "user", "content": text}}) + } + + fn real_user_entry(text: &str) -> Value { + json!({"type": "user", "message": {"role": "user", "content": text}}) + } + + fn tool_result_entry() -> Value { + json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]}}) + } + + fn to_jsonl(entries: &[Value]) -> String { + entries + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("\n") + } + + #[test] + fn parse_tail_entries_skips_malformed_lines() { + let content = format!("not-json{{\n{}", assistant_text_entry("hello")); + let entries = parse_tail_entries(&content, 200); + assert_eq!(entries.len(), 1); + } + + #[test] + fn parse_tail_entries_respects_tail_limit() { + let all: Vec = (0..10).map(|i| json!({"type": "x", "i": i})).collect(); + let entries = parse_tail_entries(&to_jsonl(&all), 3); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0]["i"], 7); + } + + #[test] + fn scan_detects_single_leak_with_tool_name() { + let entries = vec![assistant_tool_use_entry(), assistant_text_entry(LEAK_TEXT)]; + let scan = scan_tail(&entries); + assert_eq!(scan.consecutive_leaks, 1); + assert_eq!(scan.last_tool_name.as_deref(), Some("Bash")); + } + + #[test] + fn scan_returns_zero_for_normal_last_assistant() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + assistant_text_entry("完了しました。"), + ]; + let scan = scan_tail(&entries); + assert_eq!(scan.consecutive_leaks, 0); + } + + #[test] + fn scan_returns_zero_for_tool_use_only_entry() { + let entries = vec![assistant_tool_use_entry()]; + assert_eq!(scan_tail(&entries).consecutive_leaks, 0); + } + + #[test] + fn scan_counts_consecutive_leaks_across_meta_user_entries() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + meta_user_entry("Stop hook feedback: 再実行してください"), + assistant_text_entry(LEAK_TEXT), + ]; + assert_eq!(scan_tail(&entries).consecutive_leaks, 2); + } + + #[test] + fn scan_resets_chain_at_real_user_entry() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + real_user_entry("再実行してください"), + assistant_text_entry(LEAK_TEXT), + ]; + assert_eq!(scan_tail(&entries).consecutive_leaks, 1); + } + + #[test] + fn scan_does_not_break_chain_at_tool_result() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + tool_result_entry(), + assistant_text_entry(LEAK_TEXT), + ]; + assert_eq!(scan_tail(&entries).consecutive_leaks, 2); + } + + #[test] + fn scan_skips_sidechain_assistant_entries() { + let sidechain = json!({"type": "assistant", "isSidechain": true, + "message": {"content": [{"type": "text", "text": "サブエージェント出力"}]}}); + let entries = vec![assistant_text_entry(LEAK_TEXT), sidechain]; + let scan = scan_tail(&entries); + assert_eq!(scan.consecutive_leaks, 1); + assert_eq!(scan.last_tool_name.as_deref(), Some("Bash")); + } + + #[test] + fn scan_skips_non_message_entries() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + json!({"type": "queue-operation", "operation": "enqueue"}), + json!({"type": "last-prompt", "lastPrompt": "..."}), + ]; + assert_eq!(scan_tail(&entries).consecutive_leaks, 1); + } + + #[test] + fn scan_handles_string_content_assistant() { + let entry = json!({"type": "assistant", "message": {"content": LEAK_TEXT}}); + assert_eq!(scan_tail(&[entry]).consecutive_leaks, 1); + } + + #[test] + fn scan_handles_empty_entries() { + assert_eq!(scan_tail(&[]).consecutive_leaks, 0); + } +} diff --git a/src/hooks-stop-tool-call-leak/tests/e2e.rs b/src/hooks-stop-tool-call-leak/tests/e2e.rs new file mode 100644 index 00000000..ff338e50 --- /dev/null +++ b/src/hooks-stop-tool-call-leak/tests/e2e.rs @@ -0,0 +1,187 @@ +//! hooks-stop-tool-call-leak の exe-spawn E2E テスト (ADR-049 準拠)。 +//! +//! 実 exe を `CARGO_BIN_EXE_*` 経由で spawn し、Stop hook の stdin JSON と +//! 一時 transcript JSONL を与えて stdout の decision を検証する。ユニットテストが +//! 検知関数を直接呼ぶのに対し、本テストは stdin parse -> config 読込 -> transcript +//! 解析 -> block JSON 出力の全チェーンを通す。 +//! +//! fixture は実 incident (4 セッション 197 件の leak、ADR-053 §調査結果) を +//! 再現する synthetic data。 + +use lib_subprocess::{drain_pipe_unlimited, wait_with_timeout_safe}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Once; + +/// spawn した exe の bounded wait (dev-conventions.md § bounded wait) +const HOOK_TIMEOUT_SECS: u64 = 30; + +/// 実 leak を再現する text (87387df2 セッション由来の synthetic data) +const LEAK_TEXT: &str = "court\n\npnpm push 2>&1\n"; + +static COPY_CONFIG_ONCE: Once = Once::new(); + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..") +} + +fn exe_path() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_hooks-stop-tool-call-leak")) +} + +/// exe は自 binary の隣の hooks-config.toml を読む。`cargo test` 時の binary は +/// `target/debug/` にあり deploy 済み config が無いため、repo の config を +/// 隣に 1 回だけコピーする (並列テストの partial-copy race を Once で回避)。 +fn ensure_config_beside_exe() { + COPY_CONFIG_ONCE.call_once(|| { + let src = repo_root().join(".claude").join("hooks-config.toml"); + let content = std::fs::read_to_string(&src) + .unwrap_or_else(|e| panic!("repo hooks-config.toml read failed: {e}")); + assert!( + content.contains("[stop_tool_call_leak]"), + "repo config に [stop_tool_call_leak] section が必要 (false-green guard)" + ); + let dst = exe_path() + .parent() + .expect("exe has a parent dir") + .join("hooks-config.toml"); + std::fs::write(&dst, content) + .unwrap_or_else(|e| panic!("copy config beside exe failed: {e}")); + }); +} + +fn assistant_text_entry(text: &str) -> Value { + json!({"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}) +} + +fn meta_user_entry(text: &str) -> Value { + json!({"type": "user", "isMeta": true, "message": {"role": "user", "content": text}}) +} + +fn write_transcript(dir: &tempfile::TempDir, entries: &[Value]) -> PathBuf { + let path = dir.path().join("transcript.jsonl"); + let content = entries + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("\n"); + std::fs::write(&path, content).expect("write transcript fixture"); + path +} + +/// exe を spawn して stdin payload を与え、(stdout, stderr) を返す +fn run_hook(stdin_payload: &str, override_env: Option<&str>) -> (String, String) { + ensure_config_beside_exe(); + let mut cmd = Command::new(exe_path()); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + match override_env { + Some(value) => cmd.env("STOP_TOOL_CALL_LEAK_OVERRIDE", value), + None => cmd.env_remove("STOP_TOOL_CALL_LEAK_OVERRIDE"), + }; + let mut child = cmd.spawn().expect("spawn hook exe"); + child + .stdin + .take() + .expect("child stdin") + .write_all(stdin_payload.as_bytes()) + .expect("write stdin payload"); + let stdout_handle = drain_pipe_unlimited(child.stdout.take().expect("child stdout")); + let stderr_handle = drain_pipe_unlimited(child.stderr.take().expect("child stderr")); + let status = wait_with_timeout_safe("hooks-stop-tool-call-leak", &mut child, HOOK_TIMEOUT_SECS) + .expect("bounded wait"); + assert!(status.is_some(), "hook exe が {} 秒以内に終了しない", HOOK_TIMEOUT_SECS); + ( + stdout_handle.join().expect("join stdout drain"), + stderr_handle.join().expect("join stderr drain"), + ) +} + +fn stdin_for(transcript_path: &std::path::Path) -> String { + json!({ + "session_id": "e2e-test", + "transcript_path": transcript_path.to_string_lossy(), + "hook_event_name": "Stop", + "stop_hook_active": false + }) + .to_string() +} + +#[test] +fn leak_transcript_blocks_with_reason() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT)]); + let (stdout, _stderr) = run_hook(&stdin_for(&path), None); + let decision: Value = serde_json::from_str(&stdout).expect("stdout は block JSON"); + assert_eq!(decision["decision"], "block"); + let reason = decision["reason"].as_str().expect("reason は文字列"); + assert!(reason.contains("Bash"), "reason にツール名: {}", reason); + assert!(reason.contains("実行されていません"), "reason: {}", reason); +} + +#[test] +fn clean_transcript_allows_stop() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry("作業が完了しました。")]); + let (stdout, _stderr) = run_hook(&stdin_for(&path), None); + assert_eq!(stdout, "", "正常応答では出力なし (停止許可)"); +} + +#[test] +fn second_consecutive_leak_still_blocks() { + let dir = tempfile::tempdir().expect("temp dir"); + let entries = [ + assistant_text_entry(LEAK_TEXT), + meta_user_entry("Stop hook feedback: 再実行してください"), + assistant_text_entry(LEAK_TEXT), + ]; + let path = write_transcript(&dir, &entries); + let (stdout, _stderr) = run_hook(&stdin_for(&path), None); + let decision: Value = serde_json::from_str(&stdout).expect("stdout は block JSON"); + let reason = decision["reason"].as_str().expect("reason は文字列"); + assert!(reason.contains("2 回目"), "検知回数を明示: {}", reason); +} + +#[test] +fn consecutive_leaks_at_cap_fail_open() { + let dir = tempfile::tempdir().expect("temp dir"); + let entries = [ + assistant_text_entry(LEAK_TEXT), + meta_user_entry("Stop hook feedback: 1"), + assistant_text_entry(LEAK_TEXT), + meta_user_entry("Stop hook feedback: 2"), + assistant_text_entry(LEAK_TEXT), + ]; + let path = write_transcript(&dir, &entries); + let (stdout, stderr) = run_hook(&stdin_for(&path), None); + assert_eq!(stdout, "", "上限到達では block しない (fail-open)"); + assert!(stderr.contains("上限"), "fail-open を stderr に明示: {}", stderr); +} + +#[test] +fn kill_switch_env_skips_check() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT)]); + let (stdout, stderr) = run_hook(&stdin_for(&path), Some("1")); + assert_eq!(stdout, "", "kill-switch 有効時は検査 skip"); + assert!(stderr.contains("STOP_TOOL_CALL_LEAK_OVERRIDE"), "skip 理由を明示: {}", stderr); +} + +#[test] +fn missing_transcript_fails_open() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("no-such-transcript.jsonl"); + let (stdout, stderr) = run_hook(&stdin_for(&path), None); + assert_eq!(stdout, "", "transcript 不在では block しない"); + assert!(stderr.contains("fail-open"), "fail-open を stderr に明示: {}", stderr); +} + +#[test] +fn malformed_stdin_fails_open() { + let (stdout, stderr) = run_hook("not-a-json{", None); + assert_eq!(stdout, "", "壊れた stdin では block しない"); + assert!(stderr.contains("fail-open"), "fail-open を stderr に明示: {}", stderr); +}