feat(webui): read-only workflow policy & guardrail visibility (Closes #646)
Phase 3 child of the Web Console epic #631. Operators can now see the active workflow policy/guardrail configuration from the console instead of reading the repo tree. - webui/policy_inventory.py (new): redacted, machine-readable guardrail inventory. One row per major guardrail (role separation/RBAC, lease rules, worktree binding, merge confirmation, redaction, contamination, allocator policy, audit logging, mutation gating) with source pointers (file/module/doc) and a compact active projection from the existing safe policy accessors. Fail-soft per entry; whole payload run through console_redaction before emit; diff vs documented default where feasible. - webui/policy_views.py (new): HTML cards with source pointers, active config, and the documented-default diff; read-only page copy, no forms. - webui/app.py: register GET /policy and GET /api/v1/policy (additive). - webui/layout.py: add Policy nav item. - tests/test_webui_policy_visibility.py (new): guardrail presence + source pointers (AC1), redaction incl. planted-secret masking and scan_for_secrets (AC2/AC3), read-only page + no-mutation routes (AC4), fail-soft rendering. - docs/webui-local-dev.md: route table + read-only policy-visibility section. Read-only throughout; no policy editing, no gate-weakening toggle, secrets redacted. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -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("<form", text.lower())
|
||||
|
||||
def test_policy_html_has_no_secret_shapes(self):
|
||||
text = self.client.get("/policy").text
|
||||
self.assertEqual(console_redaction.scan_for_secrets(text), [])
|
||||
|
||||
def test_api_v1_policy_returns_inventory(self):
|
||||
response = self.client.get("/api/v1/policy")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["schema_version"], policy_inventory.SCHEMA_VERSION)
|
||||
self.assertTrue(data["read_only"])
|
||||
self.assertEqual(data["entry_count"], len(data["entries"]))
|
||||
self.assertEqual(set(data["categories"]), _EXPECTED_CATEGORIES)
|
||||
|
||||
def test_policy_is_read_only_no_post(self):
|
||||
# AC4 / non-goal: no mutation endpoint.
|
||||
response = self.client.post("/policy")
|
||||
self.assertIn(response.status_code, (404, 405))
|
||||
|
||||
def test_nav_links_policy(self):
|
||||
text = self.client.get("/").text
|
||||
self.assertIn('href="/policy"', text)
|
||||
|
||||
|
||||
class TestPolicyViewFailSoft(unittest.TestCase):
|
||||
def test_page_renders_when_a_projection_errors(self):
|
||||
snapshot = _snapshot([
|
||||
_entry("role_separation", "role_separation", error="active projection unavailable: boom"),
|
||||
_entry("redaction", "redaction", active={"redact_before_persist": True}),
|
||||
])
|
||||
page = render_policy_page(snapshot)
|
||||
# The errored guardrail surfaces its error; other guardrails still render.
|
||||
self.assertIn("Active value unavailable", page)
|
||||
self.assertIn("Redaction", page)
|
||||
self.assertIn("Workflow policy", page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user