From 84b841c72765ee1249cc6b9e4957ce1bf51ae606 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 15:34:39 -0400 Subject: [PATCH] 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 --- docs/webui-local-dev.md | 18 ++- tests/test_webui_gated_actions.py | 128 +++++++++++++++++++ tests/test_webui_skeleton.py | 11 +- webui/app.py | 50 ++++++++ webui/gated_action_views.py | 101 +++++++++++++++ webui/gated_actions.py | 201 ++++++++++++++++++++++++++++++ webui/layout.py | 1 + 7 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 tests/test_webui_gated_actions.py create mode 100644 webui/gated_action_views.py create mode 100644 webui/gated_actions.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 0a5b935..92e10b8 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -47,9 +47,12 @@ Optional environment variables: | `/audit` | Stub — report audit paste (#431) | | `/worktrees` | Stub — hygiene dashboard (#432) | | `/leases` | Stub — lease visibility (#433) | +| `/actions` | Gated write-action registry — all disabled in MVP (#434) | +| `/api/actions` | JSON action registry with capability metadata | +| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) | -All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with -`read-only-mvp`. +All routes are GET-only except registered POST handlers, which still return +`405` with `read-only-mvp` until write paths ship. ## Project registry (#427) @@ -82,8 +85,17 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched, If credentials are missing or the fetch fails, the page shows an explicit error instead of an empty queue (fail closed). +## Gated actions (#434) + +`/actions` registers future write actions (claim, comment, review, merge, +delete branch, create PR/issue). Each entry declares the MCP tool, required +permission, and profile role from `task_capability_map.py` — aligned with +`gitea_resolve_task_capability`. Buttons are disabled; previews always render +a mutation ledger. Direct `attempt_action` calls fail closed without invoking +MCP tools. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_gated_actions.py b/tests/test_webui_gated_actions.py new file mode 100644 index 0000000..87d4086 --- /dev/null +++ b/tests/test_webui_gated_actions.py @@ -0,0 +1,128 @@ +"""Tests for web UI gated action framework (#434).""" +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from task_capability_map import TASK_CAPABILITY_MAP +from webui.app import create_app +from webui.gated_actions import ( + attempt_action, + build_action_registry, + load_action_registry, + preview_action, +) + + +class TestGatedActionRegistry(unittest.TestCase): + def test_all_actions_disabled_by_default(self): + registry = build_action_registry() + self.assertGreater(len(registry.actions), 0) + for action in registry.actions: + with self.subTest(action=action.action_id): + self.assertFalse(action.enabled) + + def test_actions_align_with_task_capability_map(self): + registry = build_action_registry() + for action in registry.actions: + with self.subTest(task=action.task_key): + self.assertIn(action.task_key, TASK_CAPABILITY_MAP) + self.assertEqual( + action.required_permission, + TASK_CAPABILITY_MAP[action.task_key]["permission"], + ) + self.assertEqual( + action.required_role, + TASK_CAPABILITY_MAP[action.task_key]["role"], + ) + + def test_preview_includes_mutation_ledger(self): + preview = preview_action("merge_pr", pr_number=42) + self.assertNotIn("error", preview) + ledger = preview["mutation_ledger"] + self.assertEqual(len(ledger), 1) + self.assertEqual(ledger[0]["mcp_tool"], "gitea_merge_pr") + self.assertEqual(ledger[0]["permission"], "gitea.pr.merge") + self.assertIn("42", ledger[0]["target"]) + + def test_attempt_fails_closed_without_mcp_calls(self): + registry = build_action_registry() + with patch("webui.gated_actions._MVP_ACTIONS_ENABLED", False): + for action in registry.actions: + with self.subTest(action=action.action_id): + result = attempt_action(action.action_id, issue_number=1) + self.assertFalse(result["success"]) + self.assertEqual(result["error"], "action_disabled") + self.assertIn("preview", result) + + def test_attempt_module_has_no_mcp_imports(self): + """Ungated execution path must not import MCP server tools.""" + import webui.gated_actions as ga + + source_path = Path(ga.__file__).resolve() + source = source_path.read_text(encoding="utf-8") + self.assertNotIn("mcp_server", source) + result = attempt_action("merge_pr", pr_number=99) + self.assertFalse(result["success"]) + + def test_unknown_action_fails_closed(self): + result = attempt_action("nonexistent_action") + self.assertFalse(result["success"]) + self.assertEqual(result["error"], "unknown_action") + + +class TestGatedActionRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_actions_page_renders_disabled_buttons(self): + response = self.client.get("/actions") + self.assertEqual(response.status_code, 200) + self.assertIn("Gated actions", response.text) + self.assertIn("disabled", response.text.lower()) + self.assertIn("mutation ledger", response.text.lower()) + self.assertIn("aria-disabled", response.text) + self.assertIn(" disabled", response.text) + + def test_api_actions_registry(self): + response = self.client.get("/api/actions") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["mvp_actions_enabled"]) + action_ids = {item["action_id"] for item in data["actions"]} + self.assertIn("merge_pr", action_ids) + self.assertIn("claim_issue", action_ids) + for item in data["actions"]: + self.assertFalse(item["enabled"]) + + def test_api_action_preview_json(self): + response = self.client.get( + "/api/actions/review_pr/preview?pr_number=12" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["task_key"], "review_pr") + self.assertEqual(data["required_role"], "reviewer") + self.assertEqual(len(data["mutation_ledger"]), 1) + + def test_post_attempt_fails_closed(self): + response = self.client.post( + "/api/actions/merge_pr/attempt", + json={"pr_number": 1}, + ) + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertFalse(data["success"]) + self.assertEqual(data["error"], "action_disabled") + + def test_nav_includes_actions_link(self): + text = self.client.get("/").text + self.assertIn('href="/actions"', text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 5dd23a1..2960fc5 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -51,6 +51,11 @@ class TestWebuiSkeleton(unittest.TestCase): with self.subTest(path=path): self.assertEqual(self.client.get(path).status_code, 200) + def test_actions_is_implemented(self): + response = self.client.get("/actions") + self.assertEqual(response.status_code, 200) + self.assertIn("Gated actions", response.text) + def test_post_is_rejected(self): response = self.client.post("/health") self.assertEqual(response.status_code, 405) @@ -62,10 +67,12 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Live queue", response.text) def test_nav_links_on_all_pages(self): - for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"): + for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions"): with self.subTest(path=path): text = self.client.get(path).text - for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"): + for href in ( + "/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions", + ): self.assertIn(f'href="{href}"', text) diff --git a/webui/app.py b/webui/app.py index a97d1ad..4557f7b 100644 --- a/webui/app.py +++ b/webui/app.py @@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page +from webui.gated_actions import attempt_action, load_action_registry, preview_action +from webui.gated_action_views import render_actions_page from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_views import render_queue_page @@ -41,6 +43,7 @@ async def home(_request: Request) -> HTMLResponse: "
  • Audit — final-report paste and validator preview (#431)
  • " "
  • Worktrees — branch hygiene dashboard (#432)
  • " "
  • Leases — collision and lease visibility (#433)
  • " + "
  • Actions — gated write-action framework (#434)
  • " "" ) return HTMLResponse(render_page(title="Home", body_html=body)) @@ -147,6 +150,41 @@ async def leases(_request: Request) -> HTMLResponse: ) +async def actions(_request: Request) -> HTMLResponse: + registry = load_action_registry() + return HTMLResponse(render_actions_page(registry)) + + +async def api_actions(_request: Request) -> JSONResponse: + return JSONResponse(load_action_registry().to_dict()) + + +async def api_action_preview(request: Request) -> JSONResponse: + action_id = request.path_params["action_id"] + params = dict(request.query_params) + for key in ("issue_number", "pr_number"): + if key in params: + params[key] = int(params[key]) + result = preview_action(action_id, **params) + if "error" in result: + return JSONResponse(result, status_code=404) + return JSONResponse(result) + + +async def api_action_attempt(request: Request) -> JSONResponse: + """POST is rejected by read-only guard; kept for explicit fail-closed tests.""" + action_id = request.path_params["action_id"] + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + result = attempt_action(action_id, **body) + status = 403 if not result.get("success") else 200 + return JSONResponse(result, status_code=status) + + async def method_not_allowed(request: Request, _exc: Exception) -> Response: if request.method not in _READ_ONLY_METHODS: return JSONResponse( @@ -175,6 +213,18 @@ def create_app() -> Starlette: Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), + Route("/actions", actions, methods=["GET"]), + Route("/api/actions", api_actions, methods=["GET"]), + Route( + "/api/actions/{action_id}/preview", + api_action_preview, + methods=["GET"], + ), + Route( + "/api/actions/{action_id}/attempt", + api_action_attempt, + methods=["POST"], + ), ], exception_handlers={405: method_not_allowed}, ) \ No newline at end of file diff --git a/webui/gated_action_views.py b/webui/gated_action_views.py new file mode 100644 index 0000000..0d01d61 --- /dev/null +++ b/webui/gated_action_views.py @@ -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 ( + "
    " + "Preview mutation ledger" + f"
    {_escape(ledger_json)}
    " + f"

    Sample params: " + f"{_escape(json.dumps(sample_params))}

    " + f"

    JSON preview

    " + "
    " + ) + + +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 ( + "
    " + f"

    {_escape(action.label)}

    " + f"

    {_escape(action.description)}

    " + "

    " + f"Task {_escape(action.task_key)} · " + f"MCP {_escape(action.mcp_tool)}
    " + f"Requires {_escape(action.required_permission)} " + f"({_escape(action.required_role)} profile)

    " + f"" + f"{_ledger_block(action)}" + "
    " + ) + + +ACTION_PAGE_STYLES = """ + +""" + + +def render_actions_page(registry: ActionRegistry) -> str: + cards = "".join(_action_card(action) for action in registry.actions) + body = ( + "

    Gated actions

    " + "

    Future write actions are registered here but remain " + "disabled in the read-only MVP. Each action " + "declares the MCP capability and profile role from " + "task_capability_map; previews show the mutation " + "ledger before any execution path ships.

    " + f"

    {len(registry.actions)} registered actions · " + "execution: fail-closed

    " + f"{cards}" + "

    JSON registry

    " + f"{ACTION_PAGE_STYLES}" + ) + return render_page(title="Actions", body_html=body) \ No newline at end of file diff --git a/webui/gated_actions.py b/webui/gated_actions.py new file mode 100644 index 0000000..baefab8 --- /dev/null +++ b/webui/gated_actions.py @@ -0,0 +1,201 @@ +"""Disabled-by-default gated action registry for future UI writes (#434). + +Every action declares the MCP capability and profile role from +``task_capability_map``. Previews always render a mutation ledger; execution +is fail-closed in the read-only MVP (no MCP or shell fallbacks). +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from task_capability_map import required_permission, required_role + +# MVP: no UI action may execute mutations until capability gates ship. +_MVP_ACTIONS_ENABLED = False + + +@dataclass(frozen=True) +class MutationLedgerEntry: + """One planned mutation step shown before any write executes.""" + + sequence: int + mcp_tool: str + permission: str + target: str + summary: str + + +@dataclass(frozen=True) +class GatedAction: + """Registry entry tying a UI action to resolver task metadata.""" + + action_id: str + label: str + task_key: str + mcp_tool: str + description: str + enabled: bool = False + + @property + def required_permission(self) -> str: + return required_permission(self.task_key) + + @property + def required_role(self) -> str: + return required_role(self.task_key) + + def mutation_ledger(self, **params: Any) -> tuple[MutationLedgerEntry, ...]: + """Build the mutation ledger preview for *params* (read-only).""" + target = _format_target(self.action_id, params) + return ( + MutationLedgerEntry( + sequence=1, + mcp_tool=self.mcp_tool, + permission=self.required_permission, + target=target, + summary=self.description, + ), + ) + + def preview(self, **params: Any) -> dict[str, Any]: + ledger = self.mutation_ledger(**params) + return { + "action_id": self.action_id, + "label": self.label, + "task_key": self.task_key, + "mcp_tool": self.mcp_tool, + "required_permission": self.required_permission, + "required_role": self.required_role, + "enabled": self.enabled and _MVP_ACTIONS_ENABLED, + "mvp_mode": "read-only", + "mutation_ledger": [asdict(entry) for entry in ledger], + "params": dict(params), + } + + def attempt(self, **params: Any) -> dict[str, Any]: + """Fail closed — never invokes MCP tools in MVP.""" + preview = self.preview(**params) + if not preview["enabled"]: + return { + "success": False, + "error": "action_disabled", + "detail": ( + "UI actions are disabled in the read-only MVP. " + "Use MCP tools with capability gates." + ), + "preview": preview, + } + return { + "success": False, + "error": "action_not_implemented", + "detail": "Gated execution path is not wired in MVP.", + "preview": preview, + } + + +def _format_target(action_id: str, params: dict[str, Any]) -> str: + if action_id == "claim_issue": + return f"issue #{params.get('issue_number', '?')}" + if action_id in {"comment_issue", "close_issue"}: + return f"issue #{params.get('issue_number', '?')}" + if action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}: + return f"PR #{params.get('pr_number', '?')}" + if action_id == "delete_branch": + return f"branch {params.get('branch_name', '?')!r}" + if action_id == "create_pr": + return ( + f"PR {params.get('head', '?')} → {params.get('base', 'master')}" + ) + if action_id == "create_issue": + return f"issue {params.get('title', '?')!r}" + return "unspecified" + + +@dataclass +class ActionRegistry: + """Ordered registry of gated UI actions.""" + + actions: tuple[GatedAction, ...] = field(default_factory=tuple) + + def get(self, action_id: str) -> GatedAction | None: + for action in self.actions: + if action.action_id == action_id: + return action + return None + + def to_dict(self) -> dict[str, Any]: + return { + "mvp_actions_enabled": _MVP_ACTIONS_ENABLED, + "actions": [ + { + "action_id": action.action_id, + "label": action.label, + "task_key": action.task_key, + "mcp_tool": action.mcp_tool, + "required_permission": action.required_permission, + "required_role": action.required_role, + "enabled": action.enabled, + "description": action.description, + } + for action in self.actions + ], + } + + +def build_action_registry() -> ActionRegistry: + """Return the canonical MVP action set (all disabled).""" + specs = ( + ("claim_issue", "Claim issue", "claim_issue", "gitea_mark_issue", + "Apply status:in-progress via issue comment gate."), + ("comment_issue", "Comment on issue", "comment_issue", + "gitea_create_issue_comment", "Post an issue comment."), + ("create_issue", "Create issue", "create_issue", "gitea_create_issue", + "Open a new tracking issue."), + ("create_pr", "Create pull request", "create_pr", "gitea_create_pr", + "Open a PR from a locked feature branch."), + ("review_pr", "Review PR", "review_pr", "gitea_review_pr", + "Submit approve/request-changes/comment review."), + ("merge_pr", "Merge PR", "merge_pr", "gitea_merge_pr", + "Merge an approved pull request."), + ("delete_branch", "Delete branch", "delete_branch", + "gitea_delete_branch", "Remove a remote feature branch."), + ("comment_pr", "Comment on PR", "comment_pr", + "gitea_create_issue_comment", "Post a PR review thread comment."), + ("close_pr", "Close PR", "close_pr", "gitea_edit_pr", + "Close a pull request without merge."), + ) + actions = tuple( + GatedAction( + action_id=action_id, + label=label, + task_key=task_key, + mcp_tool=mcp_tool, + description=description, + enabled=False, + ) + for action_id, label, task_key, mcp_tool, description in specs + ) + return ActionRegistry(actions=actions) + + +_REGISTRY = build_action_registry() + + +def load_action_registry() -> ActionRegistry: + return _REGISTRY + + +def preview_action(action_id: str, **params: Any) -> dict[str, Any]: + action = _REGISTRY.get(action_id) + if action is None: + return {"error": "unknown_action", "action_id": action_id} + return action.preview(**params) + + +def attempt_action(action_id: str, **params: Any) -> dict[str, Any]: + action = _REGISTRY.get(action_id) + if action is None: + return {"success": False, "error": "unknown_action", "action_id": action_id} + return action.attempt(**params) \ No newline at end of file diff --git a/webui/layout.py b/webui/layout.py index a506c2c..47bedc1 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -11,6 +11,7 @@ NAV_ITEMS = ( ("/audit", "Audit"), ("/worktrees", "Worktrees"), ("/leases", "Leases"), + ("/actions", "Actions"), ) MVP_NOTICE = (