From 6b973c3158f26d5f6c4054da2a327e204e7d830f Mon Sep 17 00:00:00 2001 From: Caner Akca Date: Fri, 4 Sep 2026 14:27:57 +0300 Subject: [PATCH 1/2] fix: keep claude dialog detection working in narrow panes Claude word-wraps its own hint and footer lines, so a pane under about 50 columns splits `Esc to cancel` across two rendered rows. Detection reads rendered rows, so every rule gating on that footer stopped matching and an open dialog reported idle. Match the affected phrases across a single bounded wrap: spaces or tabs, or one newline with optional indentation, never a blank line and never no whitespace at all. Bump the manifest version so hosts holding a cached remote manifest pick up the new rules. refs #2868 --- distribution/agent-detection/claude.toml | 66 ++++--- src/detect/manifest/tests.rs | 232 +++++++++++++++++++++++ src/detect/manifests/claude.toml | 66 ++++--- 3 files changed, 320 insertions(+), 44 deletions(-) diff --git a/distribution/agent-detection/claude.toml b/distribution/agent-detection/claude.toml index 2a26a33427..2beb78b070 100644 --- a/distribution/agent-detection/claude.toml +++ b/distribution/agent-detection/claude.toml @@ -1,7 +1,7 @@ id = "claude" -version = "2026.08.31.1" +version = "2026.09.04.1" min_engine_version = 2 -updated_at = "2026-08-31T00:00:00Z" +updated_at = "2026-09-04T00:00:00Z" aliases = ["claude-code"] [[rules]] @@ -43,7 +43,7 @@ visible_working = true regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$'] not = [ { contains = ["do you want to proceed?"] }, - { contains = ["esc to cancel"] }, + { regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] }, { contains = ["waiting for permission"] }, { contains = ["do you want to allow this connection?"] }, { contains = ["tab to amend"] }, @@ -82,15 +82,21 @@ state = "blocked" priority = 980 region = "after_last_horizontal_rule" visible_blocker = true -contains = ["esc to cancel"] +# Claude word-wraps its own hint and footer lines, so any phrase sharing one of +# those lines can be split by a real newline in a narrow pane (issue #2868). +# Rules that gate on that evidence match across a single wrap with +# `(?:[ \t]+|[ \t]*\n[ \t]*)`: spaces/tabs, or one newline with optional +# indentation. The separator stays bounded on purpose. It must not match a +# blank line between the words, and it must not match no whitespace at all. +regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] any = [ - { contains = ["enter to confirm"] }, - { contains = ["enter to select"], any = [ - { contains = ["tab/arrow keys to navigate"] }, - { contains = ["arrow keys to navigate"] }, - { contains = ["arrows to navigate"] }, - { contains = ["↑/↓ to navigate"] }, - { contains = ["↑↓ to navigate"] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)confirm'] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'], any = [ + { regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)arrows(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, ] }, ] @@ -100,7 +106,10 @@ state = "blocked" priority = 980 region = "whole_recent" visible_blocker = true -contains = ["run a dynamic workflow?", "esc to cancel"] +regex = [ + '(?i)run(?:[ \t]+|[ \t]*\n[ \t]*)a(?:[ \t]+|[ \t]*\n[ \t]*)dynamic(?:[ \t]+|[ \t]*\n[ \t]*)workflow\?', + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', +] [[rules]] id = "mcp_elicitation_prompt" @@ -111,9 +120,13 @@ visible_blocker = true # MCP elicitation dialogs (elicitation/create) show Accept/Decline controls # with an "Esc to cancel" footer but no Enter hint, so live_blocked_form # cannot see them (issue #3283). Gate on the invariant header line, the -# Accept/Decline control line, and the cancel footer. -contains = ["esc to cancel"] -line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$'] +# Accept/Decline control line, and the cancel footer. The header keeps its +# line anchors so prose cannot impersonate it, but the quoted server name and +# the trailing words may wrap in a narrow pane (#2868). +regex = [ + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', + '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}](?:[^\n]|\n[ \t]*){1,120}?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', +] all = [ { any = [ { line_regex = ['^\s*\x{276F}?\s*Accept\b'] }, @@ -128,12 +141,14 @@ priority = 950 region = "prompt_box_body" visible_idle = true line_regex = ['^\s*❯'] +# A wrapped dialog footer must still suppress the idle claim, otherwise a +# narrow pane reports idle when no blocked rule managed to match (#2868). not = [ - { contains = ["enter to select"] }, - { contains = ["esc to cancel"] }, - { contains = ["tab/arrow keys"] }, - { contains = ["arrow keys to navigate"] }, - { contains = ["↑/↓ to navigate"] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'] }, + { regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] }, + { regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys'] }, + { regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, ] [[rules]] @@ -142,7 +157,11 @@ state = "unknown" priority = 900 region = "whole_recent" skip_state_update = true -contains = ["select model", "enter to set as default", "esc to cancel"] +regex = [ + '(?i)select(?:[ \t]+|[ \t]*\n[ \t]*)model', + '(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)set(?:[ \t]+|[ \t]*\n[ \t]*)as(?:[ \t]+|[ \t]*\n[ \t]*)default', + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', +] not = [ { contains = ["do you want to proceed?"] }, { contains = ["enter to select"] }, @@ -172,7 +191,10 @@ state = "blocked" priority = 840 region = "after_last_horizontal_rule" visible_blocker = true -contains = ["do you want to proceed?", "esc to cancel"] +# "Do you want to proceed?" renders on its own line and only wraps below ~24 +# columns, so it stays a contiguous needle; the shared footer does not. +contains = ["do you want to proceed?"] +regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] all = [ { any = [ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 63345636cc..e9acb17f1b 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -802,6 +802,238 @@ fn claude_blocker_screen_outranks_stale_osc_progress() { assert!(result.visible_blocker); } +#[test] +fn claude_wrapped_selection_dialog_is_blocked() { + // #2868: Claude word-wraps its own footer, so the reporter's 48-column pane + // splits `Esc to cancel` across two rendered rows and the contiguous needle + // stopped matching. Both captures come from the issue thread; only the pane + // width differs between them. + let wide = "──────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; + let narrow = "────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to\ncancel\n"; + + for (label, screen) in [("50 columns", wide), ("48 columns", narrow)] { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_eq!(result.state, AgentState::Blocked, "{label}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("live_blocked_form"), + "{label}" + ); + assert!(result.visible_blocker, "{label}"); + } +} + +#[test] +fn claude_wrapped_selection_dialog_is_blocked_on_a_full_screen() { + // The same dialog as a whole screen, reported on Windows two releases later. + // Only the wrap positions differ; every other row is identical. + let wide = concat!( + "───────────────────────────────────────────────────────────────────────────────\n", + " ☐ Indentation\n", + "\n", + "Do you prefer tabs or spaces for indentation?\n", + "\n", + "❯ 1. Spaces\n", + " Indent with spaces (the common default in most style guides).\n", + " 2. Tabs\n", + " Indent with tab characters.\n", + " 3. Type something.\n", + "───────────────────────────────────────────────────────────────────────────────\n", + " 4. Chat about this\n", + "\n", + "Enter to select · ↑/↓ to navigate · Esc to cancel\n", + ); + let narrow = concat!( + "──────────────────────────────────────────\n", + " ☐ Indentation\n", + "\n", + "Do you prefer tabs or spaces for\n", + "indentation?\n", + "\n", + "❯ 1. Spaces\n", + " Indent with spaces (the common\n", + " default in most style guides).\n", + " 2. Tabs\n", + " Indent with tab characters.\n", + " 3. Type something.\n", + "──────────────────────────────────────────\n", + " 4. Chat about this\n", + "\n", + "Enter to select · ↑/↓ to navigate · Esc to\n", + "cancel\n", + ); + + for (label, screen) in [("wide", wide), ("narrow", narrow)] { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_eq!(result.state, AgentState::Blocked, "{label}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("live_blocked_form"), + "{label}" + ); + } +} + +#[test] +fn claude_wrapped_mcp_elicitation_dialog_is_blocked() { + // The elicitation header and its footer share the same wrap exposure, and + // the header needed line anchors, so it keeps them across the wrap. + let wide = concat!( + "MCP server \u{201C}notes\u{201D} requests your input\n", + "\n", + "\u{276F} Accept\n", + " Decline\n", + "\n", + "Esc to cancel\n", + ); + let narrow = concat!( + "MCP server \u{201C}notes\u{201D} requests your\n", + "input\n", + "\n", + "\u{276F} Accept\n", + " Decline\n", + "\n", + "Esc to\n", + "cancel\n", + ); + + for (label, screen) in [("wide", wide), ("narrow", narrow)] { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_eq!(result.state, AgentState::Blocked, "{label}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("mcp_elicitation_prompt"), + "{label}" + ); + } +} + +#[test] +fn claude_wrapped_permission_prompt_keeps_its_own_rule() { + // A narrow permission prompt already reported blocked, but only through the + // priority 300 legacy fallback. The owning rule must keep the match. + // The wide screen is the shape this rule was written against. The narrow one + // is that same screen with the footer phrase wrapped: no capture pins where + // this particular footer breaks, so it only asserts the rule survives a wrap. + let wide = "────────────────────────\nDo you want to proceed?\n\n\u{276F} 1. Yes\n 2. No\n\nEsc to cancel\n"; + let narrow = "────────────────────────\nDo you want to proceed?\n\n\u{276F} 1. Yes\n 2. No\n\nEsc to\ncancel\n"; + + for (label, screen) in [("wide", wide), ("narrow", narrow)] { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_eq!(result.state, AgentState::Blocked, "{label}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("generic_permission_prompt"), + "{label}" + ); + } +} + +#[test] +fn claude_wrapped_dynamic_workflow_prompt_is_blocked() { + let narrow = "Run a dynamic\nworkflow?\n\n\u{276F} 1. Yes\n 2. No\n\nEnter to confirm · Esc to\ncancel\n"; + let result = osc_explain(Agent::Claude, narrow, "", ""); + assert_eq!(result.state, AgentState::Blocked); + assert!(result.visible_blocker); +} + +#[test] +fn claude_wrapped_model_picker_still_skips_the_state_update() { + // The picker is not a blocker; a wrapped footer must not cost it its skip. + let narrow = "Select model\n\n\u{276F} 1. Default\n 2. Opus\n\nEnter to set as\ndefault · Esc to\ncancel\n"; + let result = osc_explain(Agent::Claude, narrow, "", ""); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("model_picker_menu") + ); + assert!(result.skip_state_update); +} + +#[test] +fn claude_wrap_tolerant_blocker_leaves_neighbouring_screens_alone() { + // Negative controls for #2868: loosening the whitespace between the footer + // words must not reach streaming, overlay, or transcript screens. + // `live_turn_working` (priority 970) owns Claude's streaming screens. The + // widened blocker at 980 must not steal them. Both lines below are real + // Claude renderings: the spinner summary and the mode line. + let spinner = osc_explain( + Agent::Claude, + "✽ Cooking… (6s · ↓ 174 tokens · thinking)\n", + "", + "", + ); + assert_eq!(spinner.state, AgentState::Working); + assert_eq!( + spinner.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("live_turn_working") + ); + + let mode_line = osc_explain( + Agent::Claude, + "⏵⏵ accept edits on · esc to interrupt\n", + "", + "", + ); + assert_eq!(mode_line.state, AgentState::Working); + assert_eq!( + mode_line.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("live_turn_working") + ); + + let overlay = osc_explain(Agent::Claude, " /btw\n a note\n\nesc to close\n", "", ""); + assert_eq!(overlay.state, AgentState::Working); + assert_eq!( + overlay.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("btw_overlay_working") + ); + + let transcript = osc_explain( + Agent::Claude, + "Showing detailed transcript · Ctrl+O to toggle\n", + "", + "", + ); + assert_eq!( + transcript + .matched_rule + .as_ref() + .map(|rule| rule.id.as_str()), + Some("transcript_viewer") + ); + assert!(transcript.skip_state_update); +} + +#[test] +fn claude_blocker_footer_separator_spans_one_wrap_only() { + // The footer separator is deliberately bounded. A soft wrap inserts exactly + // one newline, so a blank line between the words is not a wrap, a + // non-breaking space is not a wrap point at all, and no whitespace is not a + // footer. All three must stay unmatched. + let rejected = [ + ( + "blank line between `esc to` and `cancel`", + "────────────────────────\nEnter to confirm\n\nsomething esc to\n\ncancel\n", + ), + ( + "non-breaking spaces instead of a wrap", + "────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc\u{a0}to\u{a0}cancel\n", + ), + ( + "no whitespace at all", + "────────────────────────\n 4. Chat about this\n\nEntertoselect · ↑/↓tonavigate · Esctocancel\n", + ), + ]; + + for (label, screen) in rejected { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_ne!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("live_blocked_form"), + "{label}" + ); + } +} + #[test] fn claude_osc_progress_4_0_is_idle() { let result = osc_explain(Agent::Claude, "", "", "4;0;"); diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 2a26a33427..2beb78b070 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -1,7 +1,7 @@ id = "claude" -version = "2026.08.31.1" +version = "2026.09.04.1" min_engine_version = 2 -updated_at = "2026-08-31T00:00:00Z" +updated_at = "2026-09-04T00:00:00Z" aliases = ["claude-code"] [[rules]] @@ -43,7 +43,7 @@ visible_working = true regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$'] not = [ { contains = ["do you want to proceed?"] }, - { contains = ["esc to cancel"] }, + { regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] }, { contains = ["waiting for permission"] }, { contains = ["do you want to allow this connection?"] }, { contains = ["tab to amend"] }, @@ -82,15 +82,21 @@ state = "blocked" priority = 980 region = "after_last_horizontal_rule" visible_blocker = true -contains = ["esc to cancel"] +# Claude word-wraps its own hint and footer lines, so any phrase sharing one of +# those lines can be split by a real newline in a narrow pane (issue #2868). +# Rules that gate on that evidence match across a single wrap with +# `(?:[ \t]+|[ \t]*\n[ \t]*)`: spaces/tabs, or one newline with optional +# indentation. The separator stays bounded on purpose. It must not match a +# blank line between the words, and it must not match no whitespace at all. +regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] any = [ - { contains = ["enter to confirm"] }, - { contains = ["enter to select"], any = [ - { contains = ["tab/arrow keys to navigate"] }, - { contains = ["arrow keys to navigate"] }, - { contains = ["arrows to navigate"] }, - { contains = ["↑/↓ to navigate"] }, - { contains = ["↑↓ to navigate"] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)confirm'] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'], any = [ + { regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)arrows(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, ] }, ] @@ -100,7 +106,10 @@ state = "blocked" priority = 980 region = "whole_recent" visible_blocker = true -contains = ["run a dynamic workflow?", "esc to cancel"] +regex = [ + '(?i)run(?:[ \t]+|[ \t]*\n[ \t]*)a(?:[ \t]+|[ \t]*\n[ \t]*)dynamic(?:[ \t]+|[ \t]*\n[ \t]*)workflow\?', + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', +] [[rules]] id = "mcp_elicitation_prompt" @@ -111,9 +120,13 @@ visible_blocker = true # MCP elicitation dialogs (elicitation/create) show Accept/Decline controls # with an "Esc to cancel" footer but no Enter hint, so live_blocked_form # cannot see them (issue #3283). Gate on the invariant header line, the -# Accept/Decline control line, and the cancel footer. -contains = ["esc to cancel"] -line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$'] +# Accept/Decline control line, and the cancel footer. The header keeps its +# line anchors so prose cannot impersonate it, but the quoted server name and +# the trailing words may wrap in a narrow pane (#2868). +regex = [ + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', + '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}](?:[^\n]|\n[ \t]*){1,120}?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', +] all = [ { any = [ { line_regex = ['^\s*\x{276F}?\s*Accept\b'] }, @@ -128,12 +141,14 @@ priority = 950 region = "prompt_box_body" visible_idle = true line_regex = ['^\s*❯'] +# A wrapped dialog footer must still suppress the idle claim, otherwise a +# narrow pane reports idle when no blocked rule managed to match (#2868). not = [ - { contains = ["enter to select"] }, - { contains = ["esc to cancel"] }, - { contains = ["tab/arrow keys"] }, - { contains = ["arrow keys to navigate"] }, - { contains = ["↑/↓ to navigate"] }, + { regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'] }, + { regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] }, + { regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys'] }, + { regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, + { regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] }, ] [[rules]] @@ -142,7 +157,11 @@ state = "unknown" priority = 900 region = "whole_recent" skip_state_update = true -contains = ["select model", "enter to set as default", "esc to cancel"] +regex = [ + '(?i)select(?:[ \t]+|[ \t]*\n[ \t]*)model', + '(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)set(?:[ \t]+|[ \t]*\n[ \t]*)as(?:[ \t]+|[ \t]*\n[ \t]*)default', + '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', +] not = [ { contains = ["do you want to proceed?"] }, { contains = ["enter to select"] }, @@ -172,7 +191,10 @@ state = "blocked" priority = 840 region = "after_last_horizontal_rule" visible_blocker = true -contains = ["do you want to proceed?", "esc to cancel"] +# "Do you want to proceed?" renders on its own line and only wraps below ~24 +# columns, so it stays a contiguous needle; the shared footer does not. +contains = ["do you want to proceed?"] +regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] all = [ { any = [ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, From d4dc2e2a3d3937ac1bf8475a2b937cf57dd1a32f Mon Sep 17 00:00:00 2001 From: Caner Akca Date: Fri, 4 Sep 2026 15:05:09 +0300 Subject: [PATCH 2/2] fix: bound the claude elicitation header to one wrap The quoted server name accepted repeated newlines, including a blank row, so a malformed multi-row header could match where the footer rules allow only a single wrap. Hold the name to one continuation row. refs #2868 --- distribution/agent-detection/claude.toml | 2 +- src/detect/manifest/tests.rs | 25 ++++++++++++++++++++++++ src/detect/manifests/claude.toml | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/distribution/agent-detection/claude.toml b/distribution/agent-detection/claude.toml index 2beb78b070..fb63005a80 100644 --- a/distribution/agent-detection/claude.toml +++ b/distribution/agent-detection/claude.toml @@ -125,7 +125,7 @@ visible_blocker = true # the trailing words may wrap in a narrow pane (#2868). regex = [ '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', - '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}](?:[^\n]|\n[ \t]*){1,120}?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', + '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}][^\n]*?(?:\n[ \t]*[^\n]*?)?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', ] all = [ { any = [ diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index e9acb17f1b..5c5b0cbd99 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -908,6 +908,31 @@ fn claude_wrapped_mcp_elicitation_dialog_is_blocked() { } } +#[test] +fn claude_mcp_elicitation_header_spans_one_wrap_only() { + // The quoted server name gets the same bounded tolerance as the footer: one + // continuation row, never a blank row and never a second wrap. + let rejected = [ + ( + "blank row inside the quoted name", + "MCP server \u{201C}notes\n\nserver\u{201D} requests your input\n\n\u{276F} Accept\n Decline\n\nEsc to cancel\n", + ), + ( + "name wrapped across three rows", + "MCP server \u{201C}a\nb\nc\u{201D} requests your input\n\n\u{276F} Accept\n Decline\n\nEsc to cancel\n", + ), + ]; + + for (label, screen) in rejected { + let result = osc_explain(Agent::Claude, screen, "", ""); + assert_ne!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("mcp_elicitation_prompt"), + "{label}" + ); + } +} + #[test] fn claude_wrapped_permission_prompt_keeps_its_own_rule() { // A narrow permission prompt already reported blocked, but only through the diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 2beb78b070..fb63005a80 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -125,7 +125,7 @@ visible_blocker = true # the trailing words may wrap in a narrow pane (#2868). regex = [ '(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel', - '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}](?:[^\n]|\n[ \t]*){1,120}?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', + '(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}][^\n]*?(?:\n[ \t]*[^\n]*?)?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$', ] all = [ { any = [