Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>?`) 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:**
Expand Down
36 changes: 26 additions & 10 deletions periscope/panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", …),
Expand Down Expand Up @@ -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 <repo> && …` 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 <repo>
&& …` 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
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests/fixtures/permission_dialogs/bash_requires_approval.txt
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions tests/routes/test_pane_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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),
Expand Down
44 changes: 43 additions & 1 deletion tests/test_panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"}),
],
Expand All @@ -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
Expand Down
Loading