From a477afa6d473e8f48dc09c64ddaca8a238d06092 Mon Sep 17 00:00:00 2001 From: GAUTAM MANAK <106014185+gautammanak1@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:05:43 +0530 Subject: [PATCH] fix: align mandate types with spec for HNP security - IntentMandate: id, user_authorization, Pydantic validation for unsigned vs cart-confirmation rules - PaymentMandateContents: intent_mandate_id, TransactionModality enum, HNP validation requiring intent_mandate_id when human_not_present - Shopping agent: bind payment signing to signed intent only; intent hash uses intent_mandate.id; modality and intent_mandate_id from state - Go sample structs; specification and a2a-extension doc updates - Ruff: ignore UP017 for Python 3.10; spellcheck and cspell hygiene - CI: set LINTER_RULES_PATH and MARKDOWN_CONFIG_FILE (.markdownlint.json in .github/linters); disable MD060 for wide tables --- .cspell.json | 1 + .github/linters/.markdownlint.json | 3 +- .github/workflows/linter.yaml | 3 + .ruff.toml | 1 + docs/a2a-extension.md | 5 +- docs/specification.md | 14 ++- samples/go/pkg/ap2/types/mandate.go | 4 + .../shopping_agent/subagents/shopper/tools.py | 27 +++-- .../python/src/roles/shopping_agent/tools.py | 91 +++++++++++---- src/ap2/types/mandate.py | 104 +++++++++++++++--- 10 files changed, 197 insertions(+), 56 deletions(-) diff --git a/.cspell.json b/.cspell.json index 3e21d466..5d5d5503 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,7 @@ "caseSensitive": true, "useGitignore": true, "ignorePaths": [ + ".ruff.toml", ".github/**", ".cspell/**", ".gemini/**", diff --git a/.github/linters/.markdownlint.json b/.github/linters/.markdownlint.json index 38fb124f..7609310e 100644 --- a/.github/linters/.markdownlint.json +++ b/.github/linters/.markdownlint.json @@ -6,5 +6,6 @@ }, "MD033": false, "MD046": false, - "MD024": false + "MD024": false, + "MD060": false } diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 46b22a15..c2622780 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -32,6 +32,9 @@ jobs: VALIDATE_PYTHON_PYLINT: false VALIDATE_CHECKOV: false VALIDATE_NATURAL_LANGUAGE: false + # Filename only; super-linter loads it from LINTER_RULES_PATH (default: + # `.github/linters`). A repo-root `.markdownlint.json` is not used. + LINTER_RULES_PATH: ".github/linters" MARKDOWN_CONFIG_FILE: ".markdownlint.json" VALIDATE_MARKDOWN_PRETTIER: false VALIDATE_JAVASCRIPT_PRETTIER: false diff --git a/.ruff.toml b/.ruff.toml index 57ddd437..97d80b83 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -8,6 +8,7 @@ target-version = "py312" # Minimum Python version [lint] ignore = [ + "UP017", # datetime.UTC is 3.11+; project supports Python 3.10 (use timezone.utc). "COM812", "FBT001", "FBT002", diff --git a/docs/a2a-extension.md b/docs/a2a-extension.md index 97a37251..2ebe3b64 100644 --- a/docs/a2a-extension.md +++ b/docs/a2a-extension.md @@ -128,11 +128,12 @@ The following listing shows the JSON rendering of an IntentMandate Message. "kind": "data", "data": { "ap2.mandates.IntentMandate": { - "user_cart_confirmation_required": false, + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_cart_confirmation_required": true, "natural_language_description": "I'd like some cool red shoes in my size", "merchants": null, "skus": null, - "required_refundability": true, + "requires_refundability": true, "intent_expiry": "2025-09-16T15:00:00Z" } } diff --git a/docs/specification.md b/docs/specification.md index 44d92b8e..b437df4f 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -282,11 +282,15 @@ Mandate, the Intent Mandate and the Payment Mandate. The Cart Mandate is the foundational credential that captures the user's authorization for a purchase when the human is present at the time of purchase ([see illustrative user journey](#51-human-present-transaction)). It is -generated by the Merchant based on the user's request and is cryptographically -signed by the user, typically using a hardware-backed key on their device with -in-session authentication. This signature binds the user's identity and -authorization to their intent. The Cart Mandate is a structured object -containing critical parameters that define the scope of the transaction. +generated by the Merchant based on the user's request. The merchant first signs +the Cart Mandate (see Step 10 in [Section 7.1](#71-illustrative-transaction-flow)) +to guarantee fulfillment. The user then approves the merchant-signed Cart Mandate by +including its hash in their Payment Mandate signature, typically using a +hardware-backed key on their device with in-session authentication. That +Payment Mandate binding (not a separate field on the Cart Mandate object) +anchors the user's approval to the merchant-signed cart. The Cart Mandate is a +structured object containing critical parameters that define the scope of the +transaction. A Cart Mandate contains the following bound information: diff --git a/samples/go/pkg/ap2/types/mandate.go b/samples/go/pkg/ap2/types/mandate.go index 4aa6701c..b87a6e55 100644 --- a/samples/go/pkg/ap2/types/mandate.go +++ b/samples/go/pkg/ap2/types/mandate.go @@ -23,12 +23,14 @@ const ( ) type IntentMandate struct { + ID string `json:"id,omitempty"` UserCartConfirmationRequired *bool `json:"user_cart_confirmation_required,omitempty"` NaturalLanguageDescription string `json:"natural_language_description"` Merchants []string `json:"merchants,omitempty"` SKUs []string `json:"skus,omitempty"` RequiresRefundability *bool `json:"requires_refundability,omitempty"` IntentExpiry string `json:"intent_expiry"` + UserAuthorization *string `json:"user_authorization,omitempty"` } func NewIntentMandate() *IntentMandate { @@ -75,6 +77,8 @@ type PaymentMandateContents struct { PaymentDetailsTotal PaymentItem `json:"payment_details_total"` PaymentResponse PaymentResponse `json:"payment_response"` MerchantAgent string `json:"merchant_agent"` + IntentMandateID *string `json:"intent_mandate_id,omitempty"` + TransactionModality *string `json:"transaction_modality,omitempty"` Timestamp string `json:"timestamp,omitempty"` } diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index cee16eff..3705ad68 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -18,23 +18,23 @@ shopping and purchasing process. """ -from datetime import datetime -from datetime import timedelta -from datetime import timezone +from datetime import datetime, timedelta, timezone from a2a.types import Artifact -from google.adk.tools.tool_context import ToolContext - -from ap2.types.mandate import CART_MANDATE_DATA_KEY -from ap2.types.mandate import CartMandate -from ap2.types.mandate import INTENT_MANDATE_DATA_KEY -from ap2.types.mandate import IntentMandate from common.a2a_message_builder import A2aMessageBuilder from common.artifact_utils import find_canonical_objects +from google.adk.tools.tool_context import ToolContext from roles.shopping_agent.remote_agents import merchant_agent_client +from ap2.types.mandate import ( + CART_MANDATE_DATA_KEY, + INTENT_MANDATE_DATA_KEY, + CartMandate, + IntentMandate, +) + -def create_intent_mandate( +def create_intent_mandate( # noqa: PLR0913 natural_language_description: str, user_cart_confirmation_required: bool, merchants: list[str], @@ -55,6 +55,12 @@ def create_intent_mandate( Returns: An IntentMandate object valid for 1 day. """ + # Sample placeholder JWT when the user allows agent checkout without + # per-cart confirmation; production must use a real hardware-backed signature. + user_authorization = None + if not user_cart_confirmation_required: + # Sample placeholder; production must use a real hardware-backed JWT. + user_authorization = "dev.placeholder.intent_mandate_user_authorization_jwt" intent_mandate = IntentMandate( natural_language_description=natural_language_description, user_cart_confirmation_required=user_cart_confirmation_required, @@ -64,6 +70,7 @@ def create_intent_mandate( intent_expiry=( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), + user_authorization=user_authorization, ) tool_context.state["intent_mandate"] = intent_mandate return intent_mandate diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 1d8b7bf1..d8764692 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -18,27 +18,32 @@ shopping and purchasing process, such as updating a cart or initiating payment. """ -from datetime import datetime -from datetime import timezone import os import uuid +from datetime import datetime, timezone + from a2a.types import Artifact +from common import artifact_utils +from common.a2a_message_builder import A2aMessageBuilder from google.adk.tools.tool_context import ToolContext +from roles.shopping_agent.remote_agents import ( + credentials_provider_client, + merchant_agent_client, +) -from .remote_agents import credentials_provider_client -from .remote_agents import merchant_agent_client from ap2.types.contact_picker import ContactAddress -from ap2.types.mandate import CART_MANDATE_DATA_KEY -from ap2.types.mandate import CartMandate -from ap2.types.mandate import PAYMENT_MANDATE_DATA_KEY -from ap2.types.mandate import PaymentMandate -from ap2.types.mandate import PaymentMandateContents -from ap2.types.payment_receipt import PAYMENT_RECEIPT_DATA_KEY -from ap2.types.payment_receipt import PaymentReceipt +from ap2.types.mandate import ( + CART_MANDATE_DATA_KEY, + PAYMENT_MANDATE_DATA_KEY, + CartMandate, + IntentMandate, + PaymentMandate, + PaymentMandateContents, + TransactionModality, +) +from ap2.types.payment_receipt import PAYMENT_RECEIPT_DATA_KEY, PaymentReceipt from ap2.types.payment_request import PaymentResponse -from common import artifact_utils -from common.a2a_message_builder import A2aMessageBuilder async def update_cart( @@ -118,10 +123,10 @@ async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): async def initiate_payment_with_otp( challenge_response: str, tool_context: ToolContext, debug_mode: bool = False ): - """Initiates a payment using the payment mandate from state and a + """Initiates payment with the mandate from state and an OTP challenge. - challenge response. In our sample, the challenge response is a one-time - password (OTP) sent to the user. + In our sample, the challenge response is a one-time password (OTP) sent to + the user. Args: challenge_response: The challenge response. @@ -204,6 +209,18 @@ def create_payment_mandate( payer_email=user_email, ) + intent_mandate: IntentMandate | None = tool_context.state.get( + "intent_mandate", + ) + intent_mandate_id: str | None = None + transaction_modality: TransactionModality | None = None + if intent_mandate: + if intent_mandate.user_authorization: + intent_mandate_id = intent_mandate.id + transaction_modality = TransactionModality.HUMAN_NOT_PRESENT + else: + transaction_modality = TransactionModality.HUMAN_PRESENT + payment_mandate = PaymentMandate( payment_mandate_contents=PaymentMandateContents( payment_mandate_id=uuid.uuid4().hex, @@ -212,6 +229,8 @@ def create_payment_mandate( payment_details_total=payment_request.details.total, payment_response=payment_response, merchant_agent=cart_mandate.contents.merchant_name, + intent_mandate_id=intent_mandate_id, + transaction_modality=transaction_modality, ), ) @@ -232,24 +251,30 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: concatenating the mandate hashes. Args: - tool_context: The context object used for state management. It is expected - to contain the `payment_mandate` and `cart_mandate`. + tool_context: The context object used for state management. Expected to + contain the `payment_mandate` and `cart_mandate`. Returns: A string representing the simulated user authorization signature (JWT). """ payment_mandate: PaymentMandate = tool_context.state["payment_mandate"] cart_mandate: CartMandate = tool_context.state["cart_mandate"] + intent_mandate: IntentMandate | None = tool_context.state.get( + "intent_mandate", + ) cart_mandate_hash = _generate_cart_mandate_hash(cart_mandate) payment_mandate_hash = _generate_payment_mandate_hash( payment_mandate.payment_mandate_contents ) - # A JWT containing the user's digital signature to authorize the transaction. - # The payload uses hashes to bind the signature to the specific cart and + # A JWT containing the user's digital signature to authorize the + # transaction. The payload uses hashes to bind the signature to the cart and # payment details, and includes a nonce to prevent replay attacks. - payment_mandate.user_authorization = ( - cart_mandate_hash + "_" + payment_mandate_hash - ) + hashes = [cart_mandate_hash, payment_mandate_hash] + # When the intent mandate is user-signed (user_authorization set), bind the + # payment authorization to that intent for Human Not Present scenarios. + if intent_mandate and intent_mandate.user_authorization: + hashes.append(_generate_intent_mandate_hash(intent_mandate)) + payment_mandate.user_authorization = "_".join(hashes) tool_context.state["signed_payment_mandate"] = payment_mandate return payment_mandate.user_authorization @@ -302,6 +327,26 @@ def _generate_cart_mandate_hash(cart_mandate: CartMandate) -> str: return "fake_cart_mandate_hash_" + cart_mandate.contents.id +def _generate_intent_mandate_hash(intent_mandate: IntentMandate) -> str: + """Generates a cryptographic hash of the IntentMandate. + + This hash binds the user's payment authorization to the specific + user-signed Intent Mandate, ensuring that Human Not Present transactions + can be traced back to a verified user intent. + + Note: This is a placeholder implementation for development. A real + implementation must use a secure hashing algorithm (e.g., SHA-256) on the + canonical representation of the IntentMandate object. + + Args: + intent_mandate: The IntentMandate object to hash. + + Returns: + A string representing the hash of the intent mandate. + """ + return "fake_intent_mandate_hash_" + intent_mandate.id + + def _generate_payment_mandate_hash( payment_mandate_contents: PaymentMandateContents, ) -> str: diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index c5506689..7f0a40f6 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -14,21 +14,32 @@ """Contains the definitions of the Agent Payments Protocol mandates.""" -from datetime import datetime -from datetime import timezone -from typing import Optional +import uuid + +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, model_validator + +from ap2.types.payment_request import ( + PaymentItem, + PaymentRequest, + PaymentResponse, +) -from ap2.types.payment_request import PaymentItem -from ap2.types.payment_request import PaymentRequest -from ap2.types.payment_request import PaymentResponse -from pydantic import BaseModel -from pydantic import Field CART_MANDATE_DATA_KEY = "ap2.mandates.CartMandate" INTENT_MANDATE_DATA_KEY = "ap2.mandates.IntentMandate" PAYMENT_MANDATE_DATA_KEY = "ap2.mandates.PaymentMandate" +class TransactionModality(str, Enum): + """Whether the user was present at payment authorization time.""" + + HUMAN_PRESENT = "human_present" + HUMAN_NOT_PRESENT = "human_not_present" + + class IntentMandate(BaseModel): """Represents the user's purchase intent. @@ -36,6 +47,11 @@ class IntentMandate(BaseModel): human-not-present flows, additional fields will be added to this mandate. """ + id: str = Field( + default_factory=lambda: uuid.uuid4().hex, + description="Unique identifier for this intent mandate, referenced by " + "PaymentMandateContents.intent_mandate_id in Human Not Present flows.", + ) user_cart_confirmation_required: bool = Field( True, description=( @@ -53,20 +69,20 @@ class IntentMandate(BaseModel): ), example="High top, old school, red basketball shoes", ) - merchants: Optional[list[str]] = Field( + merchants: list[str] | None = Field( None, description=( "Merchants allowed to fulfill the intent. If not set, the shopping" " agent is able to work with any suitable merchant." ), ) - skus: Optional[list[str]] = Field( + skus: list[str] | None = Field( None, description=( "A list of specific product SKUs. If not set, any SKU is allowed." ), ) - requires_refundability: Optional[bool] = Field( + requires_refundability: bool | None = Field( False, description="If true, items must be refundable.", ) @@ -74,6 +90,33 @@ class IntentMandate(BaseModel): ..., description="When the intent mandate expires, in ISO 8601 format.", ) + user_authorization: str | None = Field( + None, + description=( + "A base64url-encoded JSON Web Token (JWT) that digitally signs the " + "intent mandate contents by the user's private key. This provides " + "non-repudiable proof of the user's intent and prevents tampering " + "by the shopping agent. " + "If this field is present, user_cart_confirmation_required can be " + "set to false, allowing the agent to execute purchases in the " + "user's absence. " + "If this field is None, user_cart_confirmation_required must be " + "true, requiring the user to confirm each specific purchase." + ), + example="eyJhbGciOiJFUzI1NksiLCJraWQiOiJkaWQ6ZXhhbXBsZ...", + ) + + @model_validator(mode="after") + def _validate_user_authorization_rules(self) -> "IntentMandate": + if ( + self.user_authorization is None + and not self.user_cart_confirmation_required + ): + raise ValueError( + "user_cart_confirmation_required must be True when " + "user_authorization is not provided" + ) + return self class CartContents(BaseModel): @@ -111,7 +154,7 @@ class CartMandate(BaseModel): """ contents: CartContents = Field(..., description="The contents of the cart.") - merchant_authorization: Optional[str] = Field( + merchant_authorization: str | None = Field( None, description=(""" A base64url-encoded JSON Web Token (JWT) that digitally signs the cart contents, guaranteeing its authenticity and integrity: @@ -154,6 +197,24 @@ class PaymentMandateContents(BaseModel): ), ) merchant_agent: str = Field(..., description="Identifier for the merchant.") + intent_mandate_id: str | None = Field( + None, + description=( + "Reference to the user-signed Intent Mandate that authorizes " + "this transaction in Human Not Present scenarios. This allows " + "the payment network to verify that the 'human not present' " + "transaction has pre-authorization support from a 'human present' " + "intent mandate. Required when transaction_modality is " + "human_not_present." + ), + ) + transaction_modality: TransactionModality | None = Field( + None, + description=( + "Transaction modality: whether the user was present at payment " + "authorization time. Signals to the payment network in agentic flows." + ), + ) timestamp: str = Field( description=( "The date and time the mandate was created, in ISO 8601 format." @@ -161,6 +222,18 @@ class PaymentMandateContents(BaseModel): default_factory=lambda: datetime.now(timezone.utc).isoformat(), ) + @model_validator(mode="after") + def _validate_hnp_intent_reference(self) -> "PaymentMandateContents": + if ( + self.transaction_modality == TransactionModality.HUMAN_NOT_PRESENT + and not self.intent_mandate_id + ): + raise ValueError( + "intent_mandate_id is required when transaction_modality is " + "human_not_present" + ) + return self + class PaymentMandate(BaseModel): """Contains the user's instructions & authorization for payment. @@ -178,7 +251,7 @@ class PaymentMandate(BaseModel): ..., description="The data contents of the payment mandate.", ) - user_authorization: Optional[str] = Field( + user_authorization: str | None = Field( None, description=( """ @@ -191,8 +264,9 @@ class PaymentMandate(BaseModel): "aud": ... "nonce": ... "sd_hash": hash of the issuer-signed jwt - "transaction_data": an array containing the secure hashes of - CartMandate and PaymentMandateContents. + "transaction_data": an array containing the secure hashes of + CartMandate and PaymentMandateContents, and when applicable + the Intent Mandate (Human Not Present flows). """ ),