diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index cad3507..8b4e68d 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -80,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 @@ -259,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** (placeholder), and **Insights** +Projects, Inventory, Timeline, Policy, and Insights** (placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one place so the layout and the route table cannot drift. @@ -269,7 +268,7 @@ 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 +`/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`. diff --git a/tests/test_webui_policy_visibility.py b/tests/test_webui_policy_visibility.py index ff02a9d..9d3a055 100644 --- a/tests/test_webui_policy_visibility.py +++ b/tests/test_webui_policy_visibility.py @@ -161,6 +161,19 @@ class TestPolicyRedaction(unittest.TestCase): 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): @@ -201,8 +214,11 @@ class TestPolicyRoutes(unittest.TestCase): self.assertIn(response.status_code, (404, 405)) def test_nav_links_policy(self): + from webui.nav import STUB_PAGES + self.assertNotIn("/policy", STUB_PAGES) text = self.client.get("/").text - self.assertIn('href="/policy"', text) + self.assertIn('href="/policy">Policy', text) + self.assertNotIn('href="/policy">Policy (stub)', text) class TestPolicyViewFailSoft(unittest.TestCase): diff --git a/webui/nav.py b/webui/nav.py index c24643d..3847bb5 100644 --- a/webui/nav.py +++ b/webui/nav.py @@ -60,7 +60,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = ( NavItem("/timeline", "Timeline", "stub"), )), NavGroup("Policy", ( - NavItem("/policy", "Policy", "stub"), + NavItem("/policy", "Policy"), NavItem("/prompts", "Prompts"), )), NavGroup("Insights", ( @@ -88,10 +88,6 @@ STUB_PAGES: dict[str, tuple[str, str]] = { "Timeline", "Workflow event timeline across issues and PRs. A later Phase 1 surface.", ), - "/policy": ( - "Policy", - "Capability and role policy surface. Placeholder until a later phase.", - ), "/insights": ( "Insights", "Aggregate operational insights and trends. Placeholder until a later " diff --git a/webui/policy_views.py b/webui/policy_views.py index b46cfbf..fcfffc3 100644 --- a/webui/policy_views.py +++ b/webui/policy_views.py @@ -4,51 +4,75 @@ from __future__ import annotations import html import json +from typing import Any -from webui.policy_inventory import PolicyEntry, PolicyInventorySnapshot +from webui import console_redaction +from webui.policy_inventory import ( + PolicyEntry, + PolicyInventorySnapshot, + SourcePointer, + snapshot_to_dict, +) -def _source_pointer(source) -> str: - path = source.path - if source.anchor: - path = f"{path}#{source.anchor}" +def _source_pointer(source: dict[str, Any] | SourcePointer) -> str: + if isinstance(source, dict): + label = str(source.get("label") or "") + path = str(source.get("path") or "") + anchor = source.get("anchor") + kind = str(source.get("kind") or "") + else: + label = source.label + path = source.path + anchor = source.anchor + kind = source.kind + + if anchor: + path = f"{path}#{anchor}" return ( - f"
  • {html.escape(source.label)} — " + f"
  • {html.escape(label)} — " f"{html.escape(path)} " - f"({html.escape(source.kind)})
  • " + f"({html.escape(kind)})" ) -def _active_block(entry: PolicyEntry) -> str: - if entry.error: +def _active_block(entry: dict[str, Any] | PolicyEntry) -> str: + error = entry.get("error") if isinstance(entry, dict) else entry.error + active = entry.get("active") if isinstance(entry, dict) else entry.active + if error: return ( "

    Active value unavailable: " - f"{html.escape(entry.error)}

    " + f"{html.escape(error)}

    " ) - 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 "