From ab33337a94dcba319195bb7f72082cc551a7f026 Mon Sep 17 00:00:00 2001
From: jcwalker3
Date: Thu, 23 Jul 2026 03:20:56 -0500
Subject: [PATCH 1/3] 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)
---
docs/webui-local-dev.md | 15 +
tests/test_webui_policy_visibility.py | 222 +++++++++++++++
webui/app.py | 16 ++
webui/layout.py | 1 +
webui/policy_inventory.py | 387 ++++++++++++++++++++++++++
webui/policy_views.py | 104 +++++++
6 files changed, 745 insertions(+)
create mode 100644 tests/test_webui_policy_visibility.py
create mode 100644 webui/policy_inventory.py
create mode 100644 webui/policy_views.py
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("
"
)
- if not entry.active:
+ if not active:
return "No live projection for this guardrail.
"
- pretty = json.dumps(entry.active, indent=2, sort_keys=True, default=str)
+ pretty = json.dumps(active, indent=2, sort_keys=True, default=str)
return f"{html.escape(pretty)}"
-def _diff_block(entry: PolicyEntry) -> str:
- if entry.diff is None:
- if entry.documented_default is None:
+def _diff_block(entry: dict[str, Any] | PolicyEntry) -> str:
+ diff = entry.get("diff") if isinstance(entry, dict) else entry.diff
+ documented_default = entry.get("documented_default") if isinstance(entry, dict) else entry.documented_default
+ if diff is None:
+ if documented_default is None:
return "Diff vs documented default: not feasible (no declared default).
"
return "Diff vs documented default: unavailable.
"
- status = entry.diff.get("status", "unknown")
+ status = str(diff.get("status") if isinstance(diff, dict) else "unknown")
badge = "badge-claimed" if status == "matches_documented_default" else "badge-blocked"
rows = []
- for key, cell in (entry.diff.get("checked") or {}).items():
- marker = "✓" if cell.get("matches") else "✗"
- rows.append(
- ""
- f"{html.escape(str(key))} | "
- f"{html.escape(str(cell.get('expected')))} | "
- f"{html.escape(str(cell.get('observed')))} | "
- f"{marker} | "
- "
"
- )
+ checked = diff.get("checked") if isinstance(diff, dict) else {}
+ if isinstance(checked, dict):
+ for key, cell in checked.items():
+ cell_dict = cell if isinstance(cell, dict) else {}
+ marker = "✓" if cell_dict.get("matches") else "✗"
+ rows.append(
+ ""
+ f"{html.escape(str(key))} | "
+ f"{html.escape(str(cell_dict.get('expected')))} | "
+ f"{html.escape(str(cell_dict.get('observed')))} | "
+ f"{marker} | "
+ "
"
+ )
table = ""
if rows:
table = (
@@ -64,13 +88,18 @@ def _diff_block(entry: PolicyEntry) -> str:
)
-def _entry_card(entry: PolicyEntry) -> str:
- sources = "".join(_source_pointer(s) for s in entry.sources)
+def _entry_card(entry: dict[str, Any] | PolicyEntry) -> str:
+ title = str(entry.get("title") if isinstance(entry, dict) else entry.title)
+ category = str(entry.get("category") if isinstance(entry, dict) else entry.category)
+ summary = str(entry.get("summary") if isinstance(entry, dict) else entry.summary)
+ sources_data = entry.get("sources", []) if isinstance(entry, dict) else entry.sources
+
+ sources = "".join(_source_pointer(s) for s in sources_data)
return (
""
- f"
{html.escape(entry.title)} "
- f"{html.escape(entry.category)}
"
- f"
{html.escape(entry.summary)}
"
+ f"
{html.escape(title)} "
+ f"{html.escape(category)}
"
+ f"
{html.escape(summary)}
"
"
Source pointers
"
f"
"
"
Active configuration
"
@@ -80,23 +109,35 @@ def _entry_card(entry: PolicyEntry) -> str:
)
-def render_policy_page(snapshot: PolicyInventorySnapshot) -> str:
- categories = ", ".join(html.escape(c) for c in snapshot.categories) or "none"
- cards = "".join(_entry_card(e) for e in snapshot.entries)
+def render_policy_page(snapshot: PolicyInventorySnapshot | dict[str, Any]) -> str:
+ if isinstance(snapshot, PolicyInventorySnapshot):
+ payload = snapshot_to_dict(snapshot)
+ elif isinstance(snapshot, dict):
+ payload = console_redaction.redact_payload(snapshot)
+ else:
+ payload = {}
+
+ categories_list = payload.get("categories") or []
+ categories = ", ".join(html.escape(str(c)) for c in categories_list) or "none"
+ entries_list = payload.get("entries") or []
+ cards = "".join(_entry_card(e) for e in entries_list)
build_errors = ""
- if snapshot.build_errors:
+ errors_list = payload.get("build_errors") or []
+ if errors_list:
items = "".join(
- f"
{html.escape(err)}" for err in snapshot.build_errors
+ f"
{html.escape(str(err))}" for err in errors_list
)
build_errors = (
"
Some guardrails could not be built:"
f"
"
)
+ note = str(payload.get("note") or "")
+ schema_version = payload.get("schema_version") or 1
return (
"
Workflow policy & guardrails
"
- f"
{html.escape(snapshot.note)}
"
- f"
Schema v{snapshot.schema_version} · "
- f"{len(snapshot.entries)} guardrails · categories: {categories}
"
+ f"
{html.escape(note)}
"
+ f"
Schema v{schema_version} · "
+ f"{len(entries_list)} guardrails · categories: {categories}
"
f"{build_errors}"
f"{cards}"
"
This page is read-only. It reports enforced policy "
--
2.43.7
From 87f7b5385de150bf456dcc7cc5963a3b0dc05236 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Fri, 24 Jul 2026 08:32:07 -0400
Subject: [PATCH 3/3] fix(webui): tighten PR #856 remediation docs and nav
assertions (#646)
Clarify that Policy is live (not a Phase 1 placeholder) in shell docs and
nav module docstring. Strengthen the Policy nav graduation test to assert
status=live and absence of the nav-stub CSS class.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/webui-local-dev.md | 10 +++++-----
tests/test_webui_policy_visibility.py | 9 +++++++--
webui/nav.py | 2 +-
3 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 8b4e68d..7972b16 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -258,7 +258,7 @@ The view never edits policy and exposes no gate-weakening toggle.
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, 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.
@@ -268,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`,
-`/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
index 9d3a055..d07c43d 100644
--- a/tests/test_webui_policy_visibility.py
+++ b/tests/test_webui_policy_visibility.py
@@ -214,11 +214,16 @@ class TestPolicyRoutes(unittest.TestCase):
self.assertIn(response.status_code, (404, 405))
def test_nav_links_policy(self):
- from webui.nav import STUB_PAGES
+ # Policy is graduated live: not in STUB_PAGES and not labeled ·stub in nav.
+ from webui.nav import STUB_PAGES, iter_nav_items
+
self.assertNotIn("/policy", STUB_PAGES)
+ policy_items = [i for i in iter_nav_items() if i.href == "/policy"]
+ self.assertEqual(len(policy_items), 1)
+ self.assertEqual(policy_items[0].status, "live")
text = self.client.get("/").text
self.assertIn('href="/policy">Policy', text)
- self.assertNotIn('href="/policy">Policy (stub)', text)
+ self.assertNotIn('href="/policy" class="nav-stub"', text)
class TestPolicyViewFailSoft(unittest.TestCase):
diff --git a/webui/nav.py b/webui/nav.py
index 3847bb5..4c65b53 100644
--- a/webui/nav.py
+++ b/webui/nav.py
@@ -5,7 +5,7 @@ the ``webui/app.py`` route table stay aligned with epic #631. Read-only: every
destination is a GET view or a Phase 1 placeholder. No mutation links.
Nav groups follow the #631 Phase 1 information architecture: Health, Traffic,
-Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and
+Runtime/Sessions, Projects, Inventory, Timeline, Policy (live via #646), and
Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and
backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder
instead of a 404.
--
2.43.7