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:** diff --git a/periscope/panes.py b/periscope/panes.py index 625a5cd1..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 @@ -339,6 +340,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 @@ -551,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", …), @@ -616,11 +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+), 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 @@ -640,11 +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)) - block = "".join(_GUTTER_RE.sub("", row.lstrip()) for row in body[start:]) + 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) 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/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), diff --git a/tests/test_panes.py b/tests/test_panes.py index 4917f3a7..34495873 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,31 @@ 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 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") + ) + 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 +1530,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 +1548,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