diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 15c00e4..cf4bfad 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -66,6 +66,8 @@ status, onboarding checklist state, and the fail-closed error payloads (#635). | `/api/prompts` | JSON prompt export with workflow hashes | | `/runtime` | MCP runtime health and stale detection (#430) | | `/api/runtime` | JSON runtime health export | +| `/policy` | Workflow policy and guardrail configuration visibility (#646) | +| `/api/v1/policy` | Versioned JSON guardrail inventory (redacted, read-only) | | `/audit` | Report audit paste + validator preview (#431) | | `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) | | `/worktrees` | Worktree hygiene dashboard (#432) | @@ -78,7 +80,6 @@ status, onboarding checklist state, and the fail-closed error payloads (#635). | `/sessions` | Phase 1 shell stub — session inventory (backed by #636) | | `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) | | `/timeline` | Phase 1 shell stub — workflow event timeline | -| `/policy` | Phase 1 shell stub — capability/role policy placeholder | | `/insights` | Phase 1 shell stub — operational insights placeholder | Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with @@ -239,12 +240,25 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## Policy & guardrail visibility (#646) + +`/policy` (HTML) and `/api/v1/policy` (JSON) surface a **read-only** projection +of the major workflow guardrails — role separation/RBAC, lease lifecycle, +author worktree binding, merge confirmation, secret redaction, contamination +containment, allocator policy, audit logging, and mutation gating. Each entry +carries source pointers to the file/module/doc that owns it, a compact active +value derived from the existing safe policy accessors, and — where a documented +default is declared — a diff of active vs documented. The whole payload is run +through the console redaction pass before it is emitted, so a planted or +accidental secret degrades to the placeholder rather than reaching a client. +The view never edits policy and exposes no gate-weakening toggle. + ## Application shell — Phase 1 (#638) The console shell (`webui/layout.py`) renders a grouped navigation driven by a single nav-config module, `webui/nav.py`. Nav groups follow the epic #631 Phase 1 information architecture: **Health, Traffic, Runtime/Sessions, -Projects, Inventory, Timeline, Policy** (placeholder), and **Insights** +Projects, Inventory, Timeline, Policy** (live via #646), and **Insights** (placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one place so the layout and the route table cannot drift. @@ -254,10 +268,10 @@ a **mode: read-only** badge — plus a **Docs** link to this document. No privileged action controls are present in the Phase 1 shell. Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`, -`/policy`, `/insights`) resolve to graceful read-only stub pages instead of -404s; their backing views land in later child issues of #631 (the inventory -surfaces are backed by #636). Mutating methods on stub routes still fail closed -with `read-only-mvp`. +`/insights`) resolve to graceful read-only stub pages instead of 404s; their +backing views land in later child issues of #631 (the inventory surfaces are +backed by #636). `/policy` is a live read-only surface (#646), not a stub. +Mutating methods on stub routes still fail closed with `read-only-mvp`. ## System-health dashboard (#639) diff --git a/tests/test_webui_policy_visibility.py b/tests/test_webui_policy_visibility.py new file mode 100644 index 0000000..d07c43d --- /dev/null +++ b/tests/test_webui_policy_visibility.py @@ -0,0 +1,243 @@ +"""Tests for the read-only workflow policy/guardrail visibility view (#646). + +Covers issue #646 acceptance criteria: + +1. Console lists major guardrails with source pointers. +2. Secrets redacted. +3. Tests ensure sample secrets never appear. +4. Docs explain read-only nature (asserted here for the page copy; the doc + itself is covered by inspection). +""" + +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui import console_redaction +from webui import policy_inventory +from webui.app import create_app +from webui.policy_inventory import ( + PolicyEntry, + PolicyInventorySnapshot, + SourcePointer, + load_policy_inventory, + snapshot_to_dict, +) +from webui.policy_views import render_policy_page + + +def _entry(key, category, *, active=None, error=None): + return PolicyEntry( + key=key, + title=key.replace("_", " ").title(), + category=category, + summary=f"summary for {key}", + sources=(SourcePointer("src", f"{key}.py", "module"),), + active=active, + documented_default=None, + diff=None, + error=error, + ) + + +def _snapshot(entries): + return PolicyInventorySnapshot( + schema_version=1, + read_only=True, + note="read-only projection", + entries=tuple(entries), + categories=tuple(dict.fromkeys(e.category for e in entries)), + build_errors=(), + ) + +# The guardrail categories issue #646 names as in-scope. +_EXPECTED_CATEGORIES = { + "role_separation", + "lease_rules", + "worktree_rules", + "merge_confirmation", + "redaction", + "contamination", + "allocator_policy", + "audit_logging", + "mutation_gating", +} + + +class TestPolicyInventoryModel(unittest.TestCase): + def test_major_guardrails_present(self): + snapshot = load_policy_inventory() + categories = {e.category for e in snapshot.entries} + self.assertEqual(_EXPECTED_CATEGORIES, categories) + self.assertGreaterEqual(len(snapshot.entries), len(_EXPECTED_CATEGORIES)) + + def test_every_guardrail_has_source_pointers(self): + # AC1: source attribution (file/module/doc) for every guardrail. + snapshot = load_policy_inventory() + for entry in snapshot.entries: + with self.subTest(entry=entry.key): + self.assertTrue(entry.sources, "guardrail must carry source pointers") + for source in entry.sources: + self.assertTrue(source.path) + self.assertIn(source.kind, {"module", "doc", "script", "config"}) + + def test_diff_reported_where_documented_default_declared(self): + snapshot = load_policy_inventory() + checked_any = False + for entry in snapshot.entries: + if entry.documented_default is None: + self.assertIsNone(entry.diff) + continue + checked_any = True + self.assertIsNotNone(entry.diff) + self.assertEqual( + entry.diff["status"], + "matches_documented_default", + f"{entry.key} drifted from its documented default: {entry.diff}", + ) + self.assertTrue(checked_any, "at least one guardrail should declare a default") + + def test_live_projections_populate_active(self): + snapshot = load_policy_inventory() + by_key = {e.key: e for e in snapshot.entries} + for key in ("role_separation", "redaction", "audit_logging"): + self.assertIsNone(by_key[key].error, f"{key} projection failed") + self.assertIsInstance(by_key[key].active, dict) + + def test_build_entry_is_fail_soft_on_projection_error(self): + def _boom(): + raise RuntimeError("projection exploded") + + row = ( + "redaction", + "Secret redaction", + "redaction", + "summary", + (SourcePointer("x", "webui/console_redaction.py", "module"),), + _boom, + {"redact_before_persist": True}, + ) + entry = policy_inventory._build_entry(row) + self.assertIsNone(entry.active) + self.assertIsNotNone(entry.error) + self.assertEqual(entry.diff["status"], "active_unavailable") + + +class TestPolicyRedaction(unittest.TestCase): + def test_real_snapshot_has_no_secret_shapes(self): + # AC3: the real emitted payload never carries a known secret shape. + payload = snapshot_to_dict(load_policy_inventory()) + self.assertEqual(console_redaction.scan_for_secrets(payload), []) + + def test_planted_keychain_secret_is_redacted(self): + # AC2/AC3: a secret planted in an active projection is masked before emit. + snapshot = _snapshot([ + _entry( + "redaction", + "redaction", + active={"leaked": "keychain:prgs-author-super-secret", "roles": ["author"]}, + ) + ]) + payload = snapshot_to_dict(snapshot) + blob = json.dumps(payload) + self.assertNotIn("keychain:prgs-author-super-secret", blob) + self.assertEqual(console_redaction.scan_for_secrets(payload), []) + + def test_planted_credential_assignment_is_redacted(self): + snapshot = _snapshot([ + _entry( + "audit_logging", + "audit_logging", + active={"leaked": "token=abcd1234efgh5678", "append_only": True}, + ) + ]) + payload = snapshot_to_dict(snapshot) + blob = json.dumps(payload) + self.assertNotIn("abcd1234efgh5678", blob) + self.assertEqual(console_redaction.scan_for_secrets(payload), []) + + def test_planted_secret_is_redacted_in_html_emit(self): + # AC2/AC3: HTML emit path runs redaction before rendering HTML cards. + snapshot = _snapshot([ + _entry( + "redaction", + "redaction", + active={"leaked": "keychain:prgs-author-super-secret", "roles": ["author"]}, + ) + ]) + html_output = render_policy_page(snapshot) + self.assertNotIn("keychain:prgs-author-super-secret", html_output) + self.assertEqual(console_redaction.scan_for_secrets(html_output), []) + + +class TestPolicyRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_policy_html_lists_guardrails_with_sources(self): + response = self.client.get("/policy") + self.assertEqual(response.status_code, 200) + text = response.text + self.assertIn("Workflow policy", text) + self.assertIn("Role separation and RBAC", text) + self.assertIn("Source pointers", text) + self.assertIn("task_capability_map.py", text) + self.assertIn("docs/safety-model.md", text) + + def test_policy_html_states_read_only(self): + # AC4: the page explains its read-only nature. + text = self.client.get("/policy").text + self.assertIn("read-only", text.lower()) + self.assertNotIn("