Skip to content
Closed
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
21 changes: 18 additions & 3 deletions periscope/panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <repo> && …` share
their first 40 characters, and every `python3 - <<'EOF'` its first line.
Accepted limit: squashing loses whitespace-only differences and the
Expand All @@ -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)
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
36 changes: 35 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,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.
Expand Down Expand Up @@ -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"}),
],
Expand All @@ -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
Expand Down
Loading