Skip to content
Closed
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
61 changes: 61 additions & 0 deletions src/bedrock_agentcore/payments/integrations/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
"""Configuration for AgentCore Payments integrations (Strands and LangGraph)."""

import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional

from .._validation import validate_permit2_allowance_limit
from .handlers import PaymentResponseHandler

logger = logging.getLogger(__name__)


def resolve_authoritative_user_id(
configured_user_id: Optional[str], requested_user_id: Optional[str]
) -> Optional[str]:
"""Resolve the effective user_id for a payment tool call, config-authoritative.

Defense-in-depth: the Strands plugin and LangGraph tools expose
``user_id`` as an LLM-controllable argument. A prompt-injected agent could supply a
different ``user_id`` to attempt an identity override. The managed service already
scopes every read to the server-resolved identity, so this never crossed a tenant
boundary -- but the SDK should not let the model attempt it in the first place.

This helper makes the configured identity authoritative: a tool-supplied ``user_id``
that differs from the configured one is ignored (and logged). When no identity is
configured (bearer/JWT auth, where the server resolves the user from the token), the
requested value is passed through unchanged, since there is nothing to override.

Args:
configured_user_id: The identity from plugin/middleware config (authoritative).
requested_user_id: The user_id supplied on the tool call (LLM-controllable).

Returns:
The configured identity when set; otherwise the (stripped) requested value.
"""
requested = requested_user_id.strip() if requested_user_id else None
if configured_user_id:
if requested and requested != configured_user_id:
logger.warning(
"Ignoring tool-supplied user_id %r that differs from the configured identity; "
"using the configured identity (defense-in-depth).",
requested,
)
return configured_user_id
return requested


@dataclass
class AgentCorePaymentsPluginConfig:
Expand Down Expand Up @@ -61,6 +99,19 @@ class AgentCorePaymentsPluginConfig:
sponsors fees via methodDetails.feePayer; when the seller does not, the service
signs only if this is True and otherwise fails validation. None (default) leaves
the protocol default in place and the field unsent. Ignored for x402.
allow_unmarked_402_detection: Whether to infer an x402 payment challenge from raw
tool output that does NOT carry the trusted ``PAYMENT_REQUIRED:`` marker
(LangGraph only). Default True (current behavior). When False, a payment is
derived only from the integration's own payment-bearing tools via the marker,
so arbitrary tool content can never mint a signed authorization to an
actor-chosen recipient. CWE-863: set False to harden, or keep
True and enforce ``pre_sign_policy``.
pre_sign_policy: Optional callback invoked at the single signing chokepoint on every
detection path (marked, unmarked, and error-retry) before any payment is signed.
Receives the payment-required request (carrying ``payTo`` and amount) and returns
True to allow signing or False to block it. Raising from the callback also blocks
(fail-closed). Use it to enforce a recipient/amount allowlist regardless of the
``allow_unmarked_402_detection`` default. None (default) applies no extra gate.
"""

payment_manager_arn: str
Expand All @@ -86,6 +137,8 @@ class AgentCorePaymentsPluginConfig:
max_error_retries: int = 3
buyer_pays_gas_fees: Optional[bool] = None
permit2_allowance_limit: Optional[str] = None
allow_unmarked_402_detection: bool = True
pre_sign_policy: Optional[Callable[[Any], bool]] = None

def __post_init__(self) -> None:
"""Validate configuration after initialization."""
Expand Down Expand Up @@ -152,6 +205,14 @@ def __post_init__(self) -> None:
f"buyer_pays_gas_fees must be a boolean or None, got {type(self.buyer_pays_gas_fees).__name__}"
)

if not isinstance(self.allow_unmarked_402_detection, bool):
raise ValueError(
"allow_unmarked_402_detection must be a boolean, got "
f"{type(self.allow_unmarked_402_detection).__name__}"
)
if self.pre_sign_policy is not None and not callable(self.pre_sign_policy):
raise ValueError(f"pre_sign_policy must be callable, got {type(self.pre_sign_policy).__name__}")

validate_permit2_allowance_limit(self.permit2_allowance_limit)

def update_payment_session_id(self, payment_session_id: str) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def _detect_402(self, request: ToolCallRequest, result: ToolMessage) -> Optional
status_code = detection_handler.extract_status_code(prepared)

# Lenient fallback if marker detection didn't find 402
if status_code != 402 and not has_custom_handler:
if status_code != 402 and not has_custom_handler and self.config.allow_unmarked_402_detection:
fallback = self._fallback_detect_402(result.content)
if fallback is not None:
status_code = 402
Expand Down Expand Up @@ -336,7 +336,7 @@ def _check_retry_rejection(

_rh = GenericPaymentHandler()
retry_status = _rh.extract_status_code(retry_prepared)
if retry_status != 402:
if retry_status != 402 and self.config.allow_unmarked_402_detection:
fallback = self._fallback_detect_402(retry_result.content)
if fallback is not None:
retry_status = 402
Expand Down Expand Up @@ -383,6 +383,19 @@ def _generate_payment_header(self, payment_required_request: Dict[str, Any]) ->
The payment protocol (x402 or MPP) is detected by PaymentManager from the 402
response, so this method is protocol-agnostic.
"""
# Defense-in-depth pre-sign gate. Runs on every signing path
# (marked, unmarked, and error-retry) before any value moves. Lets integrators
# enforce a payTo/amount allowlist regardless of the detection default. The gate is
# fail-closed: returning a falsy value OR raising blocks signing.
pre_sign_policy = self.config.pre_sign_policy
if pre_sign_policy is not None:
try:
allowed = pre_sign_policy(payment_required_request)
except Exception as exc: # fail-closed: a policy error blocks signing
raise PaymentError(f"pre_sign_policy rejected the payment (raised {type(exc).__name__}).") from exc
if not allowed:
raise PaymentError("pre_sign_policy rejected the payment before signing.")

