From 4df9a437b0625b93b1cdb2afe19e27e9f1e7edcb Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 26 Jul 2026 14:49:21 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(stop-tool-call-leak):=20=E5=90=88?= =?UTF-8?q?=E6=88=90=E3=82=A8=E3=83=B3=E3=83=88=E3=83=AA=E3=81=A7=20scan?= =?UTF-8?q?=5Ftail=20=E3=81=AE=E3=83=81=E3=82=A7=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=82=92=E6=89=93=E3=81=A1=E5=88=87=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=20(ADR-061=20=E5=89=AF=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ハーネスが tool call parse 失敗の内部リトライも失敗した turn を isApiErrorMessage:true / model:"" の合成 assistant エントリで終端する。 この合成エントリは type:"assistant" だが leak を持たないため、scan_tail が 「非 leak の最終 assistant」としてチェーンを打ち切り、直前の実 leak を取り逃がす。 is_synthetic を追加し、isMeta user と同様チェーンを切らずスキップする。 828764ce incident の 2 パターン (leak→合成、leak→合成→実user→isMeta→leak→合成) を 再現する unit テストを追加。 --- .../src/transcript.rs | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/hooks-stop-tool-call-leak/src/transcript.rs b/src/hooks-stop-tool-call-leak/src/transcript.rs index d4a4e33b..522b8a58 100644 --- a/src/hooks-stop-tool-call-leak/src/transcript.rs +++ b/src/hooks-stop-tool-call-leak/src/transcript.rs @@ -41,6 +41,23 @@ fn is_main_assistant(entry: &Value) -> bool { && entry.get("isSidechain").and_then(Value::as_bool) != Some(true) } +/// ハーネスが API リトライ失敗時に記録する合成 assistant エントリか (ADR-061)。 +/// +/// v2.1.206 実測: ツール呼び出しの parse 失敗 → 内部リトライも失敗した turn は +/// `isApiErrorMessage: true` かつ `message.model == ""` の擬似 assistant +/// エントリ ("The model's tool call could not be parsed (retry also failed).") で +/// 終端する。この合成エントリは `type: "assistant"` だが leak を持たないため、素の +/// `scan_tail` では「非 leak の最終 assistant」としてチェーンを打ち切り、直前の実 +/// leak を取り逃がす (副因)。isMeta user と同様、チェーンを切らずスキップ対象とする。 +/// 一般 API エラー (529 Overloaded / session limit 等) も本条件に該当するが、スキップ +/// されるだけで leak 判定には影響しない (直前が leak でなければチェーンは伸びない)。 +fn is_synthetic(entry: &Value) -> bool { + let api_error = entry.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true); + let synthetic_model = + entry.pointer("/message/model").and_then(Value::as_str) == Some(""); + api_error || synthetic_model +} + /// assistant エントリの text block 群を返す。 /// /// `message.content` は通常 block 配列だが、文字列形式にもフォールバック対応する。 @@ -88,7 +105,8 @@ fn is_chain_breaking_user_entry(entry: &Value) -> bool { /// 走査規則: /// - 実ユーザーの発話に到達したら打ち切り (チェーンリセット) /// - assistant 以外 (queue-operation / isMeta user / tool_result 等) はスキップ -/// - 非 leak の assistant に到達したら打ち切り +/// - 合成 assistant エントリ (hard-fail、ADR-061) はチェーンを切らずスキップ +/// - 非 leak の実 assistant に到達したら打ち切り pub(crate) fn scan_tail(entries: &[Value]) -> TailScan { let mut consecutive_leaks = 0u32; let mut last_tool_name: Option = None; @@ -99,6 +117,9 @@ pub(crate) fn scan_tail(entries: &[Value]) -> TailScan { if !is_main_assistant(entry) { continue; } + if is_synthetic(entry) { + continue; + } let blocks = assistant_text_blocks(entry); if !blocks.iter().any(|text| text_block_has_leak(text)) { break; @@ -144,6 +165,15 @@ mod tests { json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]}}) } + /// ハーネスの hard-fail 合成エントリ (ADR-061、828764ce line 274/286 実測構造)。 + fn synthetic_entry() -> Value { + json!({"type": "assistant", "isApiErrorMessage": true, "message": { + "model": "", + "content": [{"type": "text", + "text": "The model's tool call could not be parsed (retry also failed)."}] + }}) + } + fn to_jsonl(entries: &[Value]) -> String { entries .iter() @@ -251,4 +281,59 @@ mod tests { fn scan_handles_empty_entries() { assert_eq!(scan_tail(&[]).consecutive_leaks, 0); } + + #[test] + fn is_synthetic_detects_both_markers() { + assert!(is_synthetic(&synthetic_entry())); + assert!( + is_synthetic(&json!({"type": "assistant", "isApiErrorMessage": true})), + "isApiErrorMessage のみでも合成と判定する" + ); + assert!( + is_synthetic(&json!({"type": "assistant", "message": {"model": ""}})), + "model == のみでも合成と判定する" + ); + assert!( + !is_synthetic(&assistant_text_entry(LEAK_TEXT)), + "通常 assistant は合成でない" + ); + } + + #[test] + fn scan_skips_synthetic_and_detects_preceding_leak() { + let entries = vec![assistant_text_entry(LEAK_TEXT), synthetic_entry()]; + let scan = scan_tail(&entries); + assert_eq!( + scan.consecutive_leaks, 1, + "828764ce 1 回目 (leak→合成 で turn 終端): 合成はチェーンを切らず直前の leak を検知" + ); + assert_eq!(scan.last_tool_name.as_deref(), Some("Bash")); + } + + #[test] + fn scan_zero_when_synthetic_follows_normal_assistant() { + let entries = vec![assistant_text_entry("作業を続けます。"), synthetic_entry()]; + assert_eq!( + scan_tail(&entries).consecutive_leaks, + 0, + "一般 API エラー (overloaded 等) が正常応答の後: 合成の直前が leak でなければ伸びない" + ); + } + + #[test] + fn scan_leak_chain_breaks_at_real_user_across_synthetic() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + synthetic_entry(), + real_user_entry("テキストで出力されて止まっています"), + meta_user_entry("The previous response failed to produce a valid tool call."), + assistant_text_entry(LEAK_TEXT), + synthetic_entry(), + ]; + assert_eq!( + scan_tail(&entries).consecutive_leaks, + 1, + "828764ce 2 回目 (leak→合成→実 user→isMeta→leak→合成): 実 user がチェーン起点をリセット" + ); + } } From 8c60ff1b5755f146bd8b819c16ee12bf52e2e6ba Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 16:03:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(stop-tool-call-leak):=20UserPromptSubm?= =?UTF-8?q?it=20hard-fail=20leak=20=E5=9B=9E=E5=8F=8E=E5=B1=A4=20(ADR-061?= =?UTF-8?q?=20=E4=B8=BB=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hard-fail 経路 (ツール呼び出し parse 失敗 → 内部リトライも失敗 → 合成エントリで turn エラー終了) では Stop hook が発火しないため、Stop hook 側の修正では届かない。 単一 exe を hook_event_name で分岐させ、UserPromptSubmit で「最後の assistant 活動が hard-fail leak」を検知する回収層を追加。additionalContext (モデル向け、XML 再出力の 禁止を明示) + 任意 systemMessage (ユーザー可視 1 行、ADR-059) の非ブロッキング 2 チャネル で再実行を促す (decision:block は出さない)。config は prompt_recovery_enabled / recovery_system_message_enabled を追加 (ともに code default OFF、ADR-039)。telemetry は Decision::Warn / id suffix "/prompt-recovery" で Stop block と区別する。 --- src/hooks-stop-tool-call-leak/src/main.rs | 148 ++++++++++++++--- src/hooks-stop-tool-call-leak/src/recovery.rs | 150 ++++++++++++++++++ .../src/transcript.rs | 117 ++++++++++++++ 3 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 src/hooks-stop-tool-call-leak/src/recovery.rs diff --git a/src/hooks-stop-tool-call-leak/src/main.rs b/src/hooks-stop-tool-call-leak/src/main.rs index 1f56ab40..fcfbf956 100644 --- a/src/hooks-stop-tool-call-leak/src/main.rs +++ b/src/hooks-stop-tool-call-leak/src/main.rs @@ -1,9 +1,15 @@ -//! Stop tool call leak 検知フック (ADR-053) +//! tool call leak 検知フック (ADR-053 / ADR-061) //! //! Claude Code がツール呼び出しを正規の tool_use block ではなくテキスト領域に //! `...` の生 XML として出力し、実行されないまま -//! turn が終了する不具合を Stop 時に検知し、`decision: block` で正規の -//! ツール呼び出しによる再実行を促す。 +//! turn が終了する不具合を検知する。単一 exe が `hook_event_name` で 2 経路に分岐する: +//! +//! - **Stop** (既定、`hook_event_name` 欠落時も含む): 末尾 leak を検知し `decision: block` +//! で正規のツール呼び出しによる再実行を促す (ADR-053)。 +//! - **UserPromptSubmit** (ADR-061 主因): ハーネスの内部リトライも失敗して合成エントリ +//! (`isApiErrorMessage:true` / `model:""`) で turn がエラー終了する +//! hard-fail 経路では Stop hook が発火しない。直後の UserPromptSubmit で取り逃がした +//! leak を検知し、非ブロッキングな additionalContext (+任意 systemMessage) で再実行を促す。 //! //! 設計判断 (ADR-053): //! - **`stop_hook_active` skip は不採用** (ADR-004 からの意図的逸脱)。 @@ -22,9 +28,10 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; mod detect; +mod recovery; mod transcript; -use transcript::{parse_tail_entries, scan_tail, TailScan}; +use transcript::{parse_tail_entries, scan_recovery, scan_tail, TailScan}; /// 緊急バイパス用 env var (kill-switch)。truthy 値で検査を skip する。 const OVERRIDE_ENV_VAR: &str = "STOP_TOOL_CALL_LEAK_OVERRIDE"; @@ -36,10 +43,11 @@ const DEFAULT_MAX_CONSECUTIVE_BLOCKS: u32 = 3; /// (ADR-053 §調査結果)、末尾のみで判定できる。 const TAIL_LINES: usize = 200; -/// Stop hook 入力 (必要なフィールドのみ) +/// hook 入力 (必要なフィールドのみ)。`hook_event_name` で Stop / UserPromptSubmit を分岐する。 #[derive(Deserialize)] struct HookInput { transcript_path: Option, + hook_event_name: Option, } /// block 判定の出力 @@ -55,11 +63,17 @@ struct ConfigFile { stop_tool_call_leak: Option, } -/// `[stop_tool_call_leak]` section (ADR-039: code default は disabled) +/// `[stop_tool_call_leak]` section (ADR-039: 各機能 code default は disabled) #[derive(Deserialize, Default)] struct LeakConfig { + /// Stop hook の leak block を有効化する。 enabled: Option, + /// 連続 block 上限 (到達で fail-open)。 max_consecutive_blocks: Option, + /// UserPromptSubmit の hard-fail leak 回収層を有効化する (ADR-061 主因)。 + prompt_recovery_enabled: Option, + /// 回収時に systemMessage (ユーザー可視 1 行、ADR-059) を出すか。 + recovery_system_message_enabled: Option, } fn main() { @@ -67,16 +81,31 @@ fn main() { return; } let config = load_config().stop_tool_call_leak.unwrap_or_default(); - if !config.enabled.unwrap_or(false) { + let Some(input) = read_hook_input_from_stdin() else { return; - } - let Some(transcript_path) = read_transcript_path_from_stdin() else { + }; + let Some(transcript_path) = input.transcript_path else { + eprintln!("[stop-tool-call-leak] transcript_path 欠落 (fail-open)"); return; }; + let path = Path::new(&transcript_path); + if input.hook_event_name.as_deref() == Some("UserPromptSubmit") { + if !config.prompt_recovery_enabled.unwrap_or(false) { + return; + } + run_recovery( + path, + config.recovery_system_message_enabled.unwrap_or(false), + ); + return; + } + if !config.enabled.unwrap_or(false) { + return; + } let max_blocks = config .max_consecutive_blocks .unwrap_or(DEFAULT_MAX_CONSECUTIVE_BLOCKS); - run_check(Path::new(&transcript_path), max_blocks); + run_check(path, max_blocks); } /// kill-switch env が設定されていれば skip (stderr に明示) @@ -110,21 +139,16 @@ fn load_config() -> ConfigFile { 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 { +/// stdin の hook 入力 JSON をパースする。 +/// 読み取り / parse 失敗は fail-open (stderr 警告 + None)。 +fn read_hook_input_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 - } + Ok(hook_input) => Some(hook_input), Err(e) => { eprintln!("[stop-tool-call-leak] 入力 JSON parse 失敗 (fail-open): {}", e); None @@ -160,6 +184,33 @@ fn run_check(transcript_path: &Path, max_blocks: u32) { emit_block(&build_reason(&scan, max_blocks)); } +/// UserPromptSubmit 回収層 (ADR-061 主因)。hard-fail 経路で Stop hook が発火せず +/// 取り逃がした leak を検知し、additionalContext (+任意 systemMessage) で再実行を促す。 +/// 出力は非ブロッキング (decision:block は出さない)。読み取り失敗は fail-open。 +fn run_recovery(transcript_path: &Path, emit_system_message: bool) { + 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_recovery(&entries); + if !scan.should_recover { + return; + } + record_recovery_firing(); + match recovery::render(scan.last_tool_name.as_deref(), emit_system_message) { + Some(json) => println!("{}", json), + None => eprintln!("[stop-tool-call-leak] recovery 出力の JSON serialize 失敗 (fail-open)"), + } +} + /// block reason を組み立てる。ツール名と検知回数を明示して再実行を促す fn build_reason(scan: &TailScan, max_blocks: u32) -> String { let tool = scan.last_tool_name.as_deref().unwrap_or("不明"); @@ -202,6 +253,18 @@ fn record_block_firing() { }); } +/// UserPromptSubmit 回収層が nudge を発火したことを telemetry に記録する (ADR-061、fail-open)。 +/// 非ブロッキングのため `Decision::Warn`、id は Stop block と区別する suffix 付き。 +fn record_recovery_firing() { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-stop-tool-call-leak", + kind: lib_telemetry::FiringKind::Hook, + id: "hooks-stop-tool-call-leak/prompt-recovery", + decision: lib_telemetry::Decision::Warn, + session_id: None, + }); +} + #[cfg(test)] mod tests { use super::*; @@ -241,6 +304,33 @@ max_consecutive_blocks = 5 ); } + #[test] + fn config_recovery_keys_default_to_disabled() { + let leak = LeakConfig::default(); + assert!( + !leak.prompt_recovery_enabled.unwrap_or(false), + "prompt_recovery_enabled は code default OFF (ADR-039)" + ); + assert!( + !leak.recovery_system_message_enabled.unwrap_or(false), + "recovery_system_message_enabled は code default OFF (ADR-059)" + ); + } + + #[test] + fn config_parses_recovery_keys() { + let toml_str = r#" +[stop_tool_call_leak] +enabled = true +prompt_recovery_enabled = true +recovery_system_message_enabled = true +"#; + let config: ConfigFile = toml::from_str(toml_str).unwrap(); + let leak = config.stop_tool_call_leak.unwrap(); + assert_eq!(leak.prompt_recovery_enabled, Some(true)); + assert_eq!(leak.recovery_system_message_enabled, Some(true)); + } + #[test] fn hook_input_parses_with_extra_fields() { let json = r#"{ @@ -251,6 +341,26 @@ max_consecutive_blocks = 5 }"#; let input: HookInput = serde_json::from_str(json).unwrap(); assert_eq!(input.transcript_path.as_deref(), Some("C:\\tmp\\t.jsonl")); + assert_eq!(input.hook_event_name.as_deref(), Some("Stop")); + } + + #[test] + fn hook_input_parses_user_prompt_submit_event() { + let json = r#"{ + "transcript_path": "C:\\tmp\\t.jsonl", + "hook_event_name": "UserPromptSubmit" + }"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!(input.hook_event_name.as_deref(), Some("UserPromptSubmit")); + } + + #[test] + fn hook_input_hook_event_name_optional() { + let input: HookInput = serde_json::from_str(r#"{"transcript_path": "x"}"#).unwrap(); + assert_eq!( + input.hook_event_name, None, + "hook_event_name 欠落時は None (Stop 扱いで後方互換)" + ); } #[test] diff --git a/src/hooks-stop-tool-call-leak/src/recovery.rs b/src/hooks-stop-tool-call-leak/src/recovery.rs new file mode 100644 index 00000000..3f0ec3d1 --- /dev/null +++ b/src/hooks-stop-tool-call-leak/src/recovery.rs @@ -0,0 +1,150 @@ +//! UserPromptSubmit 回収層の出力組み立て (ADR-061 主因)。 +//! +//! hard-fail 経路 (合成エントリで turn がエラー終了) では Stop hook が発火しないため、 +//! 直後の UserPromptSubmit で leak を検知し、正規のツール呼び出しでの再実行を促す。 +//! Stop hook (block) と異なり UserPromptSubmit では **`decision: block` を出してはならない** +//! (ユーザーの prompt 自体が拒否される)。additionalContext (モデル向け) と任意の +//! systemMessage (ユーザー可視 1 行、ADR-059) の非ブロッキング 2 チャネルで通知する。 + +use serde::Serialize; + +/// additionalContext / systemMessage 先頭に付す検出タグ (他 hook の命名規約に倣う)。 +const TAG: &str = "[TOOL_CALL_LEAK_RECOVERY]"; + +/// ツール名が抽出できなかった場合の表示。 +const UNKNOWN_TOOL: &str = "不明"; + +#[derive(Serialize)] +struct HookSpecificOutput { + #[serde(rename = "hookEventName")] + hook_event_name: &'static str, + #[serde(rename = "additionalContext")] + additional_context: String, +} + +#[derive(Serialize)] +struct RecoveryOutput { + #[serde(rename = "hookSpecificOutput")] + hook_specific_output: HookSpecificOutput, + #[serde(rename = "systemMessage", skip_serializing_if = "Option::is_none")] + system_message: Option, +} + +/// additionalContext (モデル向け、複数行可) を組み立てる。 +/// +/// ADR-053 の block reason と同旨で「テキストに XML を書き直すな」を明示する +/// (ハーネス自身の "Please retry the tool call now." 注入は実データで再 leak を防げ +/// なかったため、leak 固有の禁止事項を補強する)。末尾に ADR-059 defense-in-depth の +/// 「ユーザーに一言伝えよ」指示を含め、systemMessage 非表示環境でもモデル経由で届ける。 +fn build_additional_context(tool_name: Option<&str>) -> String { + let tool = tool_name.unwrap_or(UNKNOWN_TOOL); + format!( + "{TAG}\n\ + 直前の turn でツール呼び出し (ツール名: {tool}) がテキスト領域に生の XML として\ + 出力され、実行されないまま turn がエラー終了しました (tool call leak)。この経路は\ + Stop hook が発火しないため、本メッセージで回収しています。\n\n\ + 対処: 直前に意図したツール呼び出しを、正規のツール呼び出し機構で直ちに再実行して\ + ください。応答テキストに XML を書き直してはいけません。\n\n\ + また、この不具合が起きたことをセッション最初の応答でユーザーに一言伝えてください \ + (systemMessage 非表示環境向けの defense-in-depth、ADR-059)。" + ) +} + +/// systemMessage (ユーザー可視 1 行、ADR-059)。表示ノイズ抑制のため `\n` を含めない。 +fn build_system_message(tool_name: Option<&str>) -> String { + let tool = tool_name.unwrap_or(UNKNOWN_TOOL); + format!( + "ツール呼び出し ({tool}) がテキスト出力され未実行のまま turn 終了したため、再実行を促しました (tool-call-leak recovery)" + ) +} + +/// 回収 JSON 文字列を組み立てる。`emit_system_message` が真のときのみトップレベル +/// systemMessage を付す (ADR-059 config opt-in)。serialize 失敗時は `None` (fail-open)。 +pub(crate) fn render(tool_name: Option<&str>, emit_system_message: bool) -> Option { + let output = RecoveryOutput { + hook_specific_output: HookSpecificOutput { + hook_event_name: "UserPromptSubmit", + additional_context: build_additional_context(tool_name), + }, + system_message: emit_system_message.then(|| build_system_message(tool_name)), + }; + serde_json::to_string(&output).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn additional_context_starts_with_tag_and_names_tool() { + let ctx = build_additional_context(Some("Bash")); + assert!( + ctx.starts_with("[TOOL_CALL_LEAK_RECOVERY]\n"), + "検出タグが 1 行目: {ctx}" + ); + assert!(ctx.contains("Bash"), "ツール名を明示: {ctx}"); + } + + #[test] + fn additional_context_forbids_rewriting_xml() { + let ctx = build_additional_context(Some("Read")); + assert!( + ctx.contains("XML を書き直しては"), + "テキストへの XML 再出力を明示的に禁止する (ADR-053 と同旨): {ctx}" + ); + } + + #[test] + fn additional_context_falls_back_to_unknown_tool() { + assert!(build_additional_context(None).contains(UNKNOWN_TOOL)); + } + + #[test] + fn additional_context_includes_defense_in_depth_user_notice() { + assert!( + build_additional_context(Some("Bash")).contains("ユーザーに一言"), + "systemMessage 非表示環境向けの ADR-059 defense-in-depth 指示を含む" + ); + } + + #[test] + fn system_message_is_single_line() { + let msg = build_system_message(Some("Bash")); + assert!(!msg.contains('\n'), "systemMessage は 1 行に限定 (ADR-059): {msg}"); + assert!(msg.contains("Bash")); + } + + #[test] + fn render_includes_system_message_when_enabled() { + let json = render(Some("Bash"), true).expect("serialize"); + let v: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit"); + assert!(v["hookSpecificOutput"]["additionalContext"].is_string()); + assert!( + v["systemMessage"].is_string(), + "opt-in が真なら systemMessage を付す" + ); + } + + #[test] + fn render_omits_system_message_when_disabled() { + let json = render(Some("Bash"), false).expect("serialize"); + let v: Value = serde_json::from_str(&json).unwrap(); + assert!( + v.get("systemMessage").is_none(), + "opt-in が偽なら systemMessage を付さない (additionalContext のみ)" + ); + assert!(v["hookSpecificOutput"]["additionalContext"].is_string()); + } + + #[test] + fn render_never_emits_block_decision() { + let json = render(Some("Bash"), true).expect("serialize"); + let v: Value = serde_json::from_str(&json).unwrap(); + assert!( + v.get("decision").is_none(), + "UserPromptSubmit では decision:block を絶対に出さない" + ); + } +} diff --git a/src/hooks-stop-tool-call-leak/src/transcript.rs b/src/hooks-stop-tool-call-leak/src/transcript.rs index 522b8a58..e3013893 100644 --- a/src/hooks-stop-tool-call-leak/src/transcript.rs +++ b/src/hooks-stop-tool-call-leak/src/transcript.rs @@ -135,6 +135,52 @@ pub(crate) fn scan_tail(entries: &[Value]) -> TailScan { } } +/// UserPromptSubmit 回収層 (ADR-061 主因) の走査結果。 +pub(crate) struct RecoveryScan { + /// 「最後の assistant 活動が hard-fail leak」= 回収 nudge を発火すべきか。 + pub(crate) should_recover: bool, + /// 回収対象 leak のツール名 (additionalContext での提示用)。 + pub(crate) last_tool_name: Option, +} + +/// 末尾から「最後の assistant 活動が hard-fail leak」かを判定する (ADR-061 主因)。 +/// +/// hard-fail 経路 (合成エントリで turn がエラー終了) では Stop hook が発火しないため、 +/// 直後の UserPromptSubmit で回収する。判定規則 (末尾から `MAX_SCAN_ENTRIES` 件): +/// - assistant 以外 (user / system / attachment / 現 prompt 等) はすべてスキップし、 +/// 末尾から最初に出会う実 assistant を探す (現 prompt が transcript に載っていても頑健) +/// - その実 assistant の手前に合成エントリが 1 つ以上あり、かつ実 assistant が leak なら発火 +/// - 合成エントリが無い (最後の assistant 活動が通常応答、または合成を伴わない leak) 場合は +/// 発火しない。leak が最後で合成が無いケースは Stop hook の領分であり、UserPromptSubmit +/// での再誘導ループを避けるための意図的スコープ限定 (ADR-061 § 設計決定 3) +pub(crate) fn scan_recovery(entries: &[Value]) -> RecoveryScan { + let mut saw_synthetic = false; + for entry in entries.iter().rev().take(MAX_SCAN_ENTRIES) { + if !is_main_assistant(entry) { + continue; + } + if is_synthetic(entry) { + saw_synthetic = true; + continue; + } + if !saw_synthetic { + break; + } + let blocks = assistant_text_blocks(entry); + if blocks.iter().any(|text| text_block_has_leak(text)) { + return RecoveryScan { + should_recover: true, + last_tool_name: blocks.iter().find_map(|text| extract_tool_name(text)), + }; + } + break; + } + RecoveryScan { + should_recover: false, + last_tool_name: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -336,4 +382,75 @@ mod tests { "828764ce 2 回目 (leak→合成→実 user→isMeta→leak→合成): 実 user がチェーン起点をリセット" ); } + + fn turn_end_entry() -> Value { + json!({"type": "system", "subtype": "turn_duration"}) + } + + #[test] + fn recovery_fires_on_synthetic_after_leak() { + let entries = vec![assistant_text_entry(LEAK_TEXT), synthetic_entry()]; + let scan = scan_recovery(&entries); + assert!( + scan.should_recover, + "leak→合成 で turn がエラー終了 (Stop 不発火) は回収対象" + ); + assert_eq!(scan.last_tool_name.as_deref(), Some("Bash")); + } + + #[test] + fn recovery_skips_synthetic_after_normal_assistant() { + let entries = vec![assistant_text_entry("完了しました。"), synthetic_entry()]; + assert!( + !scan_recovery(&entries).should_recover, + "合成の直前が leak でなければ (overloaded 等) 回収しない" + ); + } + + #[test] + fn recovery_skips_normal_last_assistant() { + let entries = vec![assistant_text_entry("作業が完了しました。")]; + assert!(!scan_recovery(&entries).should_recover); + } + + #[test] + fn recovery_skips_leak_without_synthetic() { + let entries = vec![assistant_text_entry(LEAK_TEXT)]; + assert!( + !scan_recovery(&entries).should_recover, + "合成を伴わない末尾 leak は Stop hook の領分 (再誘導ループ回避)" + ); + } + + #[test] + fn recovery_fires_across_current_prompt_user_entry() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + synthetic_entry(), + turn_end_entry(), + real_user_entry("不具合が続いています"), + ]; + assert!( + scan_recovery(&entries).should_recover, + "UserPromptSubmit 時点で現 prompt が transcript 末尾に載っていても回収する" + ); + } + + #[test] + fn recovery_passes_consecutive_synthetic_entries() { + let entries = vec![ + assistant_text_entry(LEAK_TEXT), + synthetic_entry(), + synthetic_entry(), + ]; + assert!( + scan_recovery(&entries).should_recover, + "連続する合成エントリを通過して直前の leak を検知する" + ); + } + + #[test] + fn recovery_skips_empty_entries() { + assert!(!scan_recovery(&[]).should_recover); + } } From 283fe5da4f35afd9a48809f1c656e870e654f7c1 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 16:44:51 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test(stop-tool-call-leak):=20828764ce=20inc?= =?UTF-8?q?ident=20=E3=81=AE=20e2e=20fixture=20+=20=E5=9B=9E=E5=8F=8E?= =?UTF-8?q?=E5=B1=A4=20config=20=E6=9C=89=E5=8A=B9=E5=8C=96=20(ADR-061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks-config.toml の [stop_tool_call_leak] に prompt_recovery_enabled / recovery_system_message_enabled を追加 (dogfood のため true)。 e2e に実 incident 由来 fixture を追加 (ADR-049): - synthetic_after_leak_blocks_in_stop_mode: 副因 (合成跨ぎ block) の end-to-end 検証 - recovery_mode_*: UserPromptSubmit で additionalContext / systemMessage 出力、 clean/leak-only 非発火、kill-switch skip config assertion に新キーの値 assert を追加 (dev-conventions § fixture は値まで assert)。 --- .claude/hooks-config.toml | 29 ++++-- src/hooks-stop-tool-call-leak/tests/e2e.rs | 103 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 747adf14..a9b57a19 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -175,24 +175,37 @@ steps = [ { cmd = "npx", args = ["--no-install", "markdownlint-cli2", "{file}"], fix = false }, ] -# ─── Stop: tool call leak 検知 (ADR-053、試験運用) ─── +# ─── tool call leak 検知 (ADR-053 / ADR-061、試験運用) ─── # # ツール呼び出しが tool_use block ではなくテキスト領域に `` XML として -# 出力され、実行されないまま turn が終了する不具合を検知し、block で再実行を促す。 +# 出力され、実行されないまま turn が終了する不具合を検知する。単一 exe が +# hook_event_name で 2 経路に分岐する (settings.local.json.template で Stop と +# UserPromptSubmit の両方に登録): +# - Stop (ADR-053): 末尾 leak を検知し block で再実行を促す。 +# - UserPromptSubmit (ADR-061 主因): 内部リトライも失敗して合成エントリ +# (isApiErrorMessage:true / model:"") で turn がエラー終了する hard-fail +# 経路では Stop hook が発火しない。直後の UserPromptSubmit で取り逃がした leak を検知し、 +# 非ブロッキングな additionalContext (+任意 systemMessage) で再実行を促す。 # # 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)。 +# - Config opt-in (default OFF): 各機能の code default は false (unwrap_or(false))。 +# 本 repo は dogfood のため有効化。派生プロジェクト 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 週間非観測で撤去を判定する。 +# 上流修正の確認、または leak 4 週間非観測で ADR-053 とまとめて撤去を判定する。 # -# ループ防止: stop_hook_active skip ではなく連続 leak カウント上限で fail-open +# ループ防止 (Stop): 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) +# ADR-061 主因: hard-fail 経路の取り逃がしを UserPromptSubmit で回収する層。 +# code default OFF (prompt_recovery_enabled 未設定で無効)。本 repo は dogfood のため true。 +prompt_recovery_enabled = true +# 回収時に systemMessage (ユーザー可視 1 行、ADR-059) を出す。source default OFF。 +# systemMessage のみ止めたい場合は false (additionalContext の回収は継続)。 +recovery_system_message_enabled = true # ─── Stop: 品質ゲート ─── diff --git a/src/hooks-stop-tool-call-leak/tests/e2e.rs b/src/hooks-stop-tool-call-leak/tests/e2e.rs index 08ff880b..9d6c0f7a 100644 --- a/src/hooks-stop-tool-call-leak/tests/e2e.rs +++ b/src/hooks-stop-tool-call-leak/tests/e2e.rs @@ -74,6 +74,20 @@ fn assert_leak_config_matches_test_assumptions(content: &str) { (consecutive_leaks_at_cap_fail_open / second_consecutive_leak_still_blocks) の \ leak 件数も同時に更新すること" ); + assert_eq!( + leak.get("prompt_recovery_enabled") + .and_then(toml::Value::as_bool), + Some(true), + "E2E は prompt_recovery_enabled = true を前提とする (ADR-061)。無効化するなら \ + 回収 E2E (recovery_mode_emits_additional_context 等) の期待値も同時に更新すること" + ); + assert_eq!( + leak.get("recovery_system_message_enabled") + .and_then(toml::Value::as_bool), + Some(true), + "E2E は recovery_system_message_enabled = true を前提とする (ADR-059)。値を変えたら \ + recovery_mode_includes_system_message の期待値も同時に更新すること" + ); } fn assistant_text_entry(text: &str) -> Value { @@ -84,6 +98,15 @@ fn meta_user_entry(text: &str) -> Value { json!({"type": "user", "isMeta": true, "message": {"role": "user", "content": text}}) } +/// ハーネスの hard-fail 合成エントリ (ADR-061、828764ce line 274/286 実測構造)。 +fn synthetic_entry() -> Value { + json!({"type": "assistant", "isApiErrorMessage": true, "message": { + "model": "", + "content": [{"type": "text", + "text": "The model's tool call could not be parsed (retry also failed)."}] + }}) +} + fn write_transcript(dir: &tempfile::TempDir, entries: &[Value]) -> PathBuf { let path = dir.path().join("transcript.jsonl"); let content = entries @@ -140,10 +163,14 @@ fn run_hook(stdin_payload: &str, override_env: Option<&str>) -> (String, String) } fn stdin_for(transcript_path: &std::path::Path) -> String { + stdin_for_event(transcript_path, "Stop") +} + +fn stdin_for_event(transcript_path: &std::path::Path, event: &str) -> String { json!({ "session_id": "e2e-test", "transcript_path": transcript_path.to_string_lossy(), - "hook_event_name": "Stop", + "hook_event_name": event, "stop_hook_active": false }) .to_string() @@ -224,3 +251,77 @@ fn malformed_stdin_fails_open() { assert_eq!(stdout, "", "壊れた stdin では block しない"); assert!(stderr.contains("fail-open"), "fail-open を stderr に明示: {}", stderr); } + +#[test] +fn synthetic_after_leak_blocks_in_stop_mode() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT), synthetic_entry()]); + 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", + "828764ce 副因: 合成エントリを跨いで直前の leak を block (ADR-061)" + ); +} + +#[test] +fn recovery_mode_emits_additional_context() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT), synthetic_entry()]); + let (stdout, _stderr) = run_hook(&stdin_for_event(&path, "UserPromptSubmit"), None); + let out: Value = serde_json::from_str(&stdout).expect("stdout は回収 JSON"); + assert_eq!(out["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit"); + let ctx = out["hookSpecificOutput"]["additionalContext"] + .as_str() + .expect("additionalContext は文字列"); + assert!(ctx.contains("Bash"), "ツール名を明示: {}", ctx); + assert!(ctx.contains("再実行"), "再実行を促す: {}", ctx); + assert!( + out.get("decision").is_none(), + "UserPromptSubmit では decision:block を出さない" + ); +} + +#[test] +fn recovery_mode_includes_system_message() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT), synthetic_entry()]); + let (stdout, _stderr) = run_hook(&stdin_for_event(&path, "UserPromptSubmit"), None); + let out: Value = serde_json::from_str(&stdout).expect("stdout は回収 JSON"); + let msg = out["systemMessage"] + .as_str() + .expect("recovery_system_message_enabled=true なので systemMessage が付く"); + assert!(!msg.contains('\n'), "systemMessage は 1 行 (ADR-059): {}", msg); +} + +#[test] +fn recovery_mode_clean_transcript_no_output() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry("作業が完了しました。")]); + let (stdout, _stderr) = run_hook(&stdin_for_event(&path, "UserPromptSubmit"), None); + assert_eq!(stdout, "", "hard-fail leak が無ければ回収しない"); +} + +#[test] +fn recovery_mode_leak_without_synthetic_no_output() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT)]); + let (stdout, _stderr) = run_hook(&stdin_for_event(&path, "UserPromptSubmit"), None); + assert_eq!( + stdout, "", + "合成を伴わない末尾 leak は Stop hook の領分 (回収しない)" + ); +} + +#[test] +fn recovery_mode_kill_switch_skips() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = write_transcript(&dir, &[assistant_text_entry(LEAK_TEXT), synthetic_entry()]); + let (stdout, stderr) = run_hook(&stdin_for_event(&path, "UserPromptSubmit"), Some("1")); + assert_eq!(stdout, "", "kill-switch 有効時は回収も skip"); + assert!( + stderr.contains("STOP_TOOL_CALL_LEAK_OVERRIDE"), + "skip 理由を明示: {}", + stderr + ); +} From 9fefd5ad19681ef49062b8aa3ebe6cda4db166b8 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 16:47:39 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(hooks):=20tool-call-leak=20exe=20?= =?UTF-8?q?=E3=82=92=20UserPromptSubmit=20=E3=81=AB=E3=82=82=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=20(ADR-061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.local.json.template と settings.json (cloud dispatcher、ADR-060) の UserPromptSubmit 配列に hooks-stop-tool-call-leak を追加 (feedback-recovery の後、 timeout 5)。同一 exe が hook_event_name で Stop / UserPromptSubmit を分岐する。 settings.local.json は template から build:hooks-settings で再生成 (gitignore 対象)。 --- .claude/settings.json | 5 +++++ .claude/settings.local.json.template | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 2a81d02b..59002365 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -32,6 +32,11 @@ "type": "command", "command": "node scripts/cloud-hook-dispatch.mjs hooks-user-prompt-feedback-recovery", "timeout": 10 + }, + { + "type": "command", + "command": "node scripts/cloud-hook-dispatch.mjs hooks-stop-tool-call-leak", + "timeout": 10 } ] } diff --git a/.claude/settings.local.json.template b/.claude/settings.local.json.template index 2e1434a2..cd30f5c1 100644 --- a/.claude/settings.local.json.template +++ b/.claude/settings.local.json.template @@ -26,6 +26,11 @@ "type": "command", "command": "\"{{PROJECT_DIR}}/.claude/hooks-user-prompt-feedback-recovery{{EXE_SUFFIX}}\"", "timeout": 5 + }, + { + "type": "command", + "command": "\"{{PROJECT_DIR}}/.claude/hooks-stop-tool-call-leak{{EXE_SUFFIX}}\"", + "timeout": 5 } ] } From e3fd0955991ff32b929b559d245450918dd64ae3 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 16:49:16 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs(adr-061):=20tool=20call=20leak=20?= =?UTF-8?q?=E3=81=AE=20hard-fail=20=E7=B5=8C=E8=B7=AF=E5=AF=BE=E5=BF=9C=20?= =?UTF-8?q?ADR=20=E3=82=92=E8=BF=BD=E5=8A=A0=20+=20ADR-053=20dogfood=20?= =?UTF-8?q?=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-061 を新規作成 (試験運用、ADR-039 3 点セット): 828764ce incident の背景・ corpus 調査結論・根本原因 2 層 (主因=Stop 不発火 / 副因=scan_tail 合成打ち切り)・ 設計決定・bounded lifetime (ADR-053 連動) を記録。 ADR-053 に dogfood 観測 (取り逃がし 2 件、2026-07-27/28) を追記し ADR-061 を相互参照。 CLAUDE.md の ADR index に ADR-061 を追加。実装指示書 (plan md) は完了に伴い削除。 --- CLAUDE.md | 1 + .../adr-053-stop-tool-call-leak-detection.md | 19 ++ ...dr-061-tool-call-leak-hardfail-recovery.md | 201 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 docs/adr/adr-061-tool-call-leak-hardfail-recovery.md diff --git a/CLAUDE.md b/CLAUDE.md index 31debe8c..8e6eb271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ - [ADR-058: fix 後の決定論再ゲート (post-takt re-gate) — pre-push 経路への機械的 backstop 拡張](docs/adr/adr-058-post-takt-regate.md) *(試験運用)* - [ADR-059: hook 通知の可視化チャネル分離 (systemMessage = ユーザー向け / additionalContext = モデル向け)](docs/adr/adr-059-hook-system-message-visibility.md) *(試験運用)* - [ADR-060: Cloud ハーネス有効化 — tracked dispatcher 登録 + SessionStart 実体確保の 2 層分離](docs/adr/adr-060-cloud-harness-sessionstart-dispatcher.md) *(試験運用)* +- [ADR-061: tool call leak の hard-fail 経路対応 — Stop 不発火の回収層 + scan_tail 合成エントリ耐性](docs/adr/adr-061-tool-call-leak-hardfail-recovery.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/docs/adr/adr-053-stop-tool-call-leak-detection.md b/docs/adr/adr-053-stop-tool-call-leak-detection.md index 23a80a34..8c1566f5 100644 --- a/docs/adr/adr-053-stop-tool-call-leak-detection.md +++ b/docs/adr/adr-053-stop-tool-call-leak-detection.md @@ -167,8 +167,27 @@ dogfood 計測項目: block 発火数、fail-open (上限到達) 数、誤検知 - 本 hook の block reason 自体が `"`、"...could not be parsed + (retry also failed).") を記録して turn をエラー終了させ、**この経路では Stop hooks が + 発火しない** (正常終了 turn にある `stop_hook_summary` が leak turn にだけ無い)。 + Stop hook では構造的に届かない。 +2. **副因**: `scan_tail` が合成エントリ (`type: "assistant"` だが leak 無し) を「非 leak の + 最終 assistant」としてチェーンを打ち切り、直前の実 leak を取り逃がす。 + +対応は [ADR-061](adr-061-tool-call-leak-hardfail-recovery.md) で行う (UserPromptSubmit +回収層 + `scan_tail` の合成エントリ耐性)。本 ADR の bounded lifetime (撤去判定) は +ADR-061 と連動する。約 150 セッションの corpus 調査で hard-fail 合成エントリは本 incident の +2 件のみであり、頻度は稀だが同一コマンド文脈で 2/2 再現している。 + ## 関連 ADR +- [ADR-061](adr-061-tool-call-leak-hardfail-recovery.md) — hard-fail 経路対応 (本 ADR の拡張) - [ADR-039](adr-039-experimental-feature-standard-pattern.md) — 試験運用標準パターン - [ADR-004](adr-004-stop-hook-quality-gate.md) — Stop 品質ゲート (ループ防止方式の逸脱元) - [ADR-043](adr-043-security-gates-fail-closed.md) — fail-closed 原則 (エラー処理方式の逸脱元) diff --git a/docs/adr/adr-061-tool-call-leak-hardfail-recovery.md b/docs/adr/adr-061-tool-call-leak-hardfail-recovery.md new file mode 100644 index 00000000..11db4c21 --- /dev/null +++ b/docs/adr/adr-061-tool-call-leak-hardfail-recovery.md @@ -0,0 +1,201 @@ +# ADR-061: tool call leak の hard-fail 経路対応 — Stop 不発火の回収層 + scan_tail 合成エントリ耐性 + +## ステータス + +試験運用 (2026-07-28) + +> 本 ADR は [ADR-039 (試験運用標準パターン)](adr-039-experimental-feature-standard-pattern.md) に従う。 +> Config opt-in / kill-switch / bounded lifetime の 3 点を満たす。 +> [ADR-053 (Stop hook による tool call leak 検知)](adr-053-stop-tool-call-leak-detection.md) の拡張であり、 +> bounded lifetime は ADR-053 と連動する。 + +## コンテキスト + +[ADR-053](adr-053-stop-tool-call-leak-detection.md) は、ツール呼び出しが正規の tool_use block +ではなくテキスト領域に `...` の生 XML として出力され実行されないまま +turn が終了する不具合 (tool call leak) を、Stop hook で検知し `decision: block` で再実行を誘導する。 + +しかし improve workspace (`claude-code-hook-test-improve`、CLI 運用) のセッション `828764ce` で、 +ADR-053 で対処済みのはずの leak が 2 回発生し (2026-07-27 / 07-28)、いずれも Stop hook が +block しなかった。 + +### incident の transcript 構造 (1 回目、2026-07-27) + +| 行 | 内容 | +|---|---| +| 273 | assistant text — leak 本体 (`court` + 行頭 ``、model `claude-opus-4-8`、正規の `msg_*` id) | +| 274 | assistant text — **ハーネス合成エントリ** "The model's tool call could not be parsed (retry also failed)." | +| 275 | system `turn_duration` — **直前に `stop_hook_summary` が無い = Stop hooks 不発火** | + +合成エントリ (274) の識別フィールド (v2.1.206 実測): + +```json +{ + "type": "assistant", + "isApiErrorMessage": true, + "message": { + "id": "74272aea-... (UUID 形式、msg_* でない)", + "model": "", + "stop_reason": "stop_sequence", + "content": [{ "type": "text", + "text": "The model's tool call could not be parsed (retry also failed)." }] + } +} +``` + +2 回目 (2026-07-28、同セッション resume 後) は、ユーザー発話の直後にハーネスが `isMeta: true` の +user エントリ "The previous response failed to produce a valid tool call. Please retry the tool +call now." を自動注入 → assistant が謝罪文 + **再 leak** → 同じ合成エントリ → 同じく Stop 不発火、 +という流れだった。その次の turn では同じ注入の後に正規の tool_use で成功して正常終了しており、 +正常終了 turn には `stop_hook_summary` が毎回記録されている (= hook の登録・配備・config は正常)。 + +### corpus 横断調査の結論 + +3 プロジェクト (本 repo / improve / ccht-improve)・約 150 セッション・2026-06-28〜07-28・ +v2.1.191〜2.1.218 の全 transcript を調査した: + +- leak 自体と hook の block 成功実績は `claude-vscode` / `cli` / `sdk-cli` の **3 entrypoint + すべて**にある (CLI 固有の問題ではない) +- 今回の hard-fail 合成エントリ ("could not be parsed" 系 + `isApiErrorMessage: true`) は + **corpus 全体で 828764ce の 2 件のみ**。他の `isApiErrorMessage: true` は 529 Overloaded / + session limit / stalled stream という一般 API エラーだけ +- 同一バイナリ (v2.1.206)・同一 entrypoint (cli)・同一 workspace で、07-12 には正常経路 + (Stop 発火 → hook block 成功)、07-27/28 には hard-fail 経路が起きている → 経路分岐は環境・ + バージョンではなく応答の壊れ方 (内部リトライも失敗したか) に依存する +- hard-fail 経路は同一コマンド文脈 (background Bash で cli-push-runner 起動) で 2/2 再現しており、 + 一回性ではない + +## 根本原因 (2 層) + +### 主因: hard-fail 終了経路では Stop hook イベント自体が発火しない + +ハーネスの API レベル自動リトライが失敗すると、合成エントリを記録して turn をエラー終了させ、 +この経路では Stop hooks を呼ばない (正常終了 turn には毎回ある `stop_hook_summary` が leak turn に +だけ無いことで確認済み)。hook は呼ばれる機会自体が無く、Stop hook 側の修正では構造的に届かない。 +上流 (Claude Code) の「エラー終了経路で Stop hooks が発火しない」挙動が本質的な原因である。 + +### 副因: scan_tail が合成エントリでチェーンを打ち切る + +`src/hooks-stop-tool-call-leak/src/transcript.rs` の `scan_tail` は「非 leak の assistant エントリに +到達したら打ち切り」で、合成エントリは `type: "assistant"` のため最終 assistant として検査され、 +leak なし → `consecutive_leaks = 0` になる。`is_main_assistant()` は `isApiErrorMessage` / +`model == ""` を見ていない。仮に Stop が発火していても取り逃がしていた。 + +## 決定 + +### 1. 単一 exe で両イベントを処理する + +hook 入力 JSON の `hook_event_name` で分岐し、Stop (従来の block 判定) と UserPromptSubmit +(回収層) の両方に `hooks-stop-tool-call-leak` を登録する。責務は「leak 検知」で同一 +([ADR-022 (責務分離)](adr-022-automation-responsibility-separation.md) 整合)、detect/transcript +モジュールを共有できる。`hook_event_name` 欠落時は Stop 扱い (後方互換)。 + +### 2. 副因修正: 合成エントリをチェーンを切らずスキップ + +`isApiErrorMessage == true` または `message.model == ""` の assistant エントリを +isMeta user と同扱いにする (leak とも数えない)。一般 API エラー (529 Overloaded 等) も本条件に +該当するが、スキップされるだけで leak 判定には影響しない (直前が leak でなければチェーンは伸びない)。 + +### 3. 回収層の検知条件: 「最後の assistant 活動が hard-fail leak」 + +末尾から user エントリを含む非 assistant をすべてスキップし、最初に現れた main assistant が +合成エントリ (連続する場合は連続分を通過) で、その直前の実 assistant エントリの text block が +leak なら発火する。これにより: + +- (a) UserPromptSubmit 時点で現 prompt が transcript に載っていても頑健 +- (b) 正常 turn を挟んだ古い leak では発火しない +- (c) overloaded 等 (直前が leak でない) では発火しない + +**leak が最後で合成エントリが無いケースでは発火しない** (それは Stop hook の責務。fail-open 後の +再誘導ループを避ける意図的スコープ限定)。 + +### 4. 回収層の出力は non-blocking + +UserPromptSubmit で `decision: block` は絶対に出さない (ユーザーの prompt 自体を拒否してしまう)。 +出力は 2 チャネル: + +- `additionalContext` (モデル向け): 直前 turn のツール呼び出し (ツール名を明示) が実行されて + いないこと、正規のツール呼び出し機構で直ちに再実行すること、**応答テキストに XML を書き直さない + こと** (ADR-053 の block reason と同旨。ハーネス自身の注入 "Please retry..." は実データで再 leak を + 防げなかったため、leak 固有の禁止事項を補強する) +- `systemMessage` (config opt-in、[ADR-059](adr-059-hook-system-message-visibility.md) チャネル + 分離): ユーザー可視 1 行 + +additionalContext には ADR-059 defense-in-depth として「セッション最初の応答でユーザーに一言 +伝えよ」を明示し、systemMessage 非表示環境でもモデル経由で届ける。 + +### 5. Telemetry + +回収発火時に `lib_telemetry::record` で hook `hooks-stop-tool-call-leak` / kind `Hook` / +id `hooks-stop-tool-call-leak/prompt-recovery` / `Decision::Warn` を記録 (non-blocking なので +Block ではない。id suffix で Stop block と区別する)。 + +### 検知条件 (実データ由来、ADR-049) + +fixture は 828764ce の実構造 (leak → 合成、および leak → 合成 → 実 user → isMeta 注入 → leak → +合成) を再現する ([ADR-049 (incident→eval)](adr-049-incident-eval-regression-suite.md) 準拠)。 + +## ADR-039 3 点セット + +### Config opt-in (default OFF) + +`hooks-config.toml` の `[stop_tool_call_leak]` section に追加: + +```toml +[stop_tool_call_leak] +prompt_recovery_enabled = true # code default は false (unwrap_or(false)) +recovery_system_message_enabled = true # source default OFF (ADR-059) +``` + +未設定では回収層は完全 skip。本リポジトリは dogfood のため `true`。派生プロジェクトへの deploy 時は +code default OFF を継承する。 + +### Kill-switch + +| 停止手段 | 影響範囲 | +|---|---| +| `prompt_recovery_enabled = false` | 回収層のみ恒久停止 (Stop block は継続) | +| `recovery_system_message_enabled = false` | systemMessage のみ停止 (additionalContext の回収は継続) | +| env `STOP_TOOL_CALL_LEAK_OVERRIDE=1` (truthy 値) | 緊急バイパス。Stop / UserPromptSubmit 両経路共通 | + +### Bounded lifetime + +根本原因は上流の不具合であり、本 hook は上流が修正されるまでの時限的な防御層である。 +[ADR-053](adr-053-stop-tool-call-leak-detection.md) と連動して撤去を判定する: + +- **撤去**: 上流 (Claude Code / モデル) の修正が確認できた、または leak が **4 週間観測されなく + なった**時点で、ADR-053 とまとめて hook 登録解除 + crate 削除の revert PR を作成 +- **継続**: leak が観測され続ける間は維持 + +dogfood 計測項目: 回収発火数 (telemetry の `hooks-stop-tool-call-leak/prompt-recovery`)、 +UserPromptSubmit の発火順 (ハーネス自身の isMeta 注入との前後関係の実観測)、誤発火報告 (期待値 0)。 + +## 帰結 + +### 利点 + +- hard-fail 経路 (Stop 不発火) で取り逃がしていた leak を、次の UserPromptSubmit で回収できる +- 副因修正により、Stop が発火する経路でも合成エントリ跨ぎの leak を取りこぼさない +- 判定は決定論的 (行アンカーの文字列判定 + フラグ判定のみ) で高速 +- fixture は実 incident 由来 (ADR-049 準拠の回帰テスト) + +### 欠点 / 留意点 + +- **回収の限界**: ユーザー発話なしでは回収できない (hard-fail 時にはいかなる hook イベントも + 発火しないため構造的に不可避)。これは上流報告の主眼であり、本対応のスコープ外 +- **UserPromptSubmit の発火順**: ハーネス自身の isMeta 注入との前後関係は制御できない。 + additionalContext は独立に届くため機能上は問題ないが、初回 dogfood で実挙動を観測する +- **検知条件の固有性**: 合成エントリの文言・フラグは v2.1.206 の実測に基づく。上流変更で形が + 変われば fixture を追加して追随する (ADR-053 と同じ前提) +- 上流 anthropics/claude-code への issue 報告 (「エラー終了経路で Stop hooks が発火しない」) は + 別途ユーザー承認制 ([ADR-052](adr-052-autonomy-execution-boundary-classes.md)) + +## 関連 ADR + +- [ADR-053](adr-053-stop-tool-call-leak-detection.md) — 既存の Stop hook 検知 (本 ADR の拡張元) +- [ADR-039](adr-039-experimental-feature-standard-pattern.md) — 試験運用標準パターン +- [ADR-049](adr-049-incident-eval-regression-suite.md) — incident→eval 回帰スイート (fixture 方針) +- [ADR-059](adr-059-hook-system-message-visibility.md) — systemMessage / additionalContext チャネル分離 +- [ADR-060](adr-060-cloud-harness-sessionstart-dispatcher.md) — cloud dispatcher 登録 parity +- [ADR-022](adr-022-automation-responsibility-separation.md) — 責務分離 (単一 exe 判断の根拠) +- [ADR-052](adr-052-autonomy-execution-boundary-classes.md) — 外部可視アクションのゲート From cc9f14bfd51422e7f8fa06c3c73fcd82feff988a Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 17:25:31 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs(stop-tool-call-leak):=20dual-mode=20?= =?UTF-8?q?=E5=8C=96=E3=81=AB=E4=BC=B4=E3=81=86=20module=20doc=20/=20?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E6=95=B4=E5=90=88=20(ADR-0?= =?UTF-8?q?61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks-stop-tool-call-leak/src/main.rs | 4 ++++ src/hooks-stop-tool-call-leak/src/transcript.rs | 8 ++++++-- src/hooks-stop-tool-call-leak/tests/e2e.rs | 14 ++++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/hooks-stop-tool-call-leak/src/main.rs b/src/hooks-stop-tool-call-leak/src/main.rs index fcfbf956..f6745265 100644 --- a/src/hooks-stop-tool-call-leak/src/main.rs +++ b/src/hooks-stop-tool-call-leak/src/main.rs @@ -187,6 +187,10 @@ fn run_check(transcript_path: &Path, max_blocks: u32) { /// UserPromptSubmit 回収層 (ADR-061 主因)。hard-fail 経路で Stop hook が発火せず /// 取り逃がした leak を検知し、additionalContext (+任意 systemMessage) で再実行を促す。 /// 出力は非ブロッキング (decision:block は出さない)。読み取り失敗は fail-open。 +/// +/// `run_check` の `max_consecutive_blocks` に相当する連続発火上限は意図的に持たない。 +/// 出力が非ブロッキングで retry ループを自ら誘発しないため上限が不要であり、発火頻度は +/// telemetry (`hooks-stop-tool-call-leak/prompt-recovery`) で dogfood 観測する (ADR-061)。 fn run_recovery(transcript_path: &Path, emit_system_message: bool) { let content = match std::fs::read_to_string(transcript_path) { Ok(c) => c, diff --git a/src/hooks-stop-tool-call-leak/src/transcript.rs b/src/hooks-stop-tool-call-leak/src/transcript.rs index e3013893..ecfd83bb 100644 --- a/src/hooks-stop-tool-call-leak/src/transcript.rs +++ b/src/hooks-stop-tool-call-leak/src/transcript.rs @@ -1,7 +1,11 @@ //! transcript JSONL の tail 解析。 //! -//! Stop hook 入力の `transcript_path` が指すセッション JSONL を末尾から走査し、 -//! 「最後の main-session assistant エントリが leak か」と「連続 leak 回数」を求める。 +//! セッション JSONL を末尾から走査し、2 つの hook 経路にそれぞれの判定を提供する: +//! - Stop (`scan_tail`、ADR-053): 「最後の main-session assistant エントリが leak か」と +//! 「連続 leak 回数」を求める。 +//! - UserPromptSubmit (`scan_recovery`、ADR-061): hard-fail 経路 (合成エントリで turn +//! エラー終了) で Stop が発火せず取り逃がした leak を「最後の assistant 活動が hard-fail +//! leak か」で検知する。 //! //! 連続 leak カウントの設計 (ADR-053 §ループ防止): //! - leak 検知で block すると Claude が再試行し、再 leak し得る (実データで確認済み)。 diff --git a/src/hooks-stop-tool-call-leak/tests/e2e.rs b/src/hooks-stop-tool-call-leak/tests/e2e.rs index 9d6c0f7a..dce3b804 100644 --- a/src/hooks-stop-tool-call-leak/tests/e2e.rs +++ b/src/hooks-stop-tool-call-leak/tests/e2e.rs @@ -120,12 +120,14 @@ fn write_transcript(dir: &tempfile::TempDir, entries: &[Value]) -> PathBuf { /// 子へ stdin payload を書く。**子が読まずに終了済みでも失敗させない**。 /// -/// `main` は kill-switch (`STOP_TOOL_CALL_LEAK_OVERRIDE`) と `enabled = false` の -/// 2 経路で **stdin を読む前に return** する。この場合パイプの読み手が消えるため、 -/// 親の `write_all` は Unix で `BrokenPipe` (EPIPE) になる。子の exit と親の write の -/// どちらが先かは競合で、Windows は小さな payload がバッファに収まり成功しがちなのに対し -/// Linux では実際に失敗する (2026-07-20、ubuntu-22.04 CI で `kill_switch_env_skips_check` -/// が Broken pipe で落ちた。WSL では通っていたため CI matrix が初めて捕捉した)。 +/// `main` は kill-switch (`STOP_TOOL_CALL_LEAK_OVERRIDE`) の 1 経路で **stdin を読む前に +/// return** する (ADR-061 で dual-mode 化した際、`enabled` / `prompt_recovery_enabled` の +/// 判定は `hook_event_name` を得るまで確定しないため stdin 読み取り後へ移動した。stdin 前に +/// return するのは kill-switch のみ)。この場合パイプの読み手が消えるため、親の `write_all` は +/// Unix で `BrokenPipe` (EPIPE) になる。子の exit と親の write のどちらが先かは競合で、 +/// Windows は小さな payload がバッファに収まり成功しがちなのに対し Linux では実際に失敗する +/// (2026-07-20、ubuntu-22.04 CI で `kill_switch_env_skips_check` が Broken pipe で落ちた。 +/// WSL では通っていたため CI matrix が初めて捕捉した)。 /// /// これらの test の主題は「skip されること」であって「stdin が消費されること」ではない。 /// よって `BrokenPipe` のみ正常として飲み込み、他の I/O エラーは従来どおり panic させる。 From 9bdad0fb415c957b135de6af603eb9563f6846f3 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 28 Jul 2026 22:53:30 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor(stop-tool-call-leak):=20PR=20#326?= =?UTF-8?q?=20review=20=E5=AF=BE=E5=BF=9C=20(ADR-061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (Minor): systemMessage の 1 行検証で復帰文字 (CR) も拒否するよう e2e / unit の assert を厳密化 (LF のみ拒否では CR 入りメッセージが「1 行」テストを 通過するため)。 simplicity-review (DRY): run_check / run_recovery で重複していた transcript 読み込み + fail-open ブロックを load_tail_entries ヘルパーに共通化。 --- src/hooks-stop-tool-call-leak/src/main.rs | 29 +++++++++---------- src/hooks-stop-tool-call-leak/src/recovery.rs | 5 +++- src/hooks-stop-tool-call-leak/tests/e2e.rs | 6 +++- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/hooks-stop-tool-call-leak/src/main.rs b/src/hooks-stop-tool-call-leak/src/main.rs index f6745265..c4547147 100644 --- a/src/hooks-stop-tool-call-leak/src/main.rs +++ b/src/hooks-stop-tool-call-leak/src/main.rs @@ -156,8 +156,9 @@ fn read_hook_input_from_stdin() -> Option { } } -/// transcript を読み、leak 判定と連続カウントに基づいて block / fail-open を決定する -fn run_check(transcript_path: &Path, max_blocks: u32) { +/// transcript を読み末尾 `TAIL_LINES` 行をパースする。 +/// 読み取り失敗は fail-open (stderr 警告 + None)。Stop / UserPromptSubmit 両経路で共用する。 +fn load_tail_entries(transcript_path: &Path) -> Option> { let content = match std::fs::read_to_string(transcript_path) { Ok(c) => c, Err(e) => { @@ -166,10 +167,17 @@ fn run_check(transcript_path: &Path, max_blocks: u32) { transcript_path.display(), e ); - return; + return None; } }; - let entries = parse_tail_entries(&content, TAIL_LINES); + Some(parse_tail_entries(&content, TAIL_LINES)) +} + +/// transcript を読み、leak 判定と連続カウントに基づいて block / fail-open を決定する +fn run_check(transcript_path: &Path, max_blocks: u32) { + let Some(entries) = load_tail_entries(transcript_path) else { + return; + }; let scan = scan_tail(&entries); if scan.consecutive_leaks == 0 { return; @@ -192,18 +200,9 @@ fn run_check(transcript_path: &Path, max_blocks: u32) { /// 出力が非ブロッキングで retry ループを自ら誘発しないため上限が不要であり、発火頻度は /// telemetry (`hooks-stop-tool-call-leak/prompt-recovery`) で dogfood 観測する (ADR-061)。 fn run_recovery(transcript_path: &Path, emit_system_message: bool) { - 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 Some(entries) = load_tail_entries(transcript_path) else { + return; }; - let entries = parse_tail_entries(&content, TAIL_LINES); let scan = scan_recovery(&entries); if !scan.should_recover { return; diff --git a/src/hooks-stop-tool-call-leak/src/recovery.rs b/src/hooks-stop-tool-call-leak/src/recovery.rs index 3f0ec3d1..80ec294e 100644 --- a/src/hooks-stop-tool-call-leak/src/recovery.rs +++ b/src/hooks-stop-tool-call-leak/src/recovery.rs @@ -111,7 +111,10 @@ mod tests { #[test] fn system_message_is_single_line() { let msg = build_system_message(Some("Bash")); - assert!(!msg.contains('\n'), "systemMessage は 1 行に限定 (ADR-059): {msg}"); + assert!( + !msg.contains('\n') && !msg.contains('\r'), + "systemMessage は 1 行に限定 (ADR-059): {msg}" + ); assert!(msg.contains("Bash")); } diff --git a/src/hooks-stop-tool-call-leak/tests/e2e.rs b/src/hooks-stop-tool-call-leak/tests/e2e.rs index dce3b804..1ae2f138 100644 --- a/src/hooks-stop-tool-call-leak/tests/e2e.rs +++ b/src/hooks-stop-tool-call-leak/tests/e2e.rs @@ -293,7 +293,11 @@ fn recovery_mode_includes_system_message() { let msg = out["systemMessage"] .as_str() .expect("recovery_system_message_enabled=true なので systemMessage が付く"); - assert!(!msg.contains('\n'), "systemMessage は 1 行 (ADR-059): {}", msg); + assert!( + !msg.contains('\n') && !msg.contains('\r'), + "systemMessage は 1 行 (ADR-059): {}", + msg + ); } #[test]