Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions platform/action_approval_emails.py
Original file line number Diff line number Diff line change
@@ -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(
(
"<tr>"
f"<th align='left' style='padding:4px 12px 4px 0;vertical-align:top'>{html.escape(label)}</th>"
f"<td style='padding:4px 0'>{html.escape(value)}</td>"
"</tr>"
)
for label, value in fields
)
form_html = ""
form_lines = _form_data_lines(form_data)
if form_lines:
form_rows = "".join(
(
"<tr>"
f"<th align='left' style='padding:4px 12px 4px 0;vertical-align:top'>{html.escape(field_label(key))}</th>"
f"<td style='padding:4px 0'>{html.escape(value)}</td>"
"</tr>"
)
for key, value in (form_data or {}).items()
)
form_html = f"<h3>Form data</h3><table>{form_rows}</table>"
return (
"<html><body>"
f"<h2>{html.escape(title)}</h2>"
f"<p>{html.escape(greeting)}</p>"
f"<p>{html.escape(intro)}</p>"
f"<table>{field_rows}</table>"
f"{form_html}"
f"<p>{html.escape(footer)}</p>"
"</body></html>"
)
2 changes: 2 additions & 0 deletions platform/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions platform/dolt/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading