diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index f5799b2..353879b 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -64,6 +64,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) | @@ -153,6 +155,19 @@ 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. + ## Deployment boundary (#435) MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** diff --git a/tests/test_webui_policy_visibility.py b/tests/test_webui_policy_visibility.py new file mode 100644 index 0000000..ff02a9d --- /dev/null +++ b/tests/test_webui_policy_visibility.py @@ -0,0 +1,222 @@ +"""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), []) + + +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("