Address review blockers (review #555): - Graduate /policy nav item out of STUB_PAGES to live nav in webui/nav.py - Update docs/webui-local-dev.md to list /policy as a live surface and remove stub copy - Run console_redaction on HTML emit path in webui/policy_views.py before rendering HTML - Add planted-secret HTML redaction test and nav graduation test in tests/test_webui_policy_visibility.py
This commit is contained in:
@@ -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) |
|
| `/sessions` | Phase 1 shell stub — session inventory (backed by #636) |
|
||||||
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
||||||
| `/timeline` | Phase 1 shell stub — workflow event timeline |
|
| `/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 |
|
| `/insights` | Phase 1 shell stub — operational insights placeholder |
|
||||||
|
|
||||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
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
|
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
|
single nav-config module, `webui/nav.py`. Nav groups follow the epic #631
|
||||||
Phase 1 information architecture: **Health, Traffic, Runtime/Sessions,
|
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
|
(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one
|
||||||
place so the layout and the route table cannot drift.
|
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.
|
privileged action controls are present in the Phase 1 shell.
|
||||||
|
|
||||||
Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`,
|
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
|
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
|
surfaces are backed by #636). Mutating methods on stub routes still fail closed
|
||||||
with `read-only-mvp`.
|
with `read-only-mvp`.
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ class TestPolicyRedaction(unittest.TestCase):
|
|||||||
self.assertNotIn("abcd1234efgh5678", blob)
|
self.assertNotIn("abcd1234efgh5678", blob)
|
||||||
self.assertEqual(console_redaction.scan_for_secrets(payload), [])
|
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):
|
class TestPolicyRoutes(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -201,8 +214,11 @@ class TestPolicyRoutes(unittest.TestCase):
|
|||||||
self.assertIn(response.status_code, (404, 405))
|
self.assertIn(response.status_code, (404, 405))
|
||||||
|
|
||||||
def test_nav_links_policy(self):
|
def test_nav_links_policy(self):
|
||||||
|
from webui.nav import STUB_PAGES
|
||||||
|
self.assertNotIn("/policy", STUB_PAGES)
|
||||||
text = self.client.get("/").text
|
text = self.client.get("/").text
|
||||||
self.assertIn('href="/policy"', text)
|
self.assertIn('href="/policy">Policy</a>', text)
|
||||||
|
self.assertNotIn('href="/policy">Policy</a> <span class="muted">(stub)</span>', text)
|
||||||
|
|
||||||
|
|
||||||
class TestPolicyViewFailSoft(unittest.TestCase):
|
class TestPolicyViewFailSoft(unittest.TestCase):
|
||||||
|
|||||||
+1
-5
@@ -60,7 +60,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
|||||||
NavItem("/timeline", "Timeline", "stub"),
|
NavItem("/timeline", "Timeline", "stub"),
|
||||||
)),
|
)),
|
||||||
NavGroup("Policy", (
|
NavGroup("Policy", (
|
||||||
NavItem("/policy", "Policy", "stub"),
|
NavItem("/policy", "Policy"),
|
||||||
NavItem("/prompts", "Prompts"),
|
NavItem("/prompts", "Prompts"),
|
||||||
)),
|
)),
|
||||||
NavGroup("Insights", (
|
NavGroup("Insights", (
|
||||||
@@ -88,10 +88,6 @@ STUB_PAGES: dict[str, tuple[str, str]] = {
|
|||||||
"Timeline",
|
"Timeline",
|
||||||
"Workflow event timeline across issues and PRs. A later Phase 1 surface.",
|
"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": (
|
||||||
"Insights",
|
"Insights",
|
||||||
"Aggregate operational insights and trends. Placeholder until a later "
|
"Aggregate operational insights and trends. Placeholder until a later "
|
||||||
|
|||||||
+78
-37
@@ -4,48 +4,72 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from webui.policy_inventory import PolicyEntry, PolicyInventorySnapshot
|
from webui import console_redaction
|
||||||
|
from webui.policy_inventory import (
|
||||||
|
PolicyEntry,
|
||||||
def _source_pointer(source) -> str:
|
PolicyInventorySnapshot,
|
||||||
path = source.path
|
SourcePointer,
|
||||||
if source.anchor:
|
snapshot_to_dict,
|
||||||
path = f"{path}#{source.anchor}"
|
|
||||||
return (
|
|
||||||
f"<li>{html.escape(source.label)} — "
|
|
||||||
f"<code>{html.escape(path)}</code> "
|
|
||||||
f"<span class='muted'>({html.escape(source.kind)})</span></li>"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _active_block(entry: PolicyEntry) -> str:
|
def _source_pointer(source: dict[str, Any] | SourcePointer) -> str:
|
||||||
if entry.error:
|
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"<li>{html.escape(label)} — "
|
||||||
|
f"<code>{html.escape(path)}</code> "
|
||||||
|
f"<span class='muted'>({html.escape(kind)})</span></li>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
"<p class='muted'><strong>Active value unavailable:</strong> "
|
"<p class='muted'><strong>Active value unavailable:</strong> "
|
||||||
f"{html.escape(entry.error)}</p>"
|
f"{html.escape(error)}</p>"
|
||||||
)
|
)
|
||||||
if not entry.active:
|
if not active:
|
||||||
return "<p class='muted'>No live projection for this guardrail.</p>"
|
return "<p class='muted'>No live projection for this guardrail.</p>"
|
||||||
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"<pre class='prompt-text'>{html.escape(pretty)}</pre>"
|
return f"<pre class='prompt-text'>{html.escape(pretty)}</pre>"
|
||||||
|
|
||||||
|
|
||||||
def _diff_block(entry: PolicyEntry) -> str:
|
def _diff_block(entry: dict[str, Any] | PolicyEntry) -> str:
|
||||||
if entry.diff is None:
|
diff = entry.get("diff") if isinstance(entry, dict) else entry.diff
|
||||||
if entry.documented_default is None:
|
documented_default = entry.get("documented_default") if isinstance(entry, dict) else entry.documented_default
|
||||||
|
if diff is None:
|
||||||
|
if documented_default is None:
|
||||||
return "<p class='muted'>Diff vs documented default: not feasible (no declared default).</p>"
|
return "<p class='muted'>Diff vs documented default: not feasible (no declared default).</p>"
|
||||||
return "<p class='muted'>Diff vs documented default: unavailable.</p>"
|
return "<p class='muted'>Diff vs documented default: unavailable.</p>"
|
||||||
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"
|
badge = "badge-claimed" if status == "matches_documented_default" else "badge-blocked"
|
||||||
rows = []
|
rows = []
|
||||||
for key, cell in (entry.diff.get("checked") or {}).items():
|
checked = diff.get("checked") if isinstance(diff, dict) else {}
|
||||||
marker = "✓" if cell.get("matches") 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(
|
rows.append(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td><code>{html.escape(str(key))}</code></td>"
|
f"<td><code>{html.escape(str(key))}</code></td>"
|
||||||
f"<td><code>{html.escape(str(cell.get('expected')))}</code></td>"
|
f"<td><code>{html.escape(str(cell_dict.get('expected')))}</code></td>"
|
||||||
f"<td><code>{html.escape(str(cell.get('observed')))}</code></td>"
|
f"<td><code>{html.escape(str(cell_dict.get('observed')))}</code></td>"
|
||||||
f"<td>{marker}</td>"
|
f"<td>{marker}</td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
@@ -64,13 +88,18 @@ def _diff_block(entry: PolicyEntry) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _entry_card(entry: PolicyEntry) -> str:
|
def _entry_card(entry: dict[str, Any] | PolicyEntry) -> str:
|
||||||
sources = "".join(_source_pointer(s) for s in entry.sources)
|
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 (
|
return (
|
||||||
"<div class='prompt-card'>"
|
"<div class='prompt-card'>"
|
||||||
f"<h3>{html.escape(entry.title)} "
|
f"<h3>{html.escape(title)} "
|
||||||
f"<span class='badge'>{html.escape(entry.category)}</span></h3>"
|
f"<span class='badge'>{html.escape(category)}</span></h3>"
|
||||||
f"<p>{html.escape(entry.summary)}</p>"
|
f"<p>{html.escape(summary)}</p>"
|
||||||
"<p class='meta'><strong>Source pointers</strong></p>"
|
"<p class='meta'><strong>Source pointers</strong></p>"
|
||||||
f"<ul>{sources}</ul>"
|
f"<ul>{sources}</ul>"
|
||||||
"<p class='meta'><strong>Active configuration</strong></p>"
|
"<p class='meta'><strong>Active configuration</strong></p>"
|
||||||
@@ -80,23 +109,35 @@ def _entry_card(entry: PolicyEntry) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_policy_page(snapshot: PolicyInventorySnapshot) -> str:
|
def render_policy_page(snapshot: PolicyInventorySnapshot | dict[str, Any]) -> str:
|
||||||
categories = ", ".join(html.escape(c) for c in snapshot.categories) or "none"
|
if isinstance(snapshot, PolicyInventorySnapshot):
|
||||||
cards = "".join(_entry_card(e) for e in snapshot.entries)
|
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 = ""
|
build_errors = ""
|
||||||
if snapshot.build_errors:
|
errors_list = payload.get("build_errors") or []
|
||||||
|
if errors_list:
|
||||||
items = "".join(
|
items = "".join(
|
||||||
f"<li>{html.escape(err)}</li>" for err in snapshot.build_errors
|
f"<li>{html.escape(str(err))}</li>" for err in errors_list
|
||||||
)
|
)
|
||||||
build_errors = (
|
build_errors = (
|
||||||
"<div class='stub'><p><strong>Some guardrails could not be built:"
|
"<div class='stub'><p><strong>Some guardrails could not be built:"
|
||||||
f"</strong></p><ul>{items}</ul></div>"
|
f"</strong></p><ul>{items}</ul></div>"
|
||||||
)
|
)
|
||||||
|
note = str(payload.get("note") or "")
|
||||||
|
schema_version = payload.get("schema_version") or 1
|
||||||
return (
|
return (
|
||||||
"<h2>Workflow policy & guardrails</h2>"
|
"<h2>Workflow policy & guardrails</h2>"
|
||||||
f"<p class='muted'>{html.escape(snapshot.note)}</p>"
|
f"<p class='muted'>{html.escape(note)}</p>"
|
||||||
f"<p class='meta'>Schema v{snapshot.schema_version} · "
|
f"<p class='meta'>Schema v{schema_version} · "
|
||||||
f"{len(snapshot.entries)} guardrails · categories: {categories}</p>"
|
f"{len(entries_list)} guardrails · categories: {categories}</p>"
|
||||||
f"{build_errors}"
|
f"{build_errors}"
|
||||||
f"{cards}"
|
f"{cards}"
|
||||||
"<p class='muted'>This page is read-only. It reports enforced policy "
|
"<p class='muted'>This page is read-only. It reports enforced policy "
|
||||||
|
|||||||
Reference in New Issue
Block a user