feat(dashboard): highlight new hostname in green on pipelock-block detail
When the operator opens a pipelock-block proposal in the detail
view (Enter / 'v'), append a green-coloured line:
→ would allow host: api.github.com
so what's actually about to change is obvious at a glance. The
full failed URL stays above the new line (the path is operator
context — pipelock can't enforce it, just records intent).
- _detail_lines now returns (text, attr) tuples; pipelock-block
appends the host-extract line tagged with the green color pair.
- _detail_view threaded the green_attr through from the main loop
(matches the new-proposal highlight pattern from earlier in this
PR).
- Best-effort URL parsing; unparseable payloads skip the highlight
line rather than render a misleading blank host.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -479,7 +479,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
elif key in (curses.KEY_UP, ord("k")):
|
elif key in (curses.KEY_UP, ord("k")):
|
||||||
selected = max(selected - 1, 0)
|
selected = max(selected - 1, 0)
|
||||||
elif key in (curses.KEY_ENTER, 10, 13, ord("v")):
|
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"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
approve(qp)
|
||||||
@@ -555,16 +555,21 @@ def _render(
|
|||||||
stdscr.refresh()
|
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
|
"""Render the full proposal: header, justification, proposed file
|
||||||
contents. Scrollable. Press q to return."""
|
contents. Scrollable. Press q to return."""
|
||||||
lines = _detail_lines(qp)
|
lines = _detail_lines(qp, green_attr=green_attr)
|
||||||
offset = 0
|
offset = 0
|
||||||
while True:
|
while True:
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
h, w = stdscr.getmaxyx()
|
h, w = stdscr.getmaxyx()
|
||||||
for i, line in enumerate(lines[offset:offset + h - 1]):
|
for i, (text, attr) in enumerate(lines[offset:offset + h - 1]):
|
||||||
stdscr.addnstr(i, 0, line, w - 1)
|
stdscr.addnstr(i, 0, text, w - 1, attr)
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(
|
||||||
h - 1, 0,
|
h - 1, 0,
|
||||||
"[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back",
|
"[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
|
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
|
p = qp.proposal
|
||||||
out = [
|
out: list[tuple[str, int]] = [
|
||||||
f"bottle: {p.bottle_slug}",
|
(f"bottle: {p.bottle_slug}", 0),
|
||||||
f"tool: {p.tool}",
|
(f"tool: {p.tool}", 0),
|
||||||
f"id: {p.id}",
|
(f"id: {p.id}", 0),
|
||||||
f"arrived: {p.arrival_timestamp}",
|
(f"arrived: {p.arrival_timestamp}", 0),
|
||||||
f"queue: {qp.queue_dir}",
|
(f"queue: {qp.queue_dir}", 0),
|
||||||
"",
|
("", 0),
|
||||||
"justification:",
|
("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([
|
out.extend([
|
||||||
"",
|
("", 0),
|
||||||
_proposed_payload_label(p.tool) + ":",
|
(_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
|
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:
|
def _proposed_payload_label(tool: str) -> str:
|
||||||
"""The detail-view section heading for the proposal's payload —
|
"""The detail-view section heading for the proposal's payload —
|
||||||
`proposed_file` is what the dataclass calls it, but for
|
`proposed_file` is what the dataclass calls it, but for
|
||||||
|
|||||||
@@ -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: <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()
|
||||||
Reference in New Issue
Block a user