From 0cacca9cdd0490ad8a183c8df6f2439242659976 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 11:24:09 -0400 Subject: [PATCH] panes: a Bash dialog's "This command requires approval" row is chrome, not the command, so Allow matches python3 -c prompts (THI-422) Co-Authored-By: Claude Opus 5.5 (1M context) --- periscope/panes.py | 21 +++++++++-- .../bash_requires_approval.txt | 26 ++++++++++++++ tests/test_panes.py | 36 ++++++++++++++++++- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/permission_dialogs/bash_requires_approval.txt diff --git a/periscope/panes.py b/periscope/panes.py index 625a5cd1..f5e0f221 100644 --- a/periscope/panes.py +++ b/periscope/panes.py @@ -339,6 +339,13 @@ def recency_stamps_for(target: str) -> dict: # second shallow row that no `Tip:` test would catch. Without a leading Tip, # a shallow row above the command is unknown chrome and stays in the block. _COMMAND_INDENT = 3 +# Rows Claude draws below a Bash dialog's description, shallower than the +# command: "This command requires approval" sits there for `python3 -c …` +# (tests/fixtures/permission_dialogs/bash_requires_approval.txt), and counted +# into the block it made every such Allow a 409. Only these exact rows, only +# at the block's end, are skipped; any other shallow row there stays in the +# block, so unknown chrome still fails closed. +_TRAILING_CHROME = frozenset({"This command requires approval"}) # A multi-line command's `│ ` gutter; a blank command line leaves a bare `│`. _GUTTER_RE = re.compile(r"^│(?=\s|$)") # Mirrors pane_permission_hook._HIDDEN_RE (a test pins the two): the hook @@ -617,8 +624,9 @@ def dialog_matches(parsed: dict | None, tool: str, match_key: str | None) -> boo WebFetch, MCP tools, …), never matches. Bash: the digest of the whole command + description block (after a - leading Tip and its wrap, up to the first row indented 3+), gutter and - whitespace squashed, EQUALS the key. Never a prefix or a substring: `git + leading Tip and its wrap, up to the first row indented 3+; before a known + trailing chrome row, `_TRAILING_CHROME`), gutter and whitespace squashed, + EQUALS the key. Never a prefix or a substring: `git push origin main` vouched for `… --force`, two `cd && …` share their first 40 characters, and every `python3 - <<'EOF'` its first line. Accepted limit: squashing loses whitespace-only differences and the @@ -644,7 +652,14 @@ def dialog_matches(parsed: dict | None, tool: str, match_key: str | None) -> boo start = 0 if body and body[0].lstrip().startswith("Tip:"): start = next((i for i, r in enumerate(body) if len(r) - len(r.lstrip()) >= _COMMAND_INDENT), len(body)) - block = "".join(_GUTTER_RE.sub("", row.lstrip()) for row in body[start:]) + end = len(body) + while ( + end > start + and body[end - 1].strip() in _TRAILING_CHROME + and len(body[end - 1]) - len(body[end - 1].lstrip()) < _COMMAND_INDENT + ): + end -= 1 + block = "".join(_GUTTER_RE.sub("", row.lstrip()) for row in body[start:end]) return _bash_key(_squash(block)) == match_key shown = parsed["shown_path"] return parsed["header"] in _FILE_HEADERS[tool] and bool(shown) and ("/" + match_key).endswith("/" + shown) diff --git a/tests/fixtures/permission_dialogs/bash_requires_approval.txt b/tests/fixtures/permission_dialogs/bash_requires_approval.txt new file mode 100644 index 00000000..424446cc --- /dev/null +++ b/tests/fixtures/permission_dialogs/bash_requires_approval.txt @@ -0,0 +1,26 @@ + ▐▛███▛█ Claude Code v2.1.280 +▝▜██████▀ Opus 5.5 (1M context) · Claude Max + ▝▝ ▝▝ /tmp/probe + + +❯ Use the Bash tool to run exactly this command and nothing else: python3 -c "open(\"py.txt\",\"w\").close()" + +⏺ Bash(python3 -c "open(\"py.txt\",\"w\").close()") + ⎿ Waiting… + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Bash command + Tip: auto mode handles these prompts for you — choose "switch to auto mode" below + + python3 -c "open(\"py.txt\",\"w\").close()" + Create empty py.txt file with Python + + This command requires approval + + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and don’t ask again for: python3 * + 3. Yes, and switch to auto mode · auto mode handles these prompts for you + 4. No + + Esc to cancel · Tab to amend diff --git a/tests/test_panes.py b/tests/test_panes.py index 4917f3a7..467c2cb8 100644 --- a/tests/test_panes.py +++ b/tests/test_panes.py @@ -1155,12 +1155,19 @@ def _bash_key(command: str, description: str = "") -> str: return _sha(re.sub(r"\s+", "", command) + re.sub(r"\s+", "", description)) +# The request bash_requires_approval.txt shows (its command as Claude passed it). +_PY_COMMAND = 'python3 -c "open(\\"py.txt\\",\\"w\\").close()"' +_PY_DESCRIPTION = "Create empty py.txt file with Python" + # Each capture with the key of the request it shows. _FIXTURE_KEYS = [ ("bash.txt", "Bash", _sha("touchprobe.txtCreateemptyprobe.txtfile")), # The same request in a 70-column pane, its Tip wrapped onto a second row. ("bash_narrow.txt", "Bash", _sha("touchprobe.txtCreateemptyprobe.txtfile")), ("bash_multiline.txt", "Bash", _sha("toucha.txttouchb.txtCreatetwofiles")), + # `python3 -c …` gets a "This command requires approval" row under its + # description, which is chrome, not part of the request. + ("bash_requires_approval.txt", "Bash", _bash_key(_PY_COMMAND, _PY_DESCRIPTION)), ("write.txt", "Write", "/tmp/probe/notes.md"), ("edit.txt", "Edit", "/tmp/probe/notes.md"), ] @@ -1190,6 +1197,13 @@ def _bash_key(command: str, description: str = "") -> str: "Do you want to proceed?", None, ), + ( + "bash_requires_approval.txt", + "Bash command", + [_TIP, " " + _PY_COMMAND, " " + _PY_DESCRIPTION, " This command requires approval"], + "Do you want to proceed?", + None, + ), ( "write.txt", "Create file", @@ -1302,6 +1316,23 @@ def test_unknown_chrome_above_the_command_fails_closed(): assert dialog_matches(parse_permission_dialog(_dialog(name)), "Bash", key) +def test_only_known_chrome_below_the_command_is_skipped(): + # "This command requires approval" under the description is skipped (the + # real capture matches its own key, above). Any other shallow row there + # stays in the block and fails closed, and the known text at the command's + # indent is part of the request, not chrome. + key = _bash_key(_PY_COMMAND, _PY_DESCRIPTION) + capture = _dialog("bash_requires_approval.txt") + unknown = parse_permission_dialog(capture.replace(" This command requires approval", " ⚠ Sandbox disabled")) + assert unknown is not None + assert not dialog_matches(unknown, "Bash", key) + deep = parse_permission_dialog( + capture.replace(" This command requires approval", " This command requires approval") + ) + assert not dialog_matches(deep, "Bash", key) + assert dialog_matches(deep, "Bash", _bash_key(_PY_COMMAND, _PY_DESCRIPTION + "This command requires approval")) + + def test_a_bash_dialog_matches_across_a_wrap_and_a_bare_gutter(): # A long row wraps mid-word onto an indented row; a blank line in a # multi-line command leaves a bare `│`. Neither may break the match. @@ -1491,6 +1522,7 @@ def test_hidden_controls_agree_with_the_hook(): ("bash.txt", "Bash", {"command": "touch probe.txt", "description": "Create empty probe.txt file"}), ("bash_narrow.txt", "Bash", {"command": "touch probe.txt", "description": "Create empty probe.txt file"}), ("bash_multiline.txt", "Bash", {"command": "touch a.txt\ntouch b.txt", "description": "Create two files"}), + ("bash_requires_approval.txt", "Bash", {"command": _PY_COMMAND, "description": _PY_DESCRIPTION}), ("write.txt", "Write", {"file_path": "/tmp/probe/notes.md", "content": "hello\n"}), ("edit.txt", "Edit", {"file_path": "/tmp/probe/notes.md", "old_string": "hello", "new_string": "goodbye"}), ], @@ -1508,7 +1540,9 @@ def test_a_hidden_control_in_a_command_does_not_break_the_match(): assert dialog_matches(parse_permission_dialog(screen), "Bash", key) -@pytest.mark.parametrize("name", ["bash.txt", "bash_multiline.txt", "bash_narrow.txt", "edit.txt", "write.txt"]) +@pytest.mark.parametrize( + "name", ["bash.txt", "bash_multiline.txt", "bash_narrow.txt", "bash_requires_approval.txt", "edit.txt", "write.txt"] +) def test_parse_pane_reads_a_permission_dialog_as_needs_input(name): # THI-435: its footer, "Esc to cancel · Tab to amend", is outside # NEEDS_INPUT_FOOTER_RE, and the dialog hides the composer and status