From b4b299f0fa69b0d2ed56998a6164ef5b287d8e62 Mon Sep 17 00:00:00 2001 From: suraj-ranganath Date: Wed, 26 Aug 2026 02:47:29 +0000 Subject: [PATCH] Include action name and form data in action approval emails Requestable action approval emails were generic unless the approver opened the request. Persist Action Name and form data on the access request, and put both in the approver email, the requestor confirmation, and Slack copy. Co-authored-by: c1-squire-dev[bot] --- platform/action_approval_emails.py | 348 ++++++++++++++++++ platform/app.py | 2 + platform/dolt/schema.sql | 3 + platform/dolt_store.py | 194 +++++++++- platform/gateway_service.py | 2 + platform/mcp/handlers.py | 8 +- .../person_b/contracts/access_request.json | 4 + .../person_b/fixtures/access_request.json | 4 + platform/person_b/ui_state.py | 15 +- platform/test_action_approval_emails.py | 126 +++++++ platform/web/src/App.tsx | 32 +- platform/web/src/api.ts | 13 + platform/web/src/styles.css | 33 ++ 13 files changed, 765 insertions(+), 19 deletions(-) create mode 100644 platform/action_approval_emails.py create mode 100644 platform/test_action_approval_emails.py 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( + ( + "" + f"{html.escape(label)}" + f"{html.escape(value)}" + "" + ) + for label, value in fields + ) + form_html = "" + form_lines = _form_data_lines(form_data) + if form_lines: + form_rows = "".join( + ( + "" + f"{html.escape(field_label(key))}" + f"{html.escape(value)}" + "" + ) + for key, value in (form_data or {}).items() + ) + form_html = f"

Form data

{form_rows}
" + return ( + "" + f"

{html.escape(title)}

" + f"

{html.escape(greeting)}

" + f"

{html.escape(intro)}

" + f"{field_rows}
" + f"{form_html}" + 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("

Form data

", email["html"]) + self.assertIn("Acme renewal", email["html"]) + self.assertNotEqual(email["subject"].lower(), "you have a new action to approve") + + def test_requestor_email_is_not_generic_action_submitted(self) -> None: + notifications = _notifications(form_data={"title": "Acme renewal"}) + email = notifications["emails"]["requestor"] + + self.assertEqual(email["subject"], "Create a Linear issue submitted for approval") + self.assertIn("Action Name: Create a Linear issue", email["text"]) + self.assertIn("Form data:", email["text"]) + self.assertIn("Title: Acme renewal", email["text"]) + self.assertNotIn("your action is submitted", email["text"].lower()) + self.assertNotEqual(email["subject"].lower(), "your action is submitted") + + def test_form_data_section_omitted_when_not_applicable(self) -> None: + notifications = _notifications(form_data={}) + approver = notifications["emails"]["approver"] + requestor = notifications["emails"]["requestor"] + + self.assertIn("Action Name: Create a Linear issue", approver["text"]) + self.assertNotIn("Form data:", approver["text"]) + self.assertNotIn("Form data:", requestor["text"]) + self.assertNotIn("

Form data

", approver["html"]) + + def test_slack_message_includes_action_name_and_form_params(self) -> None: + slack = _notifications(form_data={"channel": "slack_channel:sales-acme", "query": "Acme QBR"})["slack"] + + self.assertIn("Action Name: Create a Linear issue", slack["text"]) + self.assertIn("Form data:", slack["text"]) + self.assertIn("• Channel: slack_channel:sales-acme", slack["text"]) + self.assertIn("• Query: Acme QBR", slack["text"]) + + def test_field_labels_are_human_readable(self) -> None: + self.assertEqual(field_label("issue_id"), "Issue Id") + + def test_build_path_redacts_secret_form_fields(self) -> None: + notifications = build_action_approval_notifications( + action_name="Create a Linear issue", + form_data={"title": "Acme renewal", "api_token": "super-secret", "session_id": "sess_demo_001"}, + requestor_name="Alice", + requestor_id="user_alice", + approver_name="Bob", + approver_id="user_bob", + resource_id="linear_team:SALES", + reason="need write access", + request_id="req_test_002", + ) + self.assertEqual(notifications["form_data"]["title"], "Acme renewal") + self.assertEqual(notifications["form_data"]["api_token"], "[redacted]") + self.assertNotIn("session_id", notifications["form_data"]) + self.assertNotIn("super-secret", notifications["emails"]["approver"]["text"]) + + +def _notifications(form_data: dict[str, str] | None = None) -> dict: + return build_action_approval_notifications( + action_name=action_display_name("linear.create_issue"), + form_data=form_data, + requestor_name="Alice", + requestor_id="user_alice", + approver_name="Bob", + approver_id="user_bob", + resource_id="linear_team:SALES", + reason="Sales renewal prep recipe predicts a Linear write", + request_id="req_test_001", + session_id="sess_demo_001", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/platform/web/src/App.tsx b/platform/web/src/App.tsx index 5182456..be947d9 100644 --- a/platform/web/src/App.tsx +++ b/platform/web/src/App.tsx @@ -537,9 +537,10 @@ function AuthorizationLedger({ rows }: { rows: AuthorizationLedgerEntry[] }) {
{row.status || row.decision} - {row.tool_id || "authorization"} + {row.action_name || row.tool_id || "authorization"} {row.scope || "scope"} {"->"} {row.resource_id || "resource"}

{row.reason || "No policy reason recorded."}

+
{row.policy_engine ? {row.policy_engine} : null} @@ -736,6 +737,23 @@ function SlackDemoPage({ messages, decisions }: { ); } +function formFieldLabel(key: string) { + return key.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function FormDataList({ data }: { data?: Record | null }) { + const entries = Object.entries(data || {}).filter(([, value]) => value); + if (!entries.length) return null; + return ( +
+
Form data
+ {entries.map(([key, value]) => ( +
{formFieldLabel(key)} {value}
+ ))} +
+ ); +} + function RequestList({ requests, onApprove, busy }: { requests: AccessRequest[]; onApprove: (request: AccessRequest) => void; @@ -747,9 +765,13 @@ function RequestList({ requests, onApprove, busy }: { {requests.map((request) => (
- {request.requested_tool_id} + {request.action_name || request.requested_tool_id} {request.requested_scope} {request.reason} + + {request.notifications?.emails?.approver?.subject ? ( + Email: {request.notifications.emails.approver.subject} + ) : null}
{request.status === "pending" ? (