feat(webui): gated action framework (#434)
Register future write actions with task_capability_map metadata, mutation-ledger previews, and fail-closed execution in the read-only MVP. Adds /actions routes, disabled UI buttons, and tests proving ungated attempts cannot invoke mutations. Closes #434
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""HTML views for gated action previews (#434)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
|
||||
from webui.gated_actions import ActionRegistry, GatedAction, preview_action
|
||||
from webui.layout import render_page
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return html.escape(text, quote=True)
|
||||
|
||||
|
||||
def _ledger_block(action: GatedAction) -> str:
|
||||
sample_params: dict[str, object]
|
||||
if action.action_id == "create_issue":
|
||||
sample_params = {"title": "Example issue"}
|
||||
elif action.action_id == "create_pr":
|
||||
sample_params = {"head": "feat/issue-99-example", "base": "master"}
|
||||
elif action.action_id == "delete_branch":
|
||||
sample_params = {"branch_name": "feat/issue-99-example"}
|
||||
elif action.action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
|
||||
sample_params = {"pr_number": 99}
|
||||
else:
|
||||
sample_params = {"issue_number": 99}
|
||||
|
||||
preview = preview_action(action.action_id, **sample_params)
|
||||
ledger_json = json.dumps(preview.get("mutation_ledger", []), indent=2)
|
||||
return (
|
||||
"<details class='action-preview'>"
|
||||
"<summary>Preview mutation ledger</summary>"
|
||||
f"<pre class='prompt-text'>{_escape(ledger_json)}</pre>"
|
||||
f"<p class='muted meta'>Sample params: "
|
||||
f"<code>{_escape(json.dumps(sample_params))}</code></p>"
|
||||
f"<p><a href=\"/api/actions/{_escape(action.action_id)}/preview?"
|
||||
f"{_preview_query(sample_params)}\">JSON preview</a></p>"
|
||||
"</details>"
|
||||
)
|
||||
|
||||
|
||||
def _preview_query(params: dict[str, object]) -> str:
|
||||
parts = []
|
||||
for key, value in params.items():
|
||||
parts.append(f"{key}={_escape(str(value))}")
|
||||
return "&".join(parts)
|
||||
|
||||
|
||||
def _action_card(action: GatedAction) -> str:
|
||||
disabled_attr = " disabled" if not action.enabled else ""
|
||||
return (
|
||||
"<article class='prompt-card action-card'>"
|
||||
f"<h3>{_escape(action.label)}</h3>"
|
||||
f"<p class='muted'>{_escape(action.description)}</p>"
|
||||
"<p class='meta'>"
|
||||
f"Task <code>{_escape(action.task_key)}</code> · "
|
||||
f"MCP <code>{_escape(action.mcp_tool)}</code><br>"
|
||||
f"Requires <code>{_escape(action.required_permission)}</code> "
|
||||
f"({_escape(action.required_role)} profile)</p>"
|
||||
f"<button type='button' class='copy-btn action-btn'{disabled_attr} "
|
||||
f"aria-disabled='true' title='Disabled in read-only MVP'>"
|
||||
f"{_escape(action.label)}</button>"
|
||||
f"{_ledger_block(action)}"
|
||||
"</article>"
|
||||
)
|
||||
|
||||
|
||||
ACTION_PAGE_STYLES = """
|
||||
<style>
|
||||
.action-card .action-btn[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
}
|
||||
.action-preview { margin-top: 0.75rem; }
|
||||
.action-preview summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def render_actions_page(registry: ActionRegistry) -> str:
|
||||
cards = "".join(_action_card(action) for action in registry.actions)
|
||||
body = (
|
||||
"<h2>Gated actions</h2>"
|
||||
"<p>Future write actions are registered here but remain "
|
||||
"<strong>disabled</strong> in the read-only MVP. Each action "
|
||||
"declares the MCP capability and profile role from "
|
||||
"<code>task_capability_map</code>; previews show the mutation "
|
||||
"ledger before any execution path ships.</p>"
|
||||
f"<p class='meta'>{len(registry.actions)} registered actions · "
|
||||
"execution: fail-closed</p>"
|
||||
f"{cards}"
|
||||
"<p><a href=\"/api/actions\">JSON registry</a></p>"
|
||||
f"{ACTION_PAGE_STYLES}"
|
||||
)
|
||||
return render_page(title="Actions", body_html=body)
|
||||
Reference in New Issue
Block a user