{row.reason || "No policy reason recorded."}
+-
+
- Form data + {entries.map(([key, value]) => ( +
- {formFieldLabel(key)} {value} + ))} +
diff --git a/platform/action_approval_emails.py b/platform/action_approval_emails.py new file mode 100644 index 0000000..804fc71 --- /dev/null +++ b/platform/action_approval_emails.py @@ -0,0 +1,348 @@ +"""Customer-facing emails and Slack copy for requestable action approvals. + +Pylon #13007: approver and requestor emails for requestable automations/actions +were too generic. Include the Action Name and, when present, form data so an +approver does not have to open the request just to see what was asked for. +""" + +from __future__ import annotations + +import html +import json +from typing import Any + +from mcp.registry import ALL_TOOLS + + +HIDDEN_FORM_KEYS = { + "agent_id", + "authorization", + "delegation_token", + "idempotency_key", + "reason", + "requested_scope", + "resource_id", + "session_id", + "tool_id", +} + +SECRET_KEY_MARKERS = ( + "authorization", + "bearer", + "client_secret", + "credential", + "password", + "private_key", + "secret", + "token", +) + + +def action_display_name(tool_id: str) -> str: + """Human-readable Action Name for a requestable tool/automation.""" + tool = ALL_TOOLS.get(tool_id) or {} + description = str(tool.get("description") or "").split("(")[0].strip().rstrip(".") + if description: + return description + parts = [part for part in str(tool_id or "").replace("_", " ").replace(".", " ").split() if part] + if not parts: + return "Action" + return " ".join(part.capitalize() for part in parts) + + +def field_label(key: str) -> str: + return str(key or "").replace("_", " ").strip().title() or "Field" + + +def extract_form_data(arguments: dict[str, Any] | None) -> dict[str, str]: + """Return customer-visible form fields. Secrets are redacted; internals omitted.""" + if not arguments: + return {} + fields: dict[str, str] = {} + for raw_key, raw_value in arguments.items(): + key = str(raw_key) + if key.lower() in HIDDEN_FORM_KEYS or raw_value in (None, "", [], {}): + continue + if _is_secret_key(key): + fields[key] = "[redacted]" + continue + rendered = _render_form_value(raw_value) + if rendered: + fields[key] = rendered + return fields + + +def build_action_approval_notifications( + *, + action_name: str, + form_data: dict[str, str] | None = None, + requestor_name: str, + requestor_id: str, + approver_name: str, + approver_id: str, + resource_id: str, + reason: str, + request_id: str, + session_id: str = "", +) -> dict[str, Any]: + fields = extract_form_data(form_data) + return { + "action_name": action_name, + "form_data": fields, + "emails": { + "approver": build_approver_email( + action_name=action_name, + form_data=fields, + requestor_name=requestor_name, + requestor_id=requestor_id, + approver_name=approver_name, + approver_id=approver_id, + resource_id=resource_id, + reason=reason, + request_id=request_id, + session_id=session_id, + ), + "requestor": build_requestor_email( + action_name=action_name, + form_data=fields, + requestor_name=requestor_name, + requestor_id=requestor_id, + resource_id=resource_id, + reason=reason, + request_id=request_id, + session_id=session_id, + ), + }, + "slack": build_slack_message( + action_name=action_name, + form_data=fields, + requestor_name=requestor_name, + resource_id=resource_id, + reason=reason, + request_id=request_id, + ), + } + + +def build_approver_email( + *, + action_name: str, + form_data: dict[str, str] | None, + requestor_name: str, + requestor_id: str, + approver_name: str, + approver_id: str, + resource_id: str, + reason: str, + request_id: str, + session_id: str = "", +) -> dict[str, Any]: + fields = [ + ("Action Name", action_name), + ("Requested by", requestor_name), + ("Resource", resource_id), + ] + if reason: + fields.append(("Reason", reason)) + intro = ( + f"{requestor_name} submitted a requestable action that needs your approval. " + "Action Name and form data are included below so you can review without opening the request first." + ) + footer = "Open ScopeMemory to approve or deny this request." + text = _plain_email( + heading=f"Action approval needed: {action_name}", + greeting=f"Hi {approver_name},", + intro=intro, + fields=fields, + form_data=form_data, + footer=footer, + ) + return { + "kind": "approver", + "channel": "email", + "to_user_id": approver_id, + "to": approver_name, + "from_user_id": requestor_id, + "subject": f"Action approval needed: {action_name}", + "text": text, + "html": _html_email( + title=f"Action approval needed: {action_name}", + greeting=f"Hi {approver_name},", + intro=intro, + fields=fields, + form_data=form_data, + footer=footer, + ), + "request_id": request_id, + "session_id": session_id, + "action_name": action_name, + "form_data": dict(form_data or {}), + } + + +def build_requestor_email( + *, + action_name: str, + form_data: dict[str, str] | None, + requestor_name: str, + requestor_id: str, + resource_id: str, + reason: str, + request_id: str, + session_id: str = "", +) -> dict[str, Any]: + fields = [ + ("Action Name", action_name), + ("Resource", resource_id), + ] + if reason: + fields.append(("Reason", reason)) + intro = ( + f'Your request for "{action_name}" was submitted and is waiting for approval. ' + "This is not a generic action-submitted notice: the Action Name and form data are included below." + ) + footer = "You will be notified when an approver reviews this request." + text = _plain_email( + heading=f"{action_name} submitted for approval", + greeting=f"Hi {requestor_name},", + intro=intro, + fields=fields, + form_data=form_data, + footer=footer, + ) + return { + "kind": "requestor", + "channel": "email", + "to_user_id": requestor_id, + "to": requestor_name, + "subject": f"{action_name} submitted for approval", + "text": text, + "html": _html_email( + title=f"{action_name} submitted for approval", + greeting=f"Hi {requestor_name},", + intro=intro, + fields=fields, + form_data=form_data, + footer=footer, + ), + "request_id": request_id, + "session_id": session_id, + "action_name": action_name, + "form_data": dict(form_data or {}), + } + + +def build_slack_message( + *, + action_name: str, + form_data: dict[str, str] | None, + requestor_name: str, + resource_id: str, + reason: str, + request_id: str, +) -> dict[str, Any]: + lines = [ + f"{requestor_name} requested *{action_name}*", + f"Action Name: {action_name}", + f"Resource: {resource_id}", + ] + if reason: + lines.append(f"Reason: {reason}") + form_lines = _form_data_lines(form_data) + if form_lines: + lines.append("Form data:") + lines.extend(f"• {line}" for line in form_lines) + return { + "kind": "approver", + "channel": "slack", + "text": "\n".join(lines), + "action_name": action_name, + "form_data": dict(form_data or {}), + "request_id": request_id, + } + + +def _is_secret_key(key: str) -> bool: + lowered = key.lower() + return any(marker in lowered for marker in SECRET_KEY_MARKERS) + + +def _render_form_value(value: Any) -> str: + if isinstance(value, (dict, list)): + rendered = json.dumps(value, sort_keys=True, default=str) + else: + rendered = str(value).strip() + lowered = rendered.lower() + if "authorization: bearer " in lowered or "op://" in lowered: + return "[redacted]" + return rendered + + +def _form_data_lines(form_data: dict[str, str] | None) -> list[str]: + if not form_data: + return [] + return [f"{field_label(key)}: {value}" for key, value in form_data.items()] + + +def _plain_email( + *, + heading: str, + greeting: str, + intro: str, + fields: list[tuple[str, str]], + form_data: dict[str, str] | None, + footer: str, +) -> str: + lines = [heading, "", greeting, "", intro, ""] + for label, value in fields: + lines.append(f"{label}: {value}") + form_lines = _form_data_lines(form_data) + if form_lines: + lines.append("") + lines.append("Form data:") + lines.extend(f" {line}" for line in form_lines) + lines.extend(["", footer]) + return "\n".join(lines) + + +def _html_email( + *, + title: str, + greeting: str, + intro: str, + fields: list[tuple[str, str]], + form_data: dict[str, str] | None, + footer: str, +) -> str: + field_rows = "".join( + ( + "
{html.escape(greeting)}
" + f"{html.escape(intro)}
" + f"{html.escape(footer)}
" + "" + ) diff --git a/platform/app.py b/platform/app.py index ab34012..b3817db 100644 --- a/platform/app.py +++ b/platform/app.py @@ -96,6 +96,7 @@ class AuthorizeRequest(BaseModel): tool_id: str resource_id: str delegation_token: str | None = None + form_data: dict[str, Any] | None = None @app.get("/health") @@ -175,6 +176,7 @@ def authorize( try: return run_authorize( req.session_id, req.agent_id, req.tool_id, req.resource_id, jwt_claims, + form_data=req.form_data, ) except ValueError as e: raise HTTPException(404, str(e)) from e diff --git a/platform/dolt/schema.sql b/platform/dolt/schema.sql index 8313f0e..c673c96 100644 --- a/platform/dolt/schema.sql +++ b/platform/dolt/schema.sql @@ -131,6 +131,9 @@ CREATE TABLE IF NOT EXISTS access_requests ( source_trace_ids_json LONGTEXT, trigger_phase VARCHAR(64) NOT NULL DEFAULT 'authorize', created_before_tool_call TINYINT NOT NULL DEFAULT 0, + action_name VARCHAR(255), + form_data_json LONGTEXT, + notifications_json LONGTEXT, sent_at TIMESTAMP NULL, first_tool_call_at TIMESTAMP NULL, status VARCHAR(64) NOT NULL DEFAULT 'pending', diff --git a/platform/dolt_store.py b/platform/dolt_store.py index 90e09c4..4559df9 100644 --- a/platform/dolt_store.py +++ b/platform/dolt_store.py @@ -33,6 +33,142 @@ def _json(value: Any) -> str: return json.dumps(value, sort_keys=True, default=str) +def _decode_json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _decode_json_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + return parsed if isinstance(parsed, list) else [] + return [] + + +def hydrate_access_request(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return row + out = dict(row) + out["source_trace_ids"] = _decode_json_list(out.get("source_trace_ids_json") or out.get("source_trace_ids")) + out["form_data"] = _decode_json_object(out.get("form_data") if out.get("form_data") else out.get("form_data_json")) + out["notifications"] = _decode_json_object( + out.get("notifications") if out.get("notifications") else out.get("notifications_json") + ) + out["created_before_tool_call"] = bool(out.get("created_before_tool_call")) + if not out.get("action_name"): + from action_approval_emails import action_display_name + out["action_name"] = action_display_name(str(out.get("requested_tool_id") or "")) + if not out.get("notifications"): + from action_approval_emails import build_action_approval_notifications + out["notifications"] = build_action_approval_notifications( + action_name=str(out["action_name"]), + form_data=out.get("form_data") or {}, + requestor_name=str(out.get("user_id") or "Requestor"), + requestor_id=str(out.get("user_id") or ""), + approver_name=str(out.get("approver_id") or "Approver"), + approver_id=str(out.get("approver_id") or ""), + resource_id=str(out.get("requested_resource") or ""), + reason=str(out.get("reason") or ""), + request_id=str(out.get("request_id") or ""), + session_id=str(out.get("session_id") or ""), + ) + return out + + +def _lookup_request_actors(cur, session_id: str, user_id: str) -> tuple[str, str, str]: + cur.execute("SELECT display_name FROM users WHERE user_id = %s", (user_id,)) + requestor = cur.fetchone() + requestor_name = requestor["display_name"] if requestor else user_id + cur.execute( + """ + SELECT u.user_id, u.display_name + FROM sessions s + JOIN user_teams ut ON ut.team_id = s.team_id AND ut.role = 'admin' + JOIN users u ON u.user_id = ut.user_id + WHERE s.session_id = %s + ORDER BY u.user_id + LIMIT 1 + """, + (session_id,), + ) + admin = cur.fetchone() + if admin: + return requestor_name, str(admin["display_name"]), str(admin["user_id"]) + return requestor_name, "Approver", "" + + +def _notifications_for_request( + cur, + *, + session_id: str, + user_id: str, + request_id: str, + requested_tool_id: str, + requested_resource: str, + reason: str, + form_data: dict[str, Any] | None, +) -> dict[str, Any]: + from action_approval_emails import ( + action_display_name, + build_action_approval_notifications, + extract_form_data, + ) + + sanitized = extract_form_data(form_data) + requestor_name, approver_name, approver_id = _lookup_request_actors(cur, session_id, user_id) + return build_action_approval_notifications( + action_name=action_display_name(requested_tool_id), + form_data=sanitized, + requestor_name=requestor_name, + requestor_id=user_id, + approver_name=approver_name, + approver_id=approver_id, + resource_id=requested_resource, + reason=reason, + request_id=request_id, + session_id=session_id, + ) + + +def _record_approval_notifications( + cur, + session_id: str, + request_id: str, + notifications: dict[str, Any], + *, + updated: bool, +) -> None: + event_type = ( + "action_approval_notifications_updated" if updated else "action_approval_notifications_sent" + ) + _append_session_event( + cur, + session_id, + event_type, + { + "request_id": request_id, + "action_name": notifications.get("action_name"), + "has_form_data": bool(notifications.get("form_data")), + "form_data": notifications.get("form_data") or {}, + "approver_email": notifications.get("emails", {}).get("approver"), + "requestor_email": notifications.get("emails", {}).get("requestor"), + "slack": notifications.get("slack"), + }, + ) + + def _short_hash(value: Any, length: int = 24) -> str: digest = stable_hash(value).split(":", 1)[1] return digest[:length] @@ -97,6 +233,9 @@ def _ensure_runtime_columns(cur) -> None: ("access_requests", "created_before_tool_call", "TINYINT NOT NULL DEFAULT 0"), ("access_requests", "sent_at", "TIMESTAMP NULL"), ("access_requests", "first_tool_call_at", "TIMESTAMP NULL"), + ("access_requests", "action_name", "VARCHAR(255)"), + ("access_requests", "form_data_json", "LONGTEXT"), + ("access_requests", "notifications_json", "LONGTEXT"), ("session_events", "prev_event_hash", "VARCHAR(128)"), ("session_events", "event_hash", "VARCHAR(128)"), ("session_events", "event_order", "BIGINT"), @@ -947,6 +1086,7 @@ def create_access_request( source_trace_ids: list[str] | None = None, trigger_phase: str = "authorize", created_before_tool_call: bool = False, + form_data: dict[str, Any] | None = None, ) -> dict[str, Any]: conn = connect() result: dict[str, Any] @@ -968,6 +1108,19 @@ def create_access_request( existing = cur.fetchone() if existing: first_tool_call_value = existing.get("first_tool_call_at") + existing_form = _decode_json_object(existing.get("form_data_json")) + request_id = existing["request_id"] + notifications = _notifications_for_request( + cur, + session_id=session_id, + user_id=user_id, + request_id=request_id, + requested_tool_id=requested_tool_id, + requested_resource=requested_resource, + reason=reason, + form_data=form_data if form_data is not None else existing_form, + ) + sanitized_form = notifications["form_data"] cur.execute( """ UPDATE access_requests @@ -984,6 +1137,9 @@ def create_access_request( source_trace_ids_json = COALESCE(source_trace_ids_json, %s), trigger_phase = %s, created_before_tool_call = GREATEST(created_before_tool_call, %s), + action_name = %s, + form_data_json = %s, + notifications_json = %s, first_tool_call_at = CASE WHEN %s = 'authorize' AND first_tool_call_at IS NULL THEN NOW() ELSE first_tool_call_at @@ -1001,8 +1157,11 @@ def create_access_request( _json(source_trace_ids or []), trigger_phase, int(created_before_tool_call), + notifications["action_name"], + _json(sanitized_form), + _json(notifications), trigger_phase, - existing["request_id"], + request_id, ), ) existing.update({ @@ -1013,10 +1172,26 @@ def create_access_request( "trigger_phase": trigger_phase, "first_tool_call_at": first_tool_call_value, "already_pending": True, + "action_name": notifications["action_name"], + "form_data": sanitized_form, + "notifications": notifications, }) + if sanitized_form and not existing_form: + _record_approval_notifications(cur, session_id, request_id, notifications, updated=True) result = existing else: request_id = f"req_{uuid.uuid4().hex[:12]}" + notifications = _notifications_for_request( + cur, + session_id=session_id, + user_id=user_id, + request_id=request_id, + requested_tool_id=requested_tool_id, + requested_resource=requested_resource, + reason=reason, + form_data=form_data, + ) + sanitized_form = notifications["form_data"] cur.execute( """ INSERT INTO access_requests @@ -1024,8 +1199,10 @@ def create_access_request( requested_resource, requested_tool_id, reason, recipe_id, proof_id, request_origin, prediction_id, prediction_confidence, source_trace_ids_json, trigger_phase, created_before_tool_call, + action_name, form_data_json, notifications_json, sent_at, first_tool_call_at, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, NOW(), CASE WHEN %s = 'authorize' THEN NOW() ELSE NULL END, 'pending') """, ( @@ -1045,6 +1222,9 @@ def create_access_request( _json(source_trace_ids or []), trigger_phase, int(created_before_tool_call), + notifications["action_name"], + _json(sanitized_form), + _json(notifications), trigger_phase, ), ) @@ -1055,6 +1235,7 @@ def create_access_request( { "request_id": request_id, "tool_id": requested_tool_id, + "action_name": notifications["action_name"], "scope": requested_scope, "resource_id": requested_resource, "recipe_id": recipe_id, @@ -1064,8 +1245,12 @@ def create_access_request( "prediction_confidence": prediction_confidence, "created_before_tool_call": created_before_tool_call, "trigger_phase": trigger_phase, + "form_data": sanitized_form, + "approver_email_subject": notifications["emails"]["approver"]["subject"], + "requestor_email_subject": notifications["emails"]["requestor"]["subject"], }, ) + _record_approval_notifications(cur, session_id, request_id, notifications, updated=False) result = { "request_id": request_id, "session_id": session_id, @@ -1083,6 +1268,9 @@ def create_access_request( "source_trace_ids": source_trace_ids or [], "trigger_phase": trigger_phase, "created_before_tool_call": created_before_tool_call, + "action_name": notifications["action_name"], + "form_data": sanitized_form, + "notifications": notifications, "status": "pending", } cur.execute( @@ -1090,7 +1278,7 @@ def create_access_request( (session_id,), ) conn.close() - return result + return hydrate_access_request(result) def approve_access_request(request_id: str, approver_id: str) -> dict[str, Any]: @@ -1212,7 +1400,7 @@ def list_access_requests(session_id: str | None = None) -> list[dict[str, Any]]: cur.execute("SELECT * FROM access_requests") rows = list(cur.fetchall()) conn.close() - return rows + return [hydrate_access_request(row) or row for row in rows] def list_active_grants(session_id: str | None = None) -> list[dict[str, Any]]: diff --git a/platform/gateway_service.py b/platform/gateway_service.py index b8f53d7..cbfa2ce 100644 --- a/platform/gateway_service.py +++ b/platform/gateway_service.py @@ -203,6 +203,7 @@ def run_authorize( tool_id: str, resource_id: str, jwt_claims: dict[str, Any], + form_data: dict[str, Any] | None = None, ) -> dict[str, Any]: session = get_session(session_id) if not session: @@ -361,6 +362,7 @@ def run_authorize( reason=policy_decision.reason, recipe_id=_recipe_id_from_ctx(ctx), proof_id=policy_decision.proof.proof_hash, + form_data=form_data, ) elif policy_decision.decision == Decision.ALLOW: grant = find_active_grant_for_tool(session_id, tool_id, resource_id) diff --git a/platform/mcp/handlers.py b/platform/mcp/handlers.py index 1207907..2e38427 100644 --- a/platform/mcp/handlers.py +++ b/platform/mcp/handlers.py @@ -97,7 +97,9 @@ def handle_tool_call( if requested_tool not in DOWNSTREAM_TOOL_NAMES: raise McpHandlerError(MCP_POLICY_DENIED, f"unknown downstream tool: {requested_tool}") resource_id = _resource_for_tool(requested_tool, args) - auth = run_authorize(session_id, agent_id, requested_tool, resource_id, claims) + auth = run_authorize( + session_id, agent_id, requested_tool, resource_id, claims, form_data=args, + ) return tool_result_text( { "decision": auth["decision"], @@ -169,7 +171,9 @@ def handle_tool_call( {"tool_id": name, "resource_id": resource_id, "tool_intent": tool_intent}, ) mark_access_request_tool_call_seen(session_id, name, resource_id) - auth = run_authorize(session_id, agent_id, name, resource_id, claims) + auth = run_authorize( + session_id, agent_id, name, resource_id, claims, form_data=args, + ) decision = auth["decision"] if decision in {"ALLOW", "AUTO_APPROVE_EPHEMERAL_GRANT"}: diff --git a/platform/person_b/contracts/access_request.json b/platform/person_b/contracts/access_request.json index 5b003d8..91cb3bc 100644 --- a/platform/person_b/contracts/access_request.json +++ b/platform/person_b/contracts/access_request.json @@ -4,6 +4,10 @@ "requested_scope": "slack:channels:history", "requested_resource": "slack_channel:sales-acme", "requested_tool_id": "slack.search_messages", + "action_name": "Search Slack channel history", + "form_data": { + "channel": "slack_channel:sales-acme" + }, "reason": "Sales renewal prep recipe predicts Slack read for customer context", "recipe_id": "recipe_sales_renewal_v3", "status": "pending", diff --git a/platform/person_b/fixtures/access_request.json b/platform/person_b/fixtures/access_request.json index 26ae6e3..3df6cf2 100644 --- a/platform/person_b/fixtures/access_request.json +++ b/platform/person_b/fixtures/access_request.json @@ -5,6 +5,10 @@ "requested_scope": "slack:channels:history", "requested_resource": "slack_channel:sales-acme", "requested_tool_id": "slack.search_messages", + "action_name": "Search Slack channel history", + "form_data": { + "channel": "slack_channel:sales-acme" + }, "reason": "Sales renewal prep recipe predicts Slack read for customer context", "recipe_id": "recipe_sales_renewal_v3", "status": "pending", diff --git a/platform/person_b/ui_state.py b/platform/person_b/ui_state.py index 28cc3f2..069a3eb 100644 --- a/platform/person_b/ui_state.py +++ b/platform/person_b/ui_state.py @@ -9,6 +9,7 @@ from dolt_store import ( connect, get_session, + hydrate_access_request, list_context_graph, list_credential_leases, list_demo_linear_state, @@ -147,17 +148,7 @@ def build_ui_state(session_id: str, use_fixtures: bool = False) -> dict[str, Any def _normalize_request(row: dict[str, Any]) -> dict[str, Any]: - out = dict(row) - source = out.get("source_trace_ids_json") - if isinstance(source, str) and source: - try: - out["source_trace_ids"] = json.loads(source) - except json.JSONDecodeError: - out["source_trace_ids"] = [] - else: - out["source_trace_ids"] = [] - out["created_before_tool_call"] = bool(out.get("created_before_tool_call")) - return out + return hydrate_access_request(row) or dict(row) def _normalize_bool_fields(row: dict[str, Any]) -> dict[str, Any]: @@ -202,9 +193,11 @@ def _authorization_ledger( "kind": "access_request", "status": request.get("status"), "tool_id": request.get("requested_tool_id"), + "action_name": request.get("action_name") or request.get("requested_tool_id"), "resource_id": request.get("requested_resource"), "scope": request.get("requested_scope"), "reason": request.get("reason"), + "form_data": request.get("form_data") or {}, "request_id": request.get("request_id"), "policy_engine": "", "rules": [], diff --git a/platform/test_action_approval_emails.py b/platform/test_action_approval_emails.py new file mode 100644 index 0000000..2a7a1a3 --- /dev/null +++ b/platform/test_action_approval_emails.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).parent)) + +from action_approval_emails import ( # noqa: E402 + action_display_name, + build_action_approval_notifications, + extract_form_data, + field_label, +) + + +class ActionApprovalEmailTests(unittest.TestCase): + def test_action_name_uses_catalog_description(self) -> None: + self.assertEqual(action_display_name("linear.create_issue"), "Create a Linear issue") + self.assertEqual(action_display_name("slack.search_messages"), "Search Slack channel history") + + def test_unknown_tool_is_humanized(self) -> None: + self.assertEqual(action_display_name("okta.reset_password"), "Okta Reset Password") + + def test_extract_form_data_drops_internal_keys_and_redacts_secrets(self) -> None: + fields = extract_form_data({ + "session_id": "sess_demo_001", + "agent_id": "agent_renewal_01", + "tool_id": "linear.create_issue", + "resource_id": "linear_team:SALES", + "title": "Acme renewal", + "description": "Follow up after QBR", + "api_token": "super-secret", + "empty": "", + }) + self.assertEqual(fields["title"], "Acme renewal") + self.assertEqual(fields["description"], "Follow up after QBR") + self.assertEqual(fields["api_token"], "[redacted]") + self.assertNotIn("session_id", fields) + self.assertNotIn("agent_id", fields) + self.assertNotIn("tool_id", fields) + self.assertNotIn("resource_id", fields) + self.assertNotIn("super-secret", fields.values()) + + def test_approver_email_includes_action_name_and_form_data(self) -> None: + notifications = _notifications(form_data={"title": "Acme renewal", "priority": "high"}) + email = notifications["emails"]["approver"] + + self.assertEqual(email["subject"], "Action approval needed: Create a Linear issue") + self.assertIn("Action Name: Create a Linear issue", email["text"]) + self.assertIn("Form data:", email["text"]) + self.assertIn("Title: Acme renewal", email["text"]) + self.assertIn("Priority: high", email["text"]) + self.assertIn("Alice", email["text"]) + self.assertIn("{row.reason || "No policy reason recorded."}
+