feat(dashboard): wire capability-block approval to real apply (PRD 0016)
Phase 3 of PRD 0016. dashboard.approve() now dispatches to apply_capability_change when the proposal is a capability-block: cred-proxy-block → apply_routes_change pipelock-block → apply_allowlist_change capability-block → apply_capability_change (new in PRD 0016) CapabilityApplyError joins the ApplyError tuple, so the TUI's key handlers catch it the same way and surface failures in the status line. After a successful capability-block apply, dashboard archives the proposal+response itself — the supervise sidecar was torn down by apply_capability_change and can't archive its own queue file. Without this, dashboard.discover_pending would keep surfacing the resolved proposal forever. No audit log for capability-block per PRD 0013 — its record lives in the per-bottle Dockerfile state + transcript snapshot. Tests stub apply_capability_change at the dashboard module level, add TestCapabilityApplyWiring (call wiring, failure-keeps-pending, no-audit invariant, archive-after-apply), and update TestApproveReject to stub the capability path too so it stays docker-independent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .. import supervise as _supervise
|
||||
from ..backend.docker.capability_apply import (
|
||||
CapabilityApplyError,
|
||||
apply_capability_change,
|
||||
)
|
||||
from ..backend.docker.cred_proxy_apply import (
|
||||
CredProxyApplyError,
|
||||
apply_routes_change,
|
||||
@@ -45,6 +49,7 @@ from ..supervise import (
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_CRED_PROXY_BLOCK,
|
||||
TOOL_PIPELOCK_BLOCK,
|
||||
archive_proposal,
|
||||
list_pending_proposals,
|
||||
render_diff,
|
||||
write_audit_entry,
|
||||
@@ -56,7 +61,7 @@ from ._common import PROG
|
||||
# Errors any remediation engine may raise. Caught by the TUI key
|
||||
# handlers and surfaced in the status line so a failed apply keeps
|
||||
# the proposal pending rather than crashing curses.
|
||||
ApplyError = (CredProxyApplyError, PipelockApplyError)
|
||||
ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError)
|
||||
|
||||
|
||||
# --- Discovery -------------------------------------------------------------
|
||||
@@ -155,9 +160,10 @@ def approve(
|
||||
diff_before, diff_after = apply_allowlist_change(
|
||||
qp.proposal.bottle_slug, file_to_apply,
|
||||
)
|
||||
# capability-block remediation lands in PRD 0016; until then
|
||||
# it stays a no-op approval and the audit (none for capability)
|
||||
# is skipped.
|
||||
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
diff_before, diff_after = apply_capability_change(
|
||||
qp.proposal.bottle_slug, file_to_apply,
|
||||
)
|
||||
|
||||
response = Response(
|
||||
proposal_id=qp.proposal.id,
|
||||
@@ -170,6 +176,12 @@ def approve(
|
||||
qp, action=status, notes=notes,
|
||||
diff_before=diff_before, diff_after=diff_after,
|
||||
)
|
||||
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
# The supervise sidecar was torn down by apply_capability_change,
|
||||
# so it can't archive its own proposal+response. Archive here so
|
||||
# dashboard.discover_pending stops surfacing the resolved
|
||||
# proposal forever.
|
||||
archive_proposal(qp.queue_dir, qp.proposal.id)
|
||||
|
||||
|
||||
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
|
||||
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle import supervise
|
||||
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
|
||||
from claude_bottle.backend.docker.cred_proxy_apply import CredProxyApplyError
|
||||
from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError
|
||||
from claude_bottle.cli import dashboard
|
||||
@@ -118,6 +119,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
self._setup_fake_home()
|
||||
self._original_apply_routes = dashboard.apply_routes_change
|
||||
self._original_apply_allowlist = dashboard.apply_allowlist_change
|
||||
self._original_apply_capability = dashboard.apply_capability_change
|
||||
# Default stubs: succeed with deterministic before/after so the
|
||||
# audit log shows a non-empty diff.
|
||||
dashboard.apply_routes_change = lambda slug, content: (
|
||||
@@ -126,10 +128,14 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
dashboard.apply_allowlist_change = lambda slug, content: (
|
||||
"old.example\n", content,
|
||||
)
|
||||
dashboard.apply_capability_change = lambda slug, content: (
|
||||
"FROM old\n", content,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
dashboard.apply_routes_change = self._original_apply_routes
|
||||
dashboard.apply_allowlist_change = self._original_apply_allowlist
|
||||
dashboard.apply_capability_change = self._original_apply_capability
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _enqueue(self, tool: str = TOOL_CRED_PROXY_BLOCK):
|
||||
@@ -333,6 +339,75 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||
self.assertIn("+new.example", entries[0].diff)
|
||||
|
||||
|
||||
class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||
"""PRD 0016 Phase 3: approve() on a capability-block proposal
|
||||
calls apply_capability_change, archives the proposal afterward
|
||||
(sidecar is gone so it can't archive itself), and writes no
|
||||
audit entry (capability-block has none per PRD 0013)."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
self._original = dashboard.apply_capability_change
|
||||
|
||||
def tearDown(self):
|
||||
dashboard.apply_capability_change = self._original
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"):
|
||||
p = Proposal.new(
|
||||
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
|
||||
proposed_file=proposed,
|
||||
justification="need ripgrep",
|
||||
current_file_hash=sha256_hex(proposed),
|
||||
now=FIXED,
|
||||
)
|
||||
qdir = supervise.queue_dir_for_slug("dev")
|
||||
qdir.mkdir(parents=True, exist_ok=True)
|
||||
supervise.write_proposal(qdir, p)
|
||||
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
|
||||
|
||||
def test_capability_block_calls_apply_with_proposed_file(self):
|
||||
calls = []
|
||||
dashboard.apply_capability_change = lambda slug, content: (
|
||||
calls.append((slug, content)) or ("FROM old\n", content)
|
||||
)
|
||||
qp = self._enqueue_capability("FROM bookworm\n")
|
||||
dashboard.approve(qp)
|
||||
self.assertEqual([("dev", "FROM bookworm\n")], calls)
|
||||
|
||||
def test_apply_failure_blocks_response_and_keeps_pending(self):
|
||||
dashboard.apply_capability_change = lambda slug, content: (_ for _ in ()).throw(
|
||||
CapabilityApplyError("teardown failed")
|
||||
)
|
||||
qp = self._enqueue_capability()
|
||||
with self.assertRaises(CapabilityApplyError):
|
||||
dashboard.approve(qp)
|
||||
self.assertEqual(
|
||||
[qp.proposal.id],
|
||||
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
|
||||
)
|
||||
|
||||
def test_no_audit_log_for_capability(self):
|
||||
dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content)
|
||||
qp = self._enqueue_capability()
|
||||
dashboard.approve(qp)
|
||||
# capability-block has no audit log per PRD 0013 — its record
|
||||
# lives in the per-bottle Dockerfile + transcript state.
|
||||
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
|
||||
self.assertEqual([], read_audit_entries("pipelock", "dev"))
|
||||
|
||||
def test_proposal_archived_after_apply(self):
|
||||
dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content)
|
||||
qp = self._enqueue_capability()
|
||||
dashboard.approve(qp)
|
||||
# Sidecar would normally archive after delivering the response,
|
||||
# but it's gone by then. The dashboard archives so
|
||||
# discover_pending stops surfacing the resolved proposal.
|
||||
self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir))
|
||||
processed = list((qp.queue_dir / "processed").glob("*.json"))
|
||||
self.assertEqual(2, len(processed))
|
||||
|
||||
|
||||
class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
|
||||
"""PRD 0014 Phase 4: operator-initiated routes edit (not gated
|
||||
on a pending proposal)."""
|
||||
|
||||
Reference in New Issue
Block a user