From 0cacca9cdd0490ad8a183c8df6f2439242659976 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 11:24:09 -0400 Subject: [PATCH 1/5] 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 From d43f07fddb9acc9b4225ae0c053bab1266bd4b28 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 18:37:37 -0400 Subject: [PATCH 2/5] panes: a Bash block's bounds read as two _is_shallow rules, not index arithmetic; the docstring says where it starts and ends (THI-422) Co-Authored-By: Claude Opus 5.5 (1M context) --- periscope/panes.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/periscope/panes.py b/periscope/panes.py index f5e0f221..7be098c0 100644 --- a/periscope/panes.py +++ b/periscope/panes.py @@ -15,6 +15,7 @@ import hashlib import re import time +from itertools import dropwhile from periscope.config import INPUT_CTL_SESSION from periscope.tmux import _ANSI_SGR_RE, tmux @@ -558,6 +559,11 @@ def _bash_key(squashed: str) -> str: return "sha256:" + hashlib.sha256(squashed.encode()).hexdigest() +def _is_shallow(row: str) -> bool: + """Indented less than a Bash dialog's command and description.""" + return len(row) - len(row.lstrip()) < _COMMAND_INDENT + + def parse_permission_dialog(content: str) -> dict | None: """The permission dialog ending a capture, or None. Returns its `text` (header row to footer row), `header` ("Bash command", "Create file", …), @@ -623,12 +629,13 @@ def dialog_matches(parsed: dict | None, tool: str, match_key: str | None) -> boo and no other. No key, or a tool whose dialog isn't verified (NotebookEdit, 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+; 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. + Bash: the digest of the command + description block, gutter and + whitespace squashed, EQUALS the key. The block starts after a leading Tip + and its wrap, at the first row that isn't shallow, and ends before any + shallow `_TRAILING_CHROME` rows at its foot. 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 command/description boundary (`rm -rf / tmp/x` squashes as `rm -rf /tmp/x`), which the wrapped screen can't recover; the single-row rule and @@ -648,18 +655,12 @@ def dialog_matches(parsed: dict | None, tool: str, match_key: str | None) -> boo if tool == "Bash": if parsed["header"] != "Bash command": return False - body = parsed["body"] - 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)) - 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]) + rows = parsed["body"] + if rows and rows[0].lstrip().startswith("Tip:"): + rows = list(dropwhile(_is_shallow, rows)) + while rows and _is_shallow(rows[-1]) and rows[-1].strip() in _TRAILING_CHROME: + rows = rows[:-1] + block = "".join(_GUTTER_RE.sub("", row.lstrip()) for row in rows) 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) From 8fa6f01760c1bcf1056adeaad731628083ce23fe Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 18:38:40 -0400 Subject: [PATCH 3/5] test_panes: "This command requires approval" above the description stays in the block, pinning the end-only skip (THI-422) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_panes.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_panes.py b/tests/test_panes.py index 467c2cb8..34495873 100644 --- a/tests/test_panes.py +++ b/tests/test_panes.py @@ -1319,13 +1319,21 @@ def test_unknown_chrome_above_the_command_fails_closed(): 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. + # stays in the block and fails closed, and so does the known text above the + # description or at the command's indent: it's part of the request then. 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) + above = parse_permission_dialog( + capture.replace( + f" {_PY_DESCRIPTION}\n\n This command requires approval", + f" This command requires approval\n {_PY_DESCRIPTION}", + ) + ) + assert above is not None and above["body"][-1] == f" {_PY_DESCRIPTION}" + assert not dialog_matches(above, "Bash", key) deep = parse_permission_dialog( capture.replace(" This command requires approval", " This command requires approval") ) From e8ec21d85b49a04f4544425cdbc0c790062ab458 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 18:38:40 -0400 Subject: [PATCH 4/5] test_pane_actions: Allow through the route answers the "This command requires approval" capture (THI-422) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/routes/test_pane_actions.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/routes/test_pane_actions.py b/tests/routes/test_pane_actions.py index 429f136a..0ed09103 100644 --- a/tests/routes/test_pane_actions.py +++ b/tests/routes/test_pane_actions.py @@ -35,6 +35,13 @@ # The keys the hook stores for each fixture's request (pane_permission_hook.match_key). _BASH_KEY = hook.match_key("Bash", {"command": "touch probe.txt", "description": "Create empty probe.txt file"}) _MULTILINE_KEY = hook.match_key("Bash", {"command": "touch a.txt\ntouch b.txt", "description": "Create two files"}) +_PY_KEY = hook.match_key( + "Bash", + { + "command": 'python3 -c "open(\\"py.txt\\",\\"w\\").close()"', + "description": "Create empty py.txt file with Python", + }, +) _NOTES = "/tmp/probe/notes.md" @@ -109,6 +116,8 @@ def test_permission_sends_the_choices_key(client, pane, fresh_activity_db, choic ("bash.txt", "Bash", _BASH_KEY), ("bash_narrow.txt", "Bash", _BASH_KEY), ("bash_multiline.txt", "Bash", _MULTILINE_KEY), + # "This command requires approval" under the description is chrome. + ("bash_requires_approval.txt", "Bash", _PY_KEY), # The dialog names notes.md; the hook stored the absolute path. ("write.txt", "Write", _NOTES), ("edit.txt", "Edit", _NOTES), From cf9a83b42c47515f87f535e1d4e2375ea62f3276 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Fri, 30 Oct 2026 18:38:47 -0400 Subject: [PATCH 5/5] docs: the 2b spec's Bash match skips a trailing "This command requires approval" row, and the facts list records the row (THI-422) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/2026-10-26-native-notifications-2b-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-26-native-notifications-2b-design.md b/docs/superpowers/specs/2026-10-26-native-notifications-2b-design.md index 0d08b81e..7bdc7a46 100644 --- a/docs/superpowers/specs/2026-10-26-native-notifications-2b-design.md +++ b/docs/superpowers/specs/2026-10-26-native-notifications-2b-design.md @@ -32,6 +32,7 @@ These come from the installed CLI bundle, because the hooks docs don't publish t - **A silent hook doesn't change the dialog.** If the hook prints nothing and exits 0, the dialog shows unchanged. - **The hook runs before the dialog renders.** So its row exists by the time the pane reads as waiting. - **The default timeout is 600 s,** so the hook must return at once. +- **Some Bash dialogs add a row under the description.** `python3 -c …` gets `This command requires approval`, one column in (shallower than the command), between the description and the question. A real capture is `tests/fixtures/permission_dialogs/bash_requires_approval.txt`. - **The dialog's keys** (`1` = Yes, `Escape` = No and tell Claude what to do differently) aren't documented. PR 2 pins them with a fixture captured from a real dialog. ## PR 1: the shell and the plain buttons @@ -180,7 +181,7 @@ So: 3. the full-width rule sits above that, within 60 rows, with no scrollback marker (`⏺`, `✻`, `⎿`) in between; 4. the header is the first row under the rule. - **`panes.dialog_matches(parsed, tool, match_key)`** replaces the planned `dialog_shows`, and is header-bound: - - **Bash:** the header is `Bash command`, and the digest of the body **equals** the key. The body is taken after a leading `Tip:` and its wrap, with `│` gutters dropped, then squashed. Never a prefix or a substring. + - **Bash:** the header is `Bash command`, and the digest of the body **equals** the key. The body is taken after a leading `Tip:` and its wrap, and before a trailing `This command requires approval` row (`_TRAILING_CHROME`), with `│` gutters dropped, then squashed. Any other row shallower than the command, or that text anywhere but the block's end, stays in the body and fails closed. Never a prefix or a substring. - **Write** (`Create file` / `Overwrite file`) **and Edit** (`Edit file`): the path in the question (`Do you want to create / overwrite / make this edit to ?`) is a `/`-boundary suffix of the stored absolute path, so `notes.md` can't vouch for `old-notes.md`. The body (a preview, a diff) is never read: it can mention any file. - **Only `VERIFIABLE_TOOLS = ("Bash", "Edit", "Write")` can match.** The hook runs on bare `python3` and can't import `periscope`, so it and `panes.py` each define that tuple and the hidden-control class (`\u` escapes, never the raw characters), and tests assert the copies agree. - **Accepted limits:**