declarative action approval bugfix

This commit is contained in:
Peter Ibekwe
2026-06-11 13:16:36 -07:00
Unverified
parent db2c576f56
commit 0ade298cc4
7 changed files with 506 additions and 262 deletions
@@ -76,12 +76,10 @@ from ._executors_mcp import (
from ._executors_tools import (
FUNCTION_TOOL_REGISTRY_KEY,
TOOL_ACTION_EXECUTORS,
TOOL_APPROVAL_STATE_KEY,
BaseToolExecutor,
InvokeFunctionToolExecutor,
ToolApprovalRequest,
ToolApprovalResponse,
ToolApprovalState,
ToolInvocationResult,
)
from ._factory import WorkflowFactory
@@ -111,7 +109,6 @@ __all__ = [
"HTTP_ACTION_EXECUTORS",
"MCP_ACTION_EXECUTORS",
"TOOL_ACTION_EXECUTORS",
"TOOL_APPROVAL_STATE_KEY",
"TOOL_REGISTRY_KEY",
"ActionComplete",
"ActionTrigger",
@@ -164,7 +161,6 @@ __all__ = [
"SetVariableExecutor",
"ToolApprovalRequest",
"ToolApprovalResponse",
"ToolApprovalState",
"ToolInvocationResult",
"WorkflowFactory",
"WorkflowState",
@@ -15,12 +15,11 @@ Security notes:
matches the security posture of :mod:`._executors_http` (which never logs
request headers either) and prevents secrets from leaking through workflow
events that are typically observable to operators / UIs.
- ``_MCPToolApprovalState`` snapshots the EVALUATED values for non-secret
fields (server URL, tool name, arguments) at approval-request time so that
subsequent state mutations cannot make the executor "approve X then call
Y". Headers are stored as the raw expression strings (not evaluated values)
so secrets are not persisted in the workflow's checkpoint state. They are
re-evaluated on resume.
- The :class:`MCPToolApprovalRequest` payload is the source of truth for the
resumed invocation: ``tool_name``, ``server_url``, ``server_label``,
``arguments``, and ``connection_name`` come from the request the reviewer
approved. Headers are re-evaluated from the action definition on resume so
that secret values are not persisted in the workflow's checkpoint state.
- Tool outputs flow back into agent conversations through ``conversationId``
and through Tool-role messages emitted to ``output.messages``. They share
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
@@ -60,8 +59,6 @@ __all__ = [
logger = logging.getLogger(__name__)
_MCP_APPROVAL_STATE_KEY = "_mcp_tool_approval_state"
# ---------------------------------------------------------------------------
# Request / state types
@@ -86,6 +83,9 @@ class MCPToolApprovalRequest:
arguments: Evaluated arguments to be forwarded to the tool.
header_names: Sorted list of outbound header names (no values). Empty
when no headers are configured.
connection_name: Optional connection identifier the invocation will
use. Surfaced so the reviewer can see which connection is bound
to the approved call.
"""
request_id: str
@@ -94,28 +94,7 @@ class MCPToolApprovalRequest:
server_label: str | None
arguments: dict[str, Any]
header_names: list[str] = field(default_factory=lambda: [])
@dataclass
class _MCPToolApprovalState:
"""Internal state saved during the approval yield for resumption.
Stores **evaluated** values for non-secret fields to prevent
"approve X / execute Y" attacks. Stores the raw expression string for
``headers`` so that secret values are NOT persisted in checkpoint state;
the expressions are re-evaluated against current state on resume.
"""
server_url: str
tool_name: str
server_label: str | None
arguments: dict[str, Any]
connection_name: str | None
headers_def: Any
auto_send: bool
conversation_id_expr: str | None
output_messages_path: str | None
output_result_path: str | None
connection_name: str | None = None
# ---------------------------------------------------------------------------
@@ -260,20 +239,6 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
if require_approval:
request_id = str(uuid.uuid4())
approval_state = _MCPToolApprovalState(
server_url=server_url,
tool_name=tool_name,
server_label=server_label,
arguments=arguments,
connection_name=connection_name,
headers_def=self._action_def.get("headers"),
auto_send=auto_send,
conversation_id_expr=conversation_id_expr if isinstance(conversation_id_expr, str) else None,
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
ctx.state.set(self._approval_key(), approval_state)
request = MCPToolApprovalRequest(
request_id=request_id,
tool_name=tool_name,
@@ -281,6 +246,7 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
server_label=server_label,
arguments=arguments,
header_names=sorted(headers.keys()),
connection_name=connection_name,
)
logger.info(
"%s: requesting approval for MCP tool '%s' on '%s'",
@@ -322,54 +288,59 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Resume after the workflow yielded for an approval request."""
state = self._get_state(ctx.state)
approval_key = self._approval_key()
"""Resume after the workflow yielded for an approval request.
try:
approval_state: _MCPToolApprovalState = ctx.state.get(approval_key)
except KeyError:
logger.error("%s: approval state missing for executor '%s'", self.__class__.__name__, self.id)
await ctx.send_message(ActionComplete())
return
try:
ctx.state.delete(approval_key)
except KeyError:
logger.warning("%s: approval state already deleted for '%s'", self.__class__.__name__, self.id)
Invocation fields (``tool_name``, ``server_url``, ``server_label``,
``arguments``, ``connection_name``) are sourced from
``original_request``. Output configuration is re-derived from the
action definition; header values are re-evaluated from the action
definition so secrets remain out of checkpoint state.
"""
state = self._get_state(ctx.state)
tool_name = original_request.tool_name
server_url = original_request.server_url
server_label = original_request.server_label
arguments = original_request.arguments
connection_name = original_request.connection_name
auto_send = self._get_auto_send(state)
conversation_id_value = self._action_def.get("conversationId")
conversation_id_expr = conversation_id_value if isinstance(conversation_id_value, str) else None
output_messages_path = _get_output_path(self._action_def, "messages")
output_result_path = _get_output_path(self._action_def, "result")
if not response.approved:
logger.info(
"%s: MCP tool '%s' rejected: %s",
self.__class__.__name__,
approval_state.tool_name,
tool_name,
response.reason,
)
self._assign_error(
state, approval_state.output_result_path, "MCP tool invocation was not approved by user."
)
self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
await ctx.send_message(ActionComplete())
return
# Approved — re-evaluate headers (not stored at approval time for security).
headers = self._evaluate_headers(state, approval_state.headers_def)
# Approved — re-evaluate headers (not surfaced at approval time for security).
headers = self._evaluate_headers(state, self._action_def.get("headers"))
invocation = MCPToolInvocation(
server_url=approval_state.server_url,
tool_name=approval_state.tool_name,
server_label=approval_state.server_label,
arguments=approval_state.arguments,
server_url=server_url,
tool_name=tool_name,
server_label=server_label,
arguments=arguments,
headers=headers,
connection_name=approval_state.connection_name,
connection_name=connection_name,
)
result = await self._invoke_with_narrow_catch(invocation)
await self._process_result(
ctx=ctx,
state=state,
result=result,
auto_send=approval_state.auto_send,
conversation_id_expr=approval_state.conversation_id_expr,
output_messages_path=approval_state.output_messages_path,
output_result_path=approval_state.output_result_path,
auto_send=auto_send,
conversation_id_expr=conversation_id_expr,
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
await ctx.send_message(ActionComplete())
@@ -577,9 +548,6 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
return
state.set(output_result_path, f"Error: {error_message}")
def _approval_key(self) -> str:
return f"{_MCP_APPROVAL_STATE_KEY}_{self.id}"
def _parse_outputs(outputs: list[Content]) -> list[Any]:
"""Parse :class:`Content` outputs into Python values for ``output.result``.
@@ -41,10 +41,6 @@ logger = logging.getLogger(__name__)
# at runtime are discoverable by both agent-based and function-based tool executors.
FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY
# State key prefix for storing approval state during yield/resume.
# The executor's ID is appended to create a per-executor key.
TOOL_APPROVAL_STATE_KEY = "_tool_approval_state"
# ============================================================================
# Request/Response Types for Approval Flow
@@ -87,26 +83,6 @@ class ToolApprovalResponse:
reason: str | None = None
# ============================================================================
# State Types for Approval Flow
# ============================================================================
@dataclass
class ToolApprovalState:
"""State saved during approval yield for resumption.
Stored in State under a per-executor key when requireApproval=true.
Retrieved by handle_approval_response() to continue execution.
"""
function_name: str
arguments: dict[str, Any]
output_messages_var: str | None
output_result_var: str | None
auto_send: bool
# ============================================================================
# Result Types
# ============================================================================
@@ -501,25 +477,16 @@ class BaseToolExecutor(DeclarativeActionExecutor):
require_approval = self._action_def.get("requireApproval", False)
if require_approval:
# Save state for resumption (keyed by executor ID to avoid collisions)
approval_state = ToolApprovalState(
function_name=function_name,
arguments=arguments,
output_messages_var=messages_var,
output_result_var=result_var,
auto_send=auto_send,
)
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
ctx.state.set(approval_key, approval_state)
# Emit approval request - workflow yields here
# Emit approval request - the request payload is the source of
# truth for resumed invocation; no side-channel state is written.
request_id = str(uuid.uuid4())
request = ToolApprovalRequest(
request_id=str(uuid.uuid4()),
request_id=request_id,
function_name=function_name,
arguments=arguments,
)
logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
await ctx.request_info(request, ToolApprovalResponse)
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
# Workflow yields - will resume in handle_approval_response
return
@@ -545,36 +512,16 @@ class BaseToolExecutor(DeclarativeActionExecutor):
) -> None:
"""Handle response to a ToolApprovalRequest.
Called when the workflow resumes after yielding for approval.
Either executes the tool (if approved) or stores rejection status.
Resumes after the workflow yielded for approval. The invocation
``function_name`` and ``arguments`` are sourced from
``original_request`` (the payload the reviewer approved); output
configuration is re-derived from the executor's action definition.
"""
state = self._get_state(ctx.state)
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
# Retrieve saved invocation state
try:
approval_state: ToolApprovalState = ctx.state.get(approval_key)
except KeyError:
error_msg = "Approval state not found, cannot resume tool invocation"
logger.error(f"{self.__class__.__name__}: {error_msg}")
# Try to store error - get output config from action def as fallback
_, result_var, _ = self._get_output_config()
if result_var and state:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
# Clean up approval state
try:
ctx.state.delete(approval_key)
except KeyError:
logger.warning(f"{self.__class__.__name__}: approval state already deleted")
function_name = approval_state.function_name
arguments = approval_state.arguments
messages_var = approval_state.output_messages_var
result_var = approval_state.output_result_var
auto_send = approval_state.auto_send
function_name = original_request.function_name
arguments = original_request.arguments
messages_var, result_var, auto_send = self._get_output_config()
# Check if approved
if not response.approved: