diff options
| author | caner-akca <94343893+caner-akca@users.noreply.github.com> | 2026-09-07 02:27:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-07 02:27:04 +0300 |
| commit | 4b5e9bda239a0b6903889062d756424578e94691 (patch) | |
| tree | 95f44b7e4b675683edcc9a093e048ec1c10d656e /src/detect | |
| parent | c7b79294e28fe7c835691a25597fabf226ecfc20 (diff) | |
fix: match claude bash permission prompts at every cursor position (#3615)
Claude marks the selected option with "❯", but bash_permission_prompt did not allow that prefix on numbered branches and did not cover the three-option "don't ask again" layout. Match both two- and three-option Bash approval prompts regardless of the selected option.
refs #2650
Diffstat (limited to 'src/detect')
| -rw-r--r-- | src/detect/manifest/tests.rs | 102 | ||||
| -rw-r--r-- | src/detect/manifests/claude.toml | 18 |
2 files changed, 117 insertions, 3 deletions
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 00730a44..e1073f3c 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -739,6 +739,108 @@ fn claude_blocker_with_background_shell_remains_blocked() { } #[test] +fn claude_bash_prompt_with_dont_ask_again_option_matches_bash_rule() { + // Captured from a Bash approval prompt at its resting cursor position. The + // "don't ask again" choice pushes No to option 3, so the only cursor-free + // option line is one bash_permission_prompt used not to cover, which let + // the narrower generic_permission_prompt claim the prompt instead (#2650). + let screen = concat!( + "──────────────────────────────────────────────────────────────── +", + " Bash command + +", + " curl -sS -o /tmp/probe.html https://example.com +", + " Download example.com to /tmp/probe.html + +", + " This command requires approval + +", + " Do you want to proceed? +", + " ❯ 1. Yes +", + " 2. Yes, and don't ask again for: curl * +", + " 3. No + +", + " Esc to cancel · Tab to amend · ctrl+e to explain +", + ); + let result = osc_explain(Agent::Claude, screen, "", ""); + + assert_eq!(result.state, AgentState::Blocked); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("bash_permission_prompt") + ); + assert!(result.visible_blocker); +} + +#[test] +fn claude_permission_prompt_matches_at_every_cursor_position() { + // The selected option carries "❯", so no option branch may assume its line + // is cursor-free. Walk the cursor across both option layouts. + let layouts: [&[&str]; 2] = [ + &[" ❯ 1. Yes", " 2. No"], + &[ + " ❯ 1. Yes", + " 2. Yes, and don't ask again for: curl *", + " 3. No", + ], + ]; + + for layout in layouts { + for selected in 0..layout.len() { + let options: Vec<String> = layout + .iter() + .enumerate() + .map(|(index, line)| { + let bare = line.trim_start().trim_start_matches('❯').trim_start(); + if index == selected { + format!(" ❯ {bare}") + } else { + format!(" {bare}") + } + }) + .collect(); + let screen = format!( + concat!( + "──────────────────────────────────────────────────────────────── +", + " Bash command + +", + " curl -sS https://example.com + +", + " Do you want to proceed? +", + "{} + +", + " Esc to cancel · Tab to amend · ctrl+e to explain +", + ), + options.join("\n"), + ); + let result = osc_explain(Agent::Claude, &screen, "", ""); + + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("bash_permission_prompt"), + "{options:?} selected={selected}" + ); + assert_eq!(result.state, AgentState::Blocked, "selected={selected}"); + assert!(result.visible_blocker, "selected={selected}"); + } + } +} + +#[test] fn claude_osc_title_braille_prefix_is_working() { // "⠂" is U+2802, in the braille block U+2800-U+28FF let result = osc_explain(Agent::Claude, "", "⠂ project", ""); diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 2a26a334..16482764 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]] @@ -162,8 +162,20 @@ any = [ { contains = ["tab to amend"] }, { contains = ["ctrl+e to explain"] }, ] +# Claude marks the selected option with "❯", so every option branch has to allow +# that prefix. Numbered branches that omit it only match options the cursor has +# moved away from, which left the resting layout below unmatched here (#2650): +# ❯ 1. Yes / 2. Yes, and don't ask again for: <cmd> / 3. No +# Cover both the two-option and "don't ask again" three-option shapes so this +# rule, not the narrower generic_permission_prompt, claims Bash approvals. all = [ - { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, + { any = [ + { line_regex = ['(?i)^\s*❯?\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] }, + ] }, ] [[rules]] |
