diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 747815d..8880b5a 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -479,7 +479,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: elif key in (curses.KEY_UP, ord("k")): selected = max(selected - 1, 0) elif key in (curses.KEY_ENTER, 10, 13, ord("v")): - _detail_view(stdscr, qp) + _detail_view(stdscr, qp, green_attr=green_attr) elif key == ord("a"): try: approve(qp) @@ -555,16 +555,21 @@ def _render( stdscr.refresh() -def _detail_view(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> None: +def _detail_view( + stdscr: "curses._CursesWindow", + qp: QueuedProposal, + *, + green_attr: int = 0, +) -> None: """Render the full proposal: header, justification, proposed file contents. Scrollable. Press q to return.""" - lines = _detail_lines(qp) + lines = _detail_lines(qp, green_attr=green_attr) offset = 0 while True: stdscr.erase() h, w = stdscr.getmaxyx() - for i, line in enumerate(lines[offset:offset + h - 1]): - stdscr.addnstr(i, 0, line, w - 1) + for i, (text, attr) in enumerate(lines[offset:offset + h - 1]): + stdscr.addnstr(i, 0, text, w - 1, attr) stdscr.addnstr( h - 1, 0, "[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back", @@ -603,26 +608,51 @@ def _detail_view(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> None: return -def _detail_lines(qp: QueuedProposal) -> list[str]: +def _detail_lines( + qp: QueuedProposal, + *, + green_attr: int = 0, +) -> list[tuple[str, int]]: + """Return the detail-view body as (text, curses-attr) tuples. + Most lines are plain (attr=0); pipelock-block proposals append + a green "→ would allow host: ..." line so the operator sees at + a glance which hostname will land in pipelock's allowlist if + they hit approve. The URL itself is shown above for context.""" p = qp.proposal - out = [ - f"bottle: {p.bottle_slug}", - f"tool: {p.tool}", - f"id: {p.id}", - f"arrived: {p.arrival_timestamp}", - f"queue: {qp.queue_dir}", - "", - "justification:", + out: list[tuple[str, int]] = [ + (f"bottle: {p.bottle_slug}", 0), + (f"tool: {p.tool}", 0), + (f"id: {p.id}", 0), + (f"arrived: {p.arrival_timestamp}", 0), + (f"queue: {qp.queue_dir}", 0), + ("", 0), + ("justification:", 0), ] - out.extend(" " + line for line in p.justification.splitlines() or [""]) + out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) out.extend([ - "", - _proposed_payload_label(p.tool) + ":", + ("", 0), + (_proposed_payload_label(p.tool) + ":", 0), ]) - out.extend(p.proposed_file.splitlines() or [""]) + out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) + if p.tool == TOOL_PIPELOCK_BLOCK: + host = _failed_url_host(p.proposed_file) + if host: + out.append(("", 0)) + out.append((f"→ would allow host: {host}", green_attr)) return out +def _failed_url_host(url: str) -> str: + """Best-effort hostname extraction from a pipelock-block proposal's + failed_url payload. Returns empty string on unparseable input — + callers handle empty as "nothing to highlight".""" + import urllib.parse + try: + return urllib.parse.urlsplit(url.strip()).hostname or "" + except ValueError: + return "" + + def _proposed_payload_label(tool: str) -> str: """The detail-view section heading for the proposal's payload — `proposed_file` is what the dataclass calls it, but for diff --git a/tests/unit/test_dashboard_detail_lines.py b/tests/unit/test_dashboard_detail_lines.py new file mode 100644 index 0000000..99a1c87 --- /dev/null +++ b/tests/unit/test_dashboard_detail_lines.py @@ -0,0 +1,102 @@ +"""Unit: dashboard's detail-view line builder. + +_detail_lines returns (text, attr) tuples. Most are plain; for +pipelock-block proposals it appends a "→ would allow host: " +line tagged with the green attr so the operator sees at a glance +which hostname will land in pipelock's allowlist on approval.""" + +import unittest + +from claude_bottle import supervise +from claude_bottle.cli import dashboard +from claude_bottle.supervise import ( + Proposal, + TOOL_CAPABILITY_BLOCK, + TOOL_CRED_PROXY_BLOCK, + TOOL_PIPELOCK_BLOCK, + sha256_hex, +) + + +def _qp(tool: str, payload: str) -> dashboard.QueuedProposal: + from datetime import datetime, timezone + from pathlib import Path + p = Proposal.new( + bottle_slug="dev", + tool=tool, + proposed_file=payload, + justification="needs", + current_file_hash=sha256_hex(payload), + now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), + ) + return dashboard.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) + + +class TestPipelockHostHighlight(unittest.TestCase): + GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through + + def test_appends_green_host_line_for_pipelock_block(self): + lines = dashboard._detail_lines( + _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"), + green_attr=self.GREEN, + ) + host_lines = [ + (text, attr) for text, attr in lines + if text.startswith("→ would allow host:") + ] + self.assertEqual(1, len(host_lines)) + text, attr = host_lines[0] + self.assertEqual("→ would allow host: api.github.com", text) + self.assertEqual(self.GREEN, attr) + + def test_no_host_line_for_cred_proxy_block(self): + lines = dashboard._detail_lines( + _qp(TOOL_CRED_PROXY_BLOCK, '{"routes": []}'), + green_attr=self.GREEN, + ) + self.assertFalse(any("would allow host" in t for t, _ in lines)) + + def test_no_host_line_for_capability_block(self): + lines = dashboard._detail_lines( + _qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"), + green_attr=self.GREEN, + ) + self.assertFalse(any("would allow host" in t for t, _ in lines)) + + def test_skips_host_line_when_url_unparseable(self): + # Shouldn't happen in production — supervise_server validates + # the URL before queuing — but if a malformed payload ever + # reaches the dashboard, don't add a misleading host line. + lines = dashboard._detail_lines( + _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), + green_attr=self.GREEN, + ) + self.assertFalse(any("would allow host" in t for t, _ in lines)) + + def test_no_green_attr_passed_still_renders_host(self): + # Even without color support (green_attr=0), the host line + # is still present — it just won't be coloured. + lines = dashboard._detail_lines( + _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"), + green_attr=0, + ) + host_lines = [t for t, _ in lines if t.startswith("→ would allow host:")] + self.assertEqual(["→ would allow host: api.github.com"], host_lines) + + +class TestFailedUrlHost(unittest.TestCase): + def test_extracts_hostname(self): + self.assertEqual( + "api.github.com", + dashboard._failed_url_host("https://api.github.com/repos/foo"), + ) + + def test_returns_empty_for_unparseable(self): + self.assertEqual("", dashboard._failed_url_host("not a url")) + + def test_returns_empty_for_url_without_host(self): + self.assertEqual("", dashboard._failed_url_host("https:///nohost")) + + +if __name__ == "__main__": + unittest.main()