if self.config.payment_instrument_id is None:
raise PaymentInstrumentConfigurationRequired("payment_instrument_id is required for payments.")
if self.config.payment_session_id is None:
Expand Down
18 changes: 10 additions & 8 deletions src/bedrock_agentcore/payments/integrations/langgraph/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import httpx
from langchain.tools import tool

from ..config import resolve_authoritative_user_id

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -82,7 +84,7 @@ def get_payment_instrument(

Args:
payment_instrument_id: Instrument ID (falls back to middleware config).
user_id: User ID (falls back to middleware config).
user_id: User ID. The configured identity is authoritative; a differing value is ignored.
payment_connector_id: Connector ID (optional).

Returns:
Expand All @@ -91,7 +93,7 @@ def get_payment_instrument(
resolved_id = (
payment_instrument_id.strip() if payment_instrument_id else None
) or middleware.config.payment_instrument_id
resolved_user = (user_id.strip() if user_id else None) or middleware.config.user_id
resolved_user = resolve_authoritative_user_id(middleware.config.user_id, user_id)
return middleware.payment_manager.get_payment_instrument(
user_id=resolved_user,
payment_instrument_id=resolved_id,
Expand All @@ -114,15 +116,15 @@ def list_payment_instruments(
"""List all payment instruments for a user.

Args:
user_id: User ID (falls back to middleware config).
user_id: User ID. The configured identity is authoritative; a differing value is ignored.
payment_connector_id: Filter by connector (optional).
max_results: Maximum results to return (default 100).
next_token: Pagination token (optional).

Returns:
Dictionary with paymentInstruments list and optional nextToken.
"""
resolved_user = (user_id.strip() if user_id else None) or middleware.config.user_id
resolved_user = resolve_authoritative_user_id(middleware.config.user_id, user_id)
return middleware.payment_manager.list_payment_instruments(
user_id=resolved_user,
payment_connector_id=payment_connector_id,
Expand Down Expand Up @@ -151,12 +153,12 @@ def get_payment_instrument_balance(
chain: Blockchain chain (e.g., BASE_SEPOLIA, SOLANA_DEVNET).
token: Token to query (e.g., USDC).
payment_connector_id: Connector ID (falls back to config).
user_id: User ID (falls back to config).
user_id: User ID. The configured identity is authoritative; a differing value is ignored.

Returns:
Dictionary with balance information.
"""
resolved_user = (user_id.strip() if user_id else None) or middleware.config.user_id
resolved_user = resolve_authoritative_user_id(middleware.config.user_id, user_id)
resolved_connector = payment_connector_id or middleware.config.payment_connector_id
return middleware.payment_manager.get_payment_instrument_balance(
payment_connector_id=resolved_connector,
Expand All @@ -181,15 +183,15 @@ def get_payment_session(

Args:
payment_session_id: Session ID (falls back to middleware config).
user_id: User ID (falls back to middleware config).
user_id: User ID. The configured identity is authoritative; a differing value is ignored.

Returns:
Payment session details dictionary.
"""
resolved_id = (
payment_session_id.strip() if payment_session_id else None
) or middleware.config.payment_session_id
resolved_user = (user_id.strip() if user_id else None) or middleware.config.user_id
resolved_user = resolve_authoritative_user_id(middleware.config.user_id, user_id)
return middleware.payment_manager.get_payment_session(
user_id=resolved_user,
payment_session_id=resolved_id,
Expand Down
18 changes: 9 additions & 9 deletions src/bedrock_agentcore/payments/integrations/strands/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
PaymentSessionConfigurationRequired,
)

from ..config import AgentCorePaymentsPluginConfig
from ..config import AgentCorePaymentsPluginConfig, resolve_authoritative_user_id
from ..handlers import get_payment_handler
from .tools import validate_required_params

Expand Down Expand Up @@ -517,7 +517,7 @@ def get_payment_instrument(

Args:
payment_instrument_id: Payment instrument identifier (optional, falls back to plugin config)
user_id: User identifier (optional, falls back to plugin config)
user_id: User identifier (optional). The configured identity is authoritative; a differing value is ignored.
payment_connector_id: Payment connector identifier (optional)

Returns:
Expand Down Expand Up @@ -549,7 +549,7 @@ def get_payment_instrument(
"payment_instrument_id is not set. Provide it as a parameter or configure it in the plugin."
)

resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id
resolved_user_id = resolve_authoritative_user_id(self.config.user_id, user_id)

# Call PaymentManager to get instrument details
instrument_details = self.payment_manager.get_payment_instrument(
Expand Down Expand Up @@ -583,7 +583,7 @@ def list_payment_instruments(
enabling dynamic selection and management of payment methods.

Args:
user_id: User identifier (optional, falls back to plugin config)
user_id: User identifier (optional). The configured identity is authoritative; a differing value is ignored.
payment_connector_id: Filter by payment connector identifier (optional)
max_results: Maximum number of results to return (default 100)
next_token: Pagination token for retrieving next page (optional)
Expand Down Expand Up @@ -623,7 +623,7 @@ def list_payment_instruments(
if not self.payment_manager:
raise PaymentError("PaymentManager not initialized")

resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id
resolved_user_id = resolve_authoritative_user_id(self.config.user_id, user_id)

# Call PaymentManager to list instruments
instruments_list = self.payment_manager.list_payment_instruments(
Expand Down Expand Up @@ -664,7 +664,7 @@ def get_payment_instrument_balance(
chain: Blockchain chain to query (e.g., BASE_SEPOLIA, SOLANA_DEVNET)
token: Token to query balance for (e.g., USDC)
payment_connector_id: Payment connector identifier (optional, falls back to plugin config)
user_id: User identifier (optional, falls back to plugin config)
user_id: User identifier (optional). The configured identity is authoritative; a differing value is ignored.

Returns:
Dictionary containing balance information:
Expand All @@ -679,7 +679,7 @@ def get_payment_instrument_balance(
}
}
"""
resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id
resolved_user_id = resolve_authoritative_user_id(self.config.user_id, user_id)
resolved_connector_id = payment_connector_id or self.config.payment_connector_id

logger.info("Executing getPaymentInstrumentBalance for instrument %s on %s", payment_instrument_id, chain)
Expand Down Expand Up @@ -712,7 +712,7 @@ def get_payment_session(

Args:
payment_session_id: Payment session identifier (optional, falls back to plugin config)
user_id: User identifier (optional, falls back to plugin config)
user_id: User identifier (optional). The configured identity is authoritative; a differing value is ignored.

Returns:
Dictionary containing payment session details with the following structure:
Expand Down Expand Up @@ -757,7 +757,7 @@ def get_payment_session(
"payment_session_id is not set. Provide it as a parameter or configure it in the plugin."
)

resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id
resolved_user_id = resolve_authoritative_user_id(self.config.user_id, user_id)

# Call PaymentManager to get session details
session_details = self.payment_manager.get_payment_session(
Expand Down
Loading
Loading