"""FlowDeck — agent governance (v7.2.0): workspace tool scope + approval gate. ``agent_policies``: ``allowed_tools_json`` (null = all tools), ``max_steps``, ``require_approval`` (write tools pause for a human). ``check_tool()`` is consulted by ``AgentEngine`` before ``PermissionManager.assert_can``. ``agent.run.approval_requested`` is emitted on the outbound webhook bus so external systems can subscribe. See ``docs/V72_Enterprise_SCIM_2FA.md``. """ from __future__ import annotations import json from app.db import get_conn def get_policy(workspace_id: int | None) -> dict: """Effective policy (workspace row, else global row, else defaults).""" with get_conn() as conn: row = None if workspace_id is not None: row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id=?", (workspace_id,)).fetchone() if row is None: row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS NULL" ).fetchone() if not row: return {"allowed_tools": None, "max_steps": 12, "require_approval": False} d = dict(row) try: allowed = json.loads(d.get("allowed_tools_json")) if d.get("allowed_tools_json") else None except (TypeError, ValueError): allowed = None return {"allowed_tools": allowed, "max_steps": d.get("max_steps") or 12, "require_approval": bool(d.get("require_approval"))} def check_tool(user_id: int, workspace_id: int | None, tool: str, is_write: bool, conversation_id: int = 0) -> dict: """Policy gate for one tool call. Returns {allowed: bool, approval_id: int|None}. Denied tools and gated writes (pending approval) return allowed=False; the engine renders both as action errors without executing. """ from app.services.permission_manager import WRITE_TOOLS policy = get_policy(workspace_id) allowed = policy["allowed_tools"] if allowed is not None and tool not in set(allowed): return {"allowed": False, "approval_id": None, "reason": "tool not in policy scope"} if is_write or tool in WRITE_TOOLS: if policy["require_approval"]: with get_conn() as conn: cur = conn.execute( """INSERT INTO agent_approvals (conversation_id, tool, args_json, status, requester_id) VALUES (?,?,?,?,?)""", (conversation_id, tool, "{}", "pending", user_id)) conn.commit() approval_id = cur.lastrowid try: import asyncio from app.services.webhook_outbound import fire_event as _fire try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is not None: loop.create_task(_fire("agent.run.approval_requested", { "approval_id": approval_id, "tool": tool, "conversation_id": conversation_id})) except Exception: # noqa: BLE001 — webhook never blocks policy pass return {"allowed": False, "approval_id": approval_id, "reason": "approval requested"} return {"allowed": True, "approval_id": None, "reason": ""} def decide_approval(approval_id: int, approver_id: int, approve: bool) -> dict | None: with get_conn() as conn: row = conn.execute("SELECT * FROM agent_approvals WHERE id=?", (approval_id,)).fetchone() if not row or row["status"] != "pending": return None conn.execute("UPDATE agent_approvals SET status=?, approver_id=? WHERE id=?", ("approved" if approve else "rejected", approver_id, approval_id)) conn.commit() return dict(conn.execute("SELECT * FROM agent_approvals WHERE id=?", (approval_id,)).fetchone())