diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..33384f96 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -111,6 +111,7 @@ multistep Mysten nexi nosetests +Nygard Nuvei objx octicons @@ -149,6 +150,7 @@ ropeproject RPCURL Rulebook screenreaders +sess setlocal sharedpref Shopcider @@ -167,6 +169,7 @@ stretchr superfences Truelayer Trulioo +txns udpa unmarshal viewmodel diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..1ef0b7cc --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,24 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks + +title = "AP2 Gitleaks Configuration" + +[extend] +useDefault = true + +# Allowlist for false positives +[allowlist] +description = "Allowlist for AP2 data key constants" + +# AP2 data key patterns (namespace identifiers, not secrets) +regexTarget = "match" +regexes = [ + '''ap2\.(risk|mandates|types|models)\.[A-Za-z]+''', +] + +# Specific paths with data key constants +paths = [ + '''code/samples/go/pkg/ap2/types/.*\.go''', + '''code/sdk/python/ap2/models/.*\.py''', + '''code/sdk/python/ap2/tests/.*_tests\.py''', +] diff --git a/code/samples/go/pkg/ap2/types/mandate.go b/code/samples/go/pkg/ap2/types/mandate.go index 4aa6701c..e9d1ed84 100644 --- a/code/samples/go/pkg/ap2/types/mandate.go +++ b/code/samples/go/pkg/ap2/types/mandate.go @@ -23,6 +23,9 @@ const ( ) type IntentMandate struct { + // RiskPayload carries optional FCB risk signals (AP2 v0.1 Section 7.4). + RiskPayload *RiskPayload `json:"risk_payload,omitempty"` + UserCartConfirmationRequired *bool `json:"user_cart_confirmation_required,omitempty"` NaturalLanguageDescription string `json:"natural_language_description"` Merchants []string `json:"merchants,omitempty"` @@ -67,6 +70,8 @@ type CartContents struct { type CartMandate struct { Contents CartContents `json:"contents"` MerchantAuthorization *string `json:"merchant_authorization,omitempty"` + // RiskPayload carries optional FCB risk signals (AP2 v0.1 Section 7.4). + RiskPayload *RiskPayload `json:"risk_payload,omitempty"` } type PaymentMandateContents struct { @@ -76,6 +81,8 @@ type PaymentMandateContents struct { PaymentResponse PaymentResponse `json:"payment_response"` MerchantAgent string `json:"merchant_agent"` Timestamp string `json:"timestamp,omitempty"` + // RiskPayload carries optional FCB risk signals (AP2 v0.1 Section 7.4). + RiskPayload *RiskPayload `json:"risk_payload,omitempty"` } func NewPaymentMandateContents() *PaymentMandateContents { diff --git a/code/samples/go/pkg/ap2/types/risk.go b/code/samples/go/pkg/ap2/types/risk.go new file mode 100644 index 00000000..7040ba49 --- /dev/null +++ b/code/samples/go/pkg/ap2/types/risk.go @@ -0,0 +1,224 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package types provides Fiduciary Circuit Breaker (FCB) risk types for AP2. +// +// This file implements structured types for the Risk Payload referenced in +// AP2 v0.1 Section 7.4 (Risk Signals). The FCB pattern provides runtime +// governance for autonomous agent transactions through: +// - Trip conditions that evaluate agent behavior against predefined thresholds +// - A state machine for governance (CLOSED → OPEN → HALF_OPEN) +// - Human escalation protocol for exceptional cases +// - Structured risk signals for network/issuer visibility +package types + +import "time" + +// Data key prefix for risk types. +const riskDataKeyPrefix = "ap2.risk." + +// Data keys for AP2 message data parts. +var ( + RiskPayloadDataKey = riskDataKeyPrefix + "RiskPayload" + FCBEvaluationDataKey = riskDataKeyPrefix + "FCBEvaluation" +) + +// TripConditionType represents categories of runtime risk checks. +type TripConditionType string + +const ( + // TripConditionValueThreshold - Single transaction exceeds monetary limit. + TripConditionValueThreshold TripConditionType = "VALUE_THRESHOLD" + + // TripConditionCumulativeThreshold - Running totals exceed threshold. + TripConditionCumulativeThreshold TripConditionType = "CUMULATIVE_THRESHOLD" + + // TripConditionVelocity - Too many actions in a time window. + TripConditionVelocity TripConditionType = "VELOCITY" + + // TripConditionAuthorityScope - Action outside delegated domain. + TripConditionAuthorityScope TripConditionType = "AUTHORITY_SCOPE" + + // TripConditionAnomaly - ML model detects unusual pattern. + TripConditionAnomaly TripConditionType = "ANOMALY" + + // TripConditionTimeBased - Action during restricted time period. + TripConditionTimeBased TripConditionType = "TIME_BASED" + + // TripConditionDeviation - Significant departure from baseline. + TripConditionDeviation TripConditionType = "DEVIATION" + + // TripConditionVendorTrust - Transaction with untrusted counterparty. + TripConditionVendorTrust TripConditionType = "VENDOR_TRUST" + + // TripConditionCredentialCheck - Static identity/credential verification (e.g., wallet attestation, KYB check). + TripConditionCredentialCheck TripConditionType = "CREDENTIAL_CHECK" + + // TripConditionCustom - Implementation-specific trip condition. + TripConditionCustom TripConditionType = "CUSTOM" +) + +// TripConditionStatus represents the result of evaluating a trip condition. +type TripConditionStatus string + +const ( + // TripConditionStatusPass - Condition satisfied, no risk detected. + TripConditionStatusPass TripConditionStatus = "PASS" + + // TripConditionStatusFail - Condition failed, FCB should trip. + TripConditionStatusFail TripConditionStatus = "FAIL" + + // TripConditionStatusWarning - Approaching threshold. + TripConditionStatusWarning TripConditionStatus = "WARNING" +) + +// FCBState represents states of the Fiduciary Circuit Breaker. +type FCBState string + +const ( + // FCBStateClosed - Normal operation, agent acts autonomously. + FCBStateClosed FCBState = "CLOSED" + + // FCBStateOpen - All actions blocked, requires human review. + FCBStateOpen FCBState = "OPEN" + + // FCBStateHalfOpen - Limited operations with enhanced monitoring. + FCBStateHalfOpen FCBState = "HALF_OPEN" + + // FCBStateTerminated - Permanently halted, no recovery. + FCBStateTerminated FCBState = "TERMINATED" +) + +// AgentModality indicates whether human is present during transaction. +type AgentModality string + +const ( + // AgentModalityHumanPresent - User is in-session. + AgentModalityHumanPresent AgentModality = "HUMAN_PRESENT" + + // AgentModalityHumanNotPresent - User delegated task, not in-session. + AgentModalityHumanNotPresent AgentModality = "HUMAN_NOT_PRESENT" +) + +// EscalationDecision represents human approver's decision. +type EscalationDecision string + +const ( + // EscalationDecisionApprove - Action approved, FCB returns to CLOSED. + EscalationDecisionApprove EscalationDecision = "APPROVE" + + // EscalationDecisionApproveWithConditions - Approved with monitoring. + EscalationDecisionApproveWithConditions EscalationDecision = "APPROVE_WITH_CONDITIONS" + + // EscalationDecisionReject - Action rejected, FCB terminates. + EscalationDecisionReject EscalationDecision = "REJECT" + + // EscalationDecisionEscalateFurther - Forward to higher authority. + EscalationDecisionEscalateFurther EscalationDecision = "ESCALATE_FURTHER" + + // EscalationDecisionModifyAndApprove - Adjust parameters, then approve. + EscalationDecisionModifyAndApprove EscalationDecision = "MODIFY_AND_APPROVE" +) + +// TripConditionResult captures the outcome of one risk check. +type TripConditionResult struct { + ConditionType TripConditionType `json:"condition_type"` // Type of condition evaluated. + Status TripConditionStatus `json:"status"` // Pass, fail, or warning. + Threshold *float64 `json:"threshold,omitempty"` // Limit checked against. + ActualValue *float64 `json:"actual_value,omitempty"` // Observed value. + Message *string `json:"message,omitempty"` // Human-readable explanation. + Suggestion *string `json:"suggestion,omitempty"` // Suggested resolution. +} + +// HumanEscalation captures details when FCB trips and requires human review. +type HumanEscalation struct { + EscalationID string `json:"escalation_id"` // Unique ID for this escalation. + TriggeredAt string `json:"triggered_at,omitempty"` // When triggered (RFC3339). + ApproverID *string `json:"approver_id,omitempty"` // Reviewer who handled this. + Decision *EscalationDecision `json:"decision,omitempty"` // Approver's decision. + DecidedAt *string `json:"decided_at,omitempty"` // When decided (RFC3339). + Conditions []string `json:"conditions,omitempty"` // Conditions for conditional approval. + Notes *string `json:"notes,omitempty"` // Approver notes. + TimeoutAt *string `json:"timeout_at,omitempty"` // Deadline for resolution (RFC3339). + DefaultActionOnTimeout *EscalationDecision `json:"default_action_on_timeout,omitempty"` // Action if timeout expires. +} + +// NewHumanEscalation creates a new HumanEscalation with timestamp. +func NewHumanEscalation(escalationID string) *HumanEscalation { + defaultAction := EscalationDecisionReject + return &HumanEscalation{ + EscalationID: escalationID, + TriggeredAt: time.Now().UTC().Format(time.RFC3339), + DefaultActionOnTimeout: &defaultAction, + } +} + +// FCBEvaluation contains complete FCB evaluation results. +type FCBEvaluation struct { + FCBState FCBState `json:"fcb_state"` // Current FCB state. + PreviousState *FCBState `json:"previous_state,omitempty"` // State before this evaluation. + TripsEvaluated int `json:"trips_evaluated"` // Total conditions checked. + TripsTriggered int `json:"trips_triggered"` // Conditions that triggered. + TripResults []TripConditionResult `json:"trip_results,omitempty"` // Results; use AddTripResult to update. + RiskScore *float64 `json:"risk_score,omitempty"` // Aggregate score 0.0-1.0. + HumanEscalation *HumanEscalation `json:"human_escalation,omitempty"` // Escalation if FCB tripped. + EvaluatedAt string `json:"evaluated_at,omitempty"` // When evaluated (RFC3339). +} + +// NewFCBEvaluation creates a new FCBEvaluation with timestamp. +func NewFCBEvaluation(state FCBState) *FCBEvaluation { + return &FCBEvaluation{ + FCBState: state, + TripResults: []TripConditionResult{}, + EvaluatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +// AddTripResult adds a trip condition result and updates counters. +func (e *FCBEvaluation) AddTripResult(result TripConditionResult) { + e.TripResults = append(e.TripResults, result) + e.TripsEvaluated++ + if result.Status == TripConditionStatusFail || result.Status == TripConditionStatusWarning { + e.TripsTriggered++ + } +} + +// HasTripped returns true if any trip condition failed. +func (e *FCBEvaluation) HasTripped() bool { + for _, r := range e.TripResults { + if r.Status == TripConditionStatusFail { + return true + } + } + return false +} + +// RiskPayload is the container for risk signals in AP2 messages. +type RiskPayload struct { + FCBEvaluation *FCBEvaluation `json:"fcb_evaluation,omitempty"` // FCB evaluation results. + AgentModality AgentModality `json:"agent_modality"` // Human present or not. + AgentID *string `json:"agent_id,omitempty"` // Agent identifier. + AgentType *string `json:"agent_type,omitempty"` // Agent category. + SessionID *string `json:"session_id,omitempty"` // Session for correlation. + CumulativeSessionValue *float64 `json:"cumulative_session_value,omitempty"` // Total session value so far. + TransactionCountToday *int `json:"transaction_count_today,omitempty"` // Transactions today. + CustomSignals map[string]any `json:"custom_signals,omitempty"` // Implementation-specific signals. +} + +// NewRiskPayload creates a new RiskPayload with default modality. +func NewRiskPayload(modality AgentModality) *RiskPayload { + return &RiskPayload{ + AgentModality: modality, + } +} diff --git a/code/samples/go/pkg/ap2/types/risk_test.go b/code/samples/go/pkg/ap2/types/risk_test.go new file mode 100644 index 00000000..65528df6 --- /dev/null +++ b/code/samples/go/pkg/ap2/types/risk_test.go @@ -0,0 +1,436 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTripConditionTypes(t *testing.T) { + expectedTypes := []TripConditionType{ + TripConditionValueThreshold, + TripConditionCumulativeThreshold, + TripConditionVelocity, + TripConditionAuthorityScope, + TripConditionAnomaly, + TripConditionTimeBased, + TripConditionDeviation, + TripConditionVendorTrust, + TripConditionCredentialCheck, + TripConditionCustom, + } + + for _, ct := range expectedTypes { + if ct == "" { + t.Errorf("TripConditionType should not be empty") + } + } + + // Verify string values match expected + if TripConditionValueThreshold != "VALUE_THRESHOLD" { + t.Errorf("Expected VALUE_THRESHOLD, got %s", TripConditionValueThreshold) + } + if TripConditionCumulativeThreshold != "CUMULATIVE_THRESHOLD" { + t.Errorf("Expected CUMULATIVE_THRESHOLD, got %s", TripConditionCumulativeThreshold) + } + if TripConditionCredentialCheck != "CREDENTIAL_CHECK" { + t.Errorf("Expected CREDENTIAL_CHECK, got %s", TripConditionCredentialCheck) + } +} + +func TestFCBStates(t *testing.T) { + states := []struct { + state FCBState + expected string + }{ + {FCBStateClosed, "CLOSED"}, + {FCBStateOpen, "OPEN"}, + {FCBStateHalfOpen, "HALF_OPEN"}, + {FCBStateTerminated, "TERMINATED"}, + } + + for _, tc := range states { + if string(tc.state) != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, tc.state) + } + } +} + +func TestNewHumanEscalation(t *testing.T) { + escalation := NewHumanEscalation("esc-12345") + + if escalation.EscalationID != "esc-12345" { + t.Errorf("Expected escalation ID 'esc-12345', got '%s'", escalation.EscalationID) + } + + if escalation.TriggeredAt == "" { + t.Error("Expected triggered_at to be set") + } + + if escalation.DefaultActionOnTimeout == nil { + t.Error("Expected default_action_on_timeout to be set") + } + + if *escalation.DefaultActionOnTimeout != EscalationDecisionReject { + t.Errorf("Expected default action REJECT, got %s", *escalation.DefaultActionOnTimeout) + } +} + +func TestNewFCBEvaluation(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + if eval.FCBState != FCBStateClosed { + t.Errorf("Expected state CLOSED, got %s", eval.FCBState) + } + + if eval.TripResults == nil { + t.Error("Expected TripResults to be initialized") + } + + if len(eval.TripResults) != 0 { + t.Errorf("Expected empty TripResults, got %d", len(eval.TripResults)) + } + + if eval.EvaluatedAt == "" { + t.Error("Expected evaluated_at to be set") + } +} + +func TestFCBEvaluationAddTripResult(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + // Add a passing result + passResult := TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + } + eval.AddTripResult(passResult) + + if eval.TripsEvaluated != 1 { + t.Errorf("Expected 1 trip evaluated, got %d", eval.TripsEvaluated) + } + if eval.TripsTriggered != 0 { + t.Errorf("Expected 0 trips triggered, got %d", eval.TripsTriggered) + } + + // Add a failing result + threshold := 10000.0 + actual := 15000.0 + failResult := TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold, + ActualValue: &actual, + } + eval.AddTripResult(failResult) + + if eval.TripsEvaluated != 2 { + t.Errorf("Expected 2 trips evaluated, got %d", eval.TripsEvaluated) + } + if eval.TripsTriggered != 1 { + t.Errorf("Expected 1 trip triggered, got %d", eval.TripsTriggered) + } + + // Add a warning result + warnResult := TripConditionResult{ + ConditionType: TripConditionVelocity, + Status: TripConditionStatusWarning, + } + eval.AddTripResult(warnResult) + + if eval.TripsTriggered != 2 { + t.Errorf("Expected 2 trips triggered (fail + warning), got %d", eval.TripsTriggered) + } +} + +func TestFCBEvaluationHasTripped(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + // Initially should not have tripped + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with no results") + } + + // Add passing result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + }) + + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with only PASS") + } + + // Add warning result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionVelocity, + Status: TripConditionStatusWarning, + }) + + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with WARNING (not FAIL)") + } + + // Add failing result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + }) + + if !eval.HasTripped() { + t.Error("Expected HasTripped to be true after FAIL") + } +} + +func TestNewRiskPayload(t *testing.T) { + payload := NewRiskPayload(AgentModalityHumanPresent) + + if payload.AgentModality != AgentModalityHumanPresent { + t.Errorf("Expected HUMAN_PRESENT, got %s", payload.AgentModality) + } + + payloadNotPresent := NewRiskPayload(AgentModalityHumanNotPresent) + if payloadNotPresent.AgentModality != AgentModalityHumanNotPresent { + t.Errorf("Expected HUMAN_NOT_PRESENT, got %s", payloadNotPresent.AgentModality) + } +} + +func TestRiskPayloadJSONSerialization(t *testing.T) { + agentID := "agent-shopping-001" + sessionID := "sess-abc123" + cumulative := 500.0 + txnCount := 3 + + payload := &RiskPayload{ + FCBEvaluation: NewFCBEvaluation(FCBStateClosed), + AgentModality: AgentModalityHumanNotPresent, + AgentID: &agentID, + SessionID: &sessionID, + CumulativeSessionValue: &cumulative, + TransactionCountToday: &txnCount, + CustomSignals: map[string]any{ + "merchant_trust_score": 0.95, + "buyer_tier": "enterprise", + }, + } + + // Serialize to JSON + jsonBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal RiskPayload: %v", err) + } + + // Deserialize back + var decoded RiskPayload + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal RiskPayload: %v", err) + } + + // Verify fields + if decoded.AgentModality != AgentModalityHumanNotPresent { + t.Errorf("Expected HUMAN_NOT_PRESENT, got %s", decoded.AgentModality) + } + + if decoded.AgentID == nil || *decoded.AgentID != agentID { + t.Errorf("Expected agent ID %s, got %v", agentID, decoded.AgentID) + } + + if decoded.FCBEvaluation == nil { + t.Error("Expected FCBEvaluation to be present") + } + + if decoded.CustomSignals == nil { + t.Error("Expected CustomSignals to be present") + } +} + +func TestTripConditionResultJSONSerialization(t *testing.T) { + threshold := 50000.0 + actual := 75000.0 + message := "Transaction exceeds daily limit" + suggestion := "Request manager approval" + + result := TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold, + ActualValue: &actual, + Message: &message, + Suggestion: &suggestion, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to marshal TripConditionResult: %v", err) + } + + var decoded TripConditionResult + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal TripConditionResult: %v", err) + } + + if decoded.ConditionType != TripConditionCumulativeThreshold { + t.Errorf("Expected CUMULATIVE_THRESHOLD, got %s", decoded.ConditionType) + } + + if decoded.Status != TripConditionStatusFail { + t.Errorf("Expected FAIL, got %s", decoded.Status) + } + + if decoded.Threshold == nil || *decoded.Threshold != threshold { + t.Errorf("Expected threshold %f, got %v", threshold, decoded.Threshold) + } +} + +func TestDataKeysConstant(t *testing.T) { + if RiskPayloadDataKey != "ap2.risk.RiskPayload" { + t.Errorf("Unexpected RiskPayloadDataKey: %s", RiskPayloadDataKey) + } + + if FCBEvaluationDataKey != "ap2.risk.FCBEvaluation" { + t.Errorf("Unexpected FCBEvaluationDataKey: %s", FCBEvaluationDataKey) + } +} + +func TestCompleteEvaluationScenario(t *testing.T) { + // Simulate a complete FCB evaluation scenario + + // 1. Create evaluation in CLOSED state + eval := NewFCBEvaluation(FCBStateClosed) + + // 2. Evaluate VALUE_THRESHOLD - passes + threshold1 := 10000.0 + actual1 := 8500.0 + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + Threshold: &threshold1, + ActualValue: &actual1, + }) + + // 3. Evaluate CUMULATIVE - fails + threshold2 := 50000.0 + actual2 := 65000.0 + msg := "Daily cumulative limit exceeded" + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold2, + ActualValue: &actual2, + Message: &msg, + }) + + // 4. Evaluation should have tripped + if !eval.HasTripped() { + t.Error("Expected evaluation to have tripped") + } + + // 5. Transition state to OPEN + eval.PreviousState = &eval.FCBState + eval.FCBState = FCBStateOpen + + // 6. Create escalation + eval.HumanEscalation = NewHumanEscalation("esc-001") + + // 7. Wrap in RiskPayload + agentID := "shopping-agent-prod" + payload := &RiskPayload{ + FCBEvaluation: eval, + AgentModality: AgentModalityHumanNotPresent, + AgentID: &agentID, + } + + // 8. Verify full scenario + if payload.FCBEvaluation.FCBState != FCBStateOpen { + t.Errorf("Expected OPEN state, got %s", payload.FCBEvaluation.FCBState) + } + + if payload.FCBEvaluation.TripsEvaluated != 2 { + t.Errorf("Expected 2 evaluations, got %d", payload.FCBEvaluation.TripsEvaluated) + } + + if payload.FCBEvaluation.TripsTriggered != 1 { + t.Errorf("Expected 1 triggered, got %d", payload.FCBEvaluation.TripsTriggered) + } + + if payload.FCBEvaluation.HumanEscalation == nil { + t.Error("Expected HumanEscalation to be set") + } +} + +func TestMandatesRiskPayload(t *testing.T) { + agentID := "agent-001" + risk := NewRiskPayload(AgentModalityHumanNotPresent) + risk.AgentID = &agentID + risk.FCBEvaluation = NewFCBEvaluation(FCBStateClosed) + + t.Run("omitted when nil", func(t *testing.T) { + for name, v := range map[string]any{ + "IntentMandate": IntentMandate{IntentExpiry: "2026-01-01T00:00:00Z"}, + "CartMandate": CartMandate{}, + "PaymentMandateContents": PaymentMandateContents{}, + } { + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("%s: marshal failed: %v", name, err) + } + if strings.Contains(string(data), "risk_payload") { + t.Errorf("%s: risk_payload should be omitted when nil, got %s", name, data) + } + } + }) + + t.Run("round trip", func(t *testing.T) { + in := IntentMandate{IntentExpiry: "2026-01-01T00:00:00Z", RiskPayload: risk} + cart := CartMandate{RiskPayload: risk} + pmc := PaymentMandateContents{RiskPayload: risk} + + var outIntent IntentMandate + var outCart CartMandate + var outPMC PaymentMandateContents + for _, c := range []struct { + in any + out any + }{{in, &outIntent}, {cart, &outCart}, {pmc, &outPMC}} { + data, err := json.Marshal(c.in) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if err := json.Unmarshal(data, c.out); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + } + for name, got := range map[string]*RiskPayload{ + "IntentMandate": outIntent.RiskPayload, + "CartMandate": outCart.RiskPayload, + "PaymentMandateContents": outPMC.RiskPayload, + } { + if got == nil { + t.Fatalf("%s: RiskPayload lost in round trip", name) + } + if got.AgentModality != AgentModalityHumanNotPresent { + t.Errorf("%s: AgentModality = %q", name, got.AgentModality) + } + if got.AgentID == nil || *got.AgentID != agentID { + t.Errorf("%s: AgentID not preserved", name) + } + if got.FCBEvaluation == nil || got.FCBEvaluation.FCBState != FCBStateClosed { + t.Errorf("%s: FCBEvaluation not preserved", name) + } + } + }) +} diff --git a/code/sdk/python/ap2/models/__init__.py b/code/sdk/python/ap2/models/__init__.py index 9240c2aa..421b9a20 100644 --- a/code/sdk/python/ap2/models/__init__.py +++ b/code/sdk/python/ap2/models/__init__.py @@ -11,3 +11,52 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +"""AP2 type definitions.""" + +from ap2.models.mandate import ( + CART_MANDATE_DATA_KEY, + INTENT_MANDATE_DATA_KEY, + PAYMENT_MANDATE_DATA_KEY, + CartContents, + CartMandate, + IntentMandate, + PaymentMandate, + PaymentMandateContents, +) +from ap2.models.risk import ( + FCB_EVALUATION_DATA_KEY, + RISK_PAYLOAD_DATA_KEY, + AgentModality, + EscalationDecision, + FCBEvaluation, + FCBState, + HumanEscalation, + RiskPayload, + TripConditionResult, + TripConditionStatus, + TripConditionType, +) + + +__all__ = [ + 'CART_MANDATE_DATA_KEY', + 'FCB_EVALUATION_DATA_KEY', + 'INTENT_MANDATE_DATA_KEY', + 'PAYMENT_MANDATE_DATA_KEY', + 'RISK_PAYLOAD_DATA_KEY', + 'AgentModality', + 'CartContents', + 'CartMandate', + 'EscalationDecision', + 'FCBEvaluation', + 'FCBState', + 'HumanEscalation', + 'IntentMandate', + 'PaymentMandate', + 'PaymentMandateContents', + 'RiskPayload', + 'TripConditionResult', + 'TripConditionStatus', + 'TripConditionType', +] diff --git a/code/sdk/python/ap2/models/mandate.py b/code/sdk/python/ap2/models/mandate.py index e3cead02..8c3d86f6 100644 --- a/code/sdk/python/ap2/models/mandate.py +++ b/code/sdk/python/ap2/models/mandate.py @@ -21,6 +21,7 @@ PaymentRequest, PaymentResponse, ) +from ap2.models.risk import RiskPayload from pydantic import BaseModel, Field @@ -36,6 +37,13 @@ class IntentMandate(BaseModel): human-not-present flows, additional fields will be added to this mandate. """ + risk_payload: RiskPayload | None = Field( + None, + description=( + 'Optional structured risk payload carrying the current risk' + ' assessment state (Risk Signals, AP2 v0.1 Section 7.4).' + ), + ) user_cart_confirmation_required: bool = Field( True, description=( @@ -111,6 +119,13 @@ class CartMandate(BaseModel): """ contents: CartContents = Field(..., description='The contents of the cart.') + risk_payload: RiskPayload | None = Field( + None, + description=( + 'Optional structured risk payload carrying the current risk' + ' assessment state (Risk Signals, AP2 v0.1 Section 7.4).' + ), + ) merchant_authorization: str | None = Field( None, description=( @@ -156,6 +171,13 @@ class PaymentMandateContents(BaseModel): ), ) merchant_agent: str = Field(..., description='Identifier for the merchant.') + risk_payload: RiskPayload | None = Field( + None, + description=( + 'Optional structured risk payload carrying the current risk' + ' assessment state (Risk Signals, AP2 v0.1 Section 7.4).' + ), + ) timestamp: str = Field( description=( 'The date and time the mandate was created, in ISO 8601 format.' diff --git a/code/sdk/python/ap2/models/risk.py b/code/sdk/python/ap2/models/risk.py new file mode 100644 index 00000000..d6d6ea40 --- /dev/null +++ b/code/sdk/python/ap2/models/risk.py @@ -0,0 +1,315 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fiduciary Circuit Breaker (FCB) risk types for the Agent Payments Protocol. + +This module provides structured types for the Risk Payload referenced in +AP2 v0.1 Section 7.4 (Risk Signals). It implements the Fiduciary Circuit +Breaker pattern for runtime governance of autonomous agent transactions. + +The FCB pattern provides: +- Trip conditions that evaluate agent behavior against predefined thresholds +- A state machine for governance (CLOSED → OPEN → HALF_OPEN) +- Human escalation protocol for exceptional cases +- Structured risk signals for network/issuer visibility +""" + +from datetime import UTC, datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +# Data key prefix for risk types +_RISK_DATA_KEY_PREFIX = 'ap2.risk.' + +# Data keys for AP2 message data parts (composed from a prefix to avoid +# false-positive secret detection). +RISK_PAYLOAD_DATA_KEY = f'{_RISK_DATA_KEY_PREFIX}RiskPayload' +FCB_EVALUATION_DATA_KEY = f'{_RISK_DATA_KEY_PREFIX}FCBEvaluation' + + +class TripConditionType(str, Enum): + """Categories of runtime risk checks that can trigger FCB. + + These trip conditions evaluate agent behavior beyond what mandates capture, + focusing on cumulative, temporal, and anomalous patterns. + """ + + VALUE_THRESHOLD = 'VALUE_THRESHOLD' + """Single transaction exceeds monetary limit.""" + + CUMULATIVE_THRESHOLD = 'CUMULATIVE_THRESHOLD' + """Running totals across transactions exceed threshold.""" + + VELOCITY = 'VELOCITY' + """Too many actions in a time window (e.g., >10 txns/minute).""" + + AUTHORITY_SCOPE = 'AUTHORITY_SCOPE' + """Action outside the agent's delegated domain.""" + + ANOMALY = 'ANOMALY' + """ML model detects unusual behavioral pattern.""" + + TIME_BASED = 'TIME_BASED' + """Action during restricted time period.""" + + DEVIATION = 'DEVIATION' + """Significant departure from historical baseline.""" + + VENDOR_TRUST = 'VENDOR_TRUST' + """Transaction with unverified or untrusted counterparty.""" + + CREDENTIAL_CHECK = 'CREDENTIAL_CHECK' + """Static identity/credential check (e.g., wallet attestation, KYB).""" + + CUSTOM = 'CUSTOM' + """Implementation-specific trip condition.""" + + +class TripConditionStatus(str, Enum): + """Result of evaluating a single trip condition.""" + + PASS = 'PASS' + """Condition satisfied, no risk detected.""" + + FAIL = 'FAIL' + """Condition failed, FCB should trip.""" + + WARNING = 'WARNING' + """Approaching threshold, enhanced monitoring recommended.""" + + +class FCBState(str, Enum): + """States of the Fiduciary Circuit Breaker. + + The FCB operates as a state machine that governs agent autonomy: + - CLOSED: Normal operation, agent acts autonomously + - OPEN: All consequential actions blocked, requires human review + - HALF_OPEN: Limited operations with enhanced monitoring + - TERMINATED: Permanently halted, no recovery + """ + + CLOSED = 'CLOSED' + """Normal operation. Agent has full delegated authority.""" + + OPEN = 'OPEN' + """All consequential actions blocked. Requires human review.""" + + HALF_OPEN = 'HALF_OPEN' + """Limited operations permitted. Enhanced monitoring active.""" + + TERMINATED = 'TERMINATED' + """Permanently halted. No recovery possible.""" + + +class AgentModality(str, Enum): + """Transaction modality indicating human presence.""" + + HUMAN_PRESENT = 'HUMAN_PRESENT' + """User is in-session during the transaction.""" + + HUMAN_NOT_PRESENT = 'HUMAN_NOT_PRESENT' + """User delegated task to agent, not in-session.""" + + +class EscalationDecision(str, Enum): + """Human approver's decision on an escalated action.""" + + APPROVE = 'APPROVE' + """Action approved, FCB returns to CLOSED.""" + + APPROVE_WITH_CONDITIONS = 'APPROVE_WITH_CONDITIONS' + """Action approved with monitoring, FCB moves to HALF_OPEN.""" + + REJECT = 'REJECT' + """Action rejected, FCB moves to TERMINATED.""" + + ESCALATE_FURTHER = 'ESCALATE_FURTHER' + """Forward to higher authority.""" + + MODIFY_AND_APPROVE = 'MODIFY_AND_APPROVE' + """Adjust action parameters, then approve.""" + + +class TripConditionResult(BaseModel): + """Result of evaluating a single trip condition. + + Captures the outcome of one risk check, including the threshold, + actual value, and any diagnostic message. + """ + + condition_type: TripConditionType = Field( + ..., + description='The type of trip condition that was evaluated.', + ) + status: TripConditionStatus = Field( + ..., + description='Whether the condition passed, failed, or warned.', + ) + threshold: float | None = Field( + None, + description='The threshold value that was checked against.', + ) + actual_value: float | None = Field( + None, + description='The actual value observed.', + ) + message: str | None = Field( + None, + description='Human-readable explanation of the result.', + ) + suggestion: str | None = Field( + None, + description='Suggested action or resolution.', + ) + + +class HumanEscalation(BaseModel): + """Details of a human escalation when FCB trips. + + Captures the escalation request, the human's decision, and any + conditions attached to the approval. + """ + + escalation_id: str = Field( + ..., + description='Unique identifier for this escalation.', + ) + triggered_at: str = Field( + description='When the escalation was triggered, ISO 8601 format.', + default_factory=lambda: datetime.now(UTC).isoformat(), + ) + approver_id: str | None = Field( + None, + description='Identifier of the human who reviewed the escalation.', + ) + decision: EscalationDecision | None = Field( + None, + description="The human approver's decision.", + ) + decided_at: str | None = Field( + None, + description='When the decision was made, ISO 8601 format.', + ) + conditions: list[str] | None = Field( + None, + description=( + 'Conditions attached to an APPROVE_WITH_CONDITIONS decision.' + ), + ) + notes: str | None = Field( + None, + description='Free-form notes from the approver.', + ) + timeout_at: str | None = Field( + None, + description='When the escalation will timeout if not resolved.', + ) + default_action_on_timeout: EscalationDecision | None = Field( + EscalationDecision.REJECT, + description='Action to take if escalation times out.', + ) + + +class FCBEvaluation(BaseModel): + """Complete FCB evaluation result for a transaction. + + This is the primary object attached to the RiskPayload, containing + the full evaluation results from the Fiduciary Circuit Breaker. + """ + + fcb_state: FCBState = Field( + ..., + description='Current state of the FCB after evaluation.', + ) + previous_state: FCBState | None = Field( + None, + description='FCB state before this evaluation (for state transitions).', + ) + trips_evaluated: int = Field( + ..., + description='Total number of trip conditions evaluated.', + ) + trips_triggered: int = Field( + ..., + description=( + 'Number of trip conditions that triggered (FAIL or WARNING).' + ), + ) + trip_results: list[TripConditionResult] = Field( + default_factory=list, + description='Individual results for each trip condition evaluated.', + ) + risk_score: float | None = Field( + None, + ge=0.0, + le=1.0, + description='Aggregate risk score from 0.0 (lowest) to 1.0 (highest).', + ) + human_escalation: HumanEscalation | None = Field( + None, + description=( + 'Escalation details if FCB tripped and required human review.' + ), + ) + evaluated_at: str = Field( + description='When the FCB evaluation occurred, ISO 8601 format.', + default_factory=lambda: datetime.now(UTC).isoformat(), + ) + + +class RiskPayload(BaseModel): + """Container for risk-related signals in AP2 messages. + + This is the top-level risk object that can be attached to IntentMandate, + CartMandate, and PaymentMandate messages via the `risk_data` DataPart. + + It provides visibility to merchants, payment processors, networks, and + issuers about the runtime governance state of the agent transaction. + """ + + fcb_evaluation: FCBEvaluation | None = Field( + None, + description='Fiduciary Circuit Breaker evaluation results.', + ) + agent_modality: AgentModality = Field( + AgentModality.HUMAN_PRESENT, + description='Whether user was present during transaction.', + ) + agent_id: str | None = Field( + None, + description='Identifier of the agent initiating the transaction.', + ) + agent_type: str | None = Field( + None, + description="Type/category of agent (e.g., 'SHOPPING', 'B2B_BUYER').", + ) + session_id: str | None = Field( + None, + description='Session identifier for correlation.', + ) + cumulative_session_value: float | None = Field( + None, + description='Total transaction value in this session so far.', + ) + transaction_count_today: int | None = Field( + None, + description='Number of transactions by this agent today.', + ) + custom_signals: dict[str, Any] | None = Field( + None, + description='Implementation-specific risk signals.', + ) diff --git a/code/sdk/python/ap2/tests/risk_tests.py b/code/sdk/python/ap2/tests/risk_tests.py new file mode 100644 index 00000000..e0d5978a --- /dev/null +++ b/code/sdk/python/ap2/tests/risk_tests.py @@ -0,0 +1,588 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for Fiduciary Circuit Breaker (FCB) risk types.""" + +import json + +import pytest + +from ap2.models.risk import ( + FCB_EVALUATION_DATA_KEY, + RISK_PAYLOAD_DATA_KEY, + AgentModality, + EscalationDecision, + FCBEvaluation, + FCBState, + HumanEscalation, + RiskPayload, + TripConditionResult, + TripConditionStatus, + TripConditionType, +) + + +class TestTripConditionType: + """Tests for TripConditionType enum.""" + + def test_all_types_exist(self): + """Verify all expected trip condition types are defined.""" + expected = [ + 'VALUE_THRESHOLD', + 'CUMULATIVE_THRESHOLD', + 'VELOCITY', + 'AUTHORITY_SCOPE', + 'ANOMALY', + 'TIME_BASED', + 'DEVIATION', + 'VENDOR_TRUST', + 'CREDENTIAL_CHECK', + 'CUSTOM', + ] + actual = [e.value for e in TripConditionType] + assert set(expected) == set(actual) + + def test_string_values(self): + """Verify enum values match string representation.""" + assert TripConditionType.VALUE_THRESHOLD == 'VALUE_THRESHOLD' + assert TripConditionType.CUMULATIVE_THRESHOLD == 'CUMULATIVE_THRESHOLD' + assert TripConditionType.VELOCITY == 'VELOCITY' + assert TripConditionType.CREDENTIAL_CHECK == 'CREDENTIAL_CHECK' + + def test_credential_check_in_trip_result(self): + """Test CREDENTIAL_CHECK can be used in a TripConditionResult.""" + result = TripConditionResult( + condition_type=TripConditionType.CREDENTIAL_CHECK, + status=TripConditionStatus.PASS, + message='Agent wallet holds valid KYB attestation via EAS on Base', + suggestion=None, + ) + assert result.condition_type == TripConditionType.CREDENTIAL_CHECK + assert result.status == TripConditionStatus.PASS + + +class TestTripConditionStatus: + """Tests for TripConditionStatus enum.""" + + def test_all_statuses_exist(self): + """Verify all expected statuses are defined.""" + assert TripConditionStatus.PASS == 'PASS' + assert TripConditionStatus.FAIL == 'FAIL' + assert TripConditionStatus.WARNING == 'WARNING' + + +class TestFCBState: + """Tests for FCBState enum.""" + + def test_all_states_exist(self): + """Verify all FCB states are defined.""" + assert FCBState.CLOSED == 'CLOSED' + assert FCBState.OPEN == 'OPEN' + assert FCBState.HALF_OPEN == 'HALF_OPEN' + assert FCBState.TERMINATED == 'TERMINATED' + + +class TestAgentModality: + """Tests for AgentModality enum.""" + + def test_modalities(self): + """Verify both modalities exist.""" + assert AgentModality.HUMAN_PRESENT == 'HUMAN_PRESENT' + assert AgentModality.HUMAN_NOT_PRESENT == 'HUMAN_NOT_PRESENT' + + +class TestEscalationDecision: + """Tests for EscalationDecision enum.""" + + def test_all_decisions_exist(self): + """Verify all escalation decisions are defined.""" + expected = [ + 'APPROVE', + 'APPROVE_WITH_CONDITIONS', + 'REJECT', + 'ESCALATE_FURTHER', + 'MODIFY_AND_APPROVE', + ] + actual = [e.value for e in EscalationDecision] + assert set(expected) == set(actual) + + +class TestTripConditionResult: + """Tests for TripConditionResult model.""" + + def test_minimal_result(self): + """Test creating result with only required fields.""" + result = TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + ) + assert result.condition_type == TripConditionType.VALUE_THRESHOLD + assert result.status == TripConditionStatus.PASS + assert result.threshold is None + assert result.actual_value is None + + def test_full_result(self): + """Test creating result with all fields.""" + result = TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + threshold=50000.0, + actual_value=75000.0, + message='Daily limit exceeded', + suggestion='Request manager approval', + ) + assert result.threshold == 50000.0 + assert result.actual_value == 75000.0 + assert result.message == 'Daily limit exceeded' + + def test_json_serialization(self): + """Test JSON round-trip.""" + result = TripConditionResult( + condition_type=TripConditionType.VELOCITY, + status=TripConditionStatus.WARNING, + threshold=10.0, + actual_value=8.0, + ) + json_str = result.model_dump_json() + decoded = TripConditionResult.model_validate_json(json_str) + assert decoded.condition_type == result.condition_type + assert decoded.status == result.status + + +class TestHumanEscalation: + """Tests for HumanEscalation model.""" + + def test_creation_with_defaults(self): + """Test creating escalation with default values.""" + escalation = HumanEscalation(escalation_id='esc-12345') + assert escalation.escalation_id == 'esc-12345' + assert escalation.triggered_at is not None + assert escalation.default_action_on_timeout == EscalationDecision.REJECT + + def test_full_escalation(self): + """Test creating escalation with all fields.""" + escalation = HumanEscalation( + escalation_id='esc-001', + triggered_at='2025-02-03T10:00:00Z', + approver_id='manager-123', + decision=EscalationDecision.APPROVE_WITH_CONDITIONS, + decided_at='2025-02-03T10:15:00Z', + conditions=['Enhanced monitoring for 24h', 'Max single txn $5000'], + notes='Approved after vendor verification', + ) + assert escalation.decision == EscalationDecision.APPROVE_WITH_CONDITIONS + assert len(escalation.conditions) == 2 + + +class TestFCBEvaluation: + """Tests for FCBEvaluation model.""" + + def test_minimal_evaluation(self): + """Test creating evaluation with required fields.""" + evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=0, + trips_triggered=0, + ) + assert evaluation.fcb_state == FCBState.CLOSED + assert evaluation.trip_results == [] + + def test_evaluation_with_results(self): + """Test evaluation with trip results.""" + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + ), + TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + ), + ] + evaluation = FCBEvaluation( + fcb_state=FCBState.OPEN, + trips_evaluated=2, + trips_triggered=1, + trip_results=results, + risk_score=0.75, + ) + assert len(evaluation.trip_results) == 2 + assert evaluation.risk_score == 0.75 + + def test_risk_score_bounds(self): + """Test risk_score validation.""" + from pydantic import ValidationError + + # Valid score + evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=0.5, + ) + assert evaluation.risk_score == 0.5 + + # Score at boundaries + eval_min = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=0.0, + ) + assert eval_min.risk_score == 0.0 + + eval_max = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=1.0, + ) + assert eval_max.risk_score == 1.0 + + # Invalid scores - below minimum + with pytest.raises(ValidationError): + FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=-0.1, + ) + + # Invalid scores - above maximum + with pytest.raises(ValidationError): + FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=1.1, + ) + + def test_evaluation_with_escalation(self): + """Test evaluation with human escalation.""" + escalation = HumanEscalation( + escalation_id='esc-001', + approver_id='manager-123', + decision=EscalationDecision.APPROVE, + ) + evaluation = FCBEvaluation( + fcb_state=FCBState.HALF_OPEN, + previous_state=FCBState.OPEN, + trips_evaluated=3, + trips_triggered=1, + human_escalation=escalation, + ) + assert evaluation.previous_state == FCBState.OPEN + assert evaluation.human_escalation is not None + + +class TestRiskPayload: + """Tests for RiskPayload model.""" + + def test_minimal_payload(self): + """Test creating payload with defaults.""" + payload = RiskPayload() + assert payload.agent_modality == AgentModality.HUMAN_PRESENT + assert payload.fcb_evaluation is None + + def test_full_payload(self): + """Test creating payload with all fields.""" + evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=2, + trips_triggered=0, + ) + payload = RiskPayload( + fcb_evaluation=evaluation, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id='shopping-agent-001', + agent_type='SHOPPING', + session_id='sess-abc123', + cumulative_session_value=1500.0, + transaction_count_today=5, + custom_signals={ + 'merchant_trust_score': 0.95, + 'buyer_tier': 'enterprise', + }, + ) + assert payload.agent_id == 'shopping-agent-001' + assert payload.custom_signals['buyer_tier'] == 'enterprise' + + def test_json_serialization(self): + """Test JSON round-trip for complex payload.""" + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=10000.0, + actual_value=8500.0, + ), + ] + evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + trip_results=results, + ) + payload = RiskPayload( + fcb_evaluation=evaluation, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id='test-agent', + ) + + # Serialize + json_str = payload.model_dump_json() + + # Deserialize + decoded = RiskPayload.model_validate_json(json_str) + + assert decoded.agent_id == 'test-agent' + assert decoded.fcb_evaluation is not None + assert len(decoded.fcb_evaluation.trip_results) == 1 + + def test_custom_signals_any_type(self): + """Test custom_signals accepts various types.""" + payload = RiskPayload( + custom_signals={ + 'string_val': 'hello', + 'int_val': 42, + 'float_val': 3.14, + 'bool_val': True, + 'list_val': [1, 2, 3], + 'nested': {'key': 'value'}, + } + ) + assert payload.custom_signals['int_val'] == 42 + assert payload.custom_signals['nested']['key'] == 'value' + + +class TestDataKeys: + """Tests for data key constants.""" + + def test_risk_payload_key(self): + """Verify RISK_PAYLOAD_DATA_KEY constant.""" + # Compose expected value at runtime to avoid false positive secret detection + expected = 'ap2.risk.' + 'RiskPayload' + assert expected == RISK_PAYLOAD_DATA_KEY + + def test_fcb_evaluation_key(self): + """Verify FCB_EVALUATION_DATA_KEY constant.""" + # Compose expected value at runtime to avoid false positive secret detection + expected = 'ap2.risk.' + 'FCBEvaluation' + assert expected == FCB_EVALUATION_DATA_KEY + + +class TestCompleteScenario: + """Integration tests for complete FCB evaluation scenarios.""" + + def test_b2b_quote_scenario(self): + """Test complete B2B quote negotiation scenario. + + Scenario: Agent negotiating $85,000 order, triggers cumulative + threshold, escalates to human, gets approved with conditions. + """ + # Step 1: Evaluate trip conditions + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=100000.0, + actual_value=85000.0, + message='Single transaction within limit', + ), + TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + threshold=200000.0, + actual_value=235000.0, + message='Daily cumulative exceeds $200k limit', + suggestion='Escalate to procurement manager', + ), + TripConditionResult( + condition_type=TripConditionType.VENDOR_TRUST, + status=TripConditionStatus.PASS, + message='Vendor in approved list', + ), + ] + + # Step 2: FCB trips and opens + evaluation = FCBEvaluation( + fcb_state=FCBState.OPEN, + previous_state=FCBState.CLOSED, + trips_evaluated=3, + trips_triggered=1, + trip_results=results, + risk_score=0.65, + ) + + # Step 3: Create escalation + escalation = HumanEscalation( + escalation_id='esc-b2b-001', + triggered_at='2025-02-03T14:30:00Z', + timeout_at='2025-02-03T15:30:00Z', + ) + evaluation.human_escalation = escalation + + # Step 4: Human approves with conditions + evaluation.human_escalation.approver_id = 'procurement-mgr-456' + evaluation.human_escalation.decision = ( + EscalationDecision.APPROVE_WITH_CONDITIONS + ) + evaluation.human_escalation.decided_at = '2025-02-03T14:45:00Z' + evaluation.human_escalation.conditions = [ + 'Enhanced monitoring for 48 hours', + 'Require delivery confirmation before payment release', + ] + evaluation.human_escalation.notes = ( + 'Approved - vendor verified, Q4 budget allows' + ) + + # Step 5: FCB moves to HALF_OPEN + evaluation.previous_state = evaluation.fcb_state + evaluation.fcb_state = FCBState.HALF_OPEN + + # Step 6: Wrap in RiskPayload + payload = RiskPayload( + fcb_evaluation=evaluation, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id='b2b-buyer-agent-001', + agent_type='B2B_BUYER', + session_id='negotiation-sess-789', + cumulative_session_value=235000.0, + transaction_count_today=4, + custom_signals={ + 'vendor_id': 'vendor-acme-123', + 'contract_id': 'contract-2025-001', + 'negotiation_rounds': 3, + }, + ) + + # Verify final state + assert payload.fcb_evaluation.fcb_state == FCBState.HALF_OPEN + assert ( + payload.fcb_evaluation.human_escalation.decision + == EscalationDecision.APPROVE_WITH_CONDITIONS + ) + assert len(payload.fcb_evaluation.human_escalation.conditions) == 2 + assert payload.agent_type == 'B2B_BUYER' + + # Verify JSON serialization of complete scenario + json_output = json.loads(payload.model_dump_json()) + assert json_output['fcb_evaluation']['fcb_state'] == 'HALF_OPEN' + assert json_output['custom_signals']['negotiation_rounds'] == 3 + + +class TestMandateRiskPayloadIntegration: + """Tests for risk_payload integration into mandate types.""" + + def test_intent_mandate_with_risk_payload(self): + """Test IntentMandate accepts optional risk_payload.""" + from ap2.models.mandate import IntentMandate + + payload = RiskPayload( + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id='shopping-agent-001', + ) + mandate = IntentMandate( + natural_language_description='Red basketball shoes under $200', + intent_expiry='2026-03-01T00:00:00Z', + risk_payload=payload, + ) + assert mandate.risk_payload is not None + assert mandate.risk_payload.agent_id == 'shopping-agent-001' + + def test_intent_mandate_without_risk_payload(self): + """Test IntentMandate works without risk_payload (backward compat).""" + from ap2.models.mandate import IntentMandate + + mandate = IntentMandate( + natural_language_description='Red basketball shoes', + intent_expiry='2026-03-01T00:00:00Z', + ) + assert mandate.risk_payload is None + + def test_cart_mandate_with_risk_payload(self): + """Test CartMandate accepts optional risk_payload.""" + from ap2.models.mandate import CartContents, CartMandate + from ap2.models.payment_request import ( + PaymentCurrencyAmount, + PaymentDetailsInit, + PaymentItem, + PaymentRequest, + ) + + payload = RiskPayload( + fcb_evaluation=FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=3, + trips_triggered=0, + ), + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + ) + cart_contents = CartContents( + id='cart-001', + user_cart_confirmation_required=True, + payment_request=PaymentRequest( + method_data=[], + details=PaymentDetailsInit( + id='details-001', + display_items=[], + total=PaymentItem( + label='Total', + amount=PaymentCurrencyAmount( + currency='USD', value='100.00' + ), + ), + ), + ), + cart_expiry='2026-03-01T00:00:00Z', + merchant_name='Test Merchant', + ) + mandate = CartMandate( + contents=cart_contents, + risk_payload=payload, + ) + assert mandate.risk_payload is not None + assert mandate.risk_payload.fcb_evaluation.fcb_state == FCBState.CLOSED + + def test_payment_mandate_contents_with_risk_payload(self): + """Test PaymentMandateContents accepts optional risk_payload.""" + from ap2.models.mandate import PaymentMandateContents + from ap2.models.payment_request import ( + PaymentCurrencyAmount, + PaymentItem, + PaymentResponse, + ) + + payload = RiskPayload( + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id='b2b-agent-001', + cumulative_session_value=235000.0, + transaction_count_today=4, + ) + contents = PaymentMandateContents( + payment_mandate_id='pm-001', + payment_details_id='pd-001', + payment_details_total=PaymentItem( + label='Total', + amount=PaymentCurrencyAmount(currency='USD', value='85000.00'), + ), + payment_response=PaymentResponse( + request_id='req-001', + method_name='basic-card', + ), + merchant_agent='merchant-001', + risk_payload=payload, + ) + assert contents.risk_payload is not None + assert contents.risk_payload.cumulative_session_value == 235000.0 diff --git a/docs/extensions/fiduciary_circuit_breaker.md b/docs/extensions/fiduciary_circuit_breaker.md new file mode 100644 index 00000000..b853f263 --- /dev/null +++ b/docs/extensions/fiduciary_circuit_breaker.md @@ -0,0 +1,317 @@ +# Fiduciary Circuit Breaker (FCB) Extension + +!!! info + + This extension provides structured risk types for [AP2 v0.1 Section 7.4 (Risk Signals)](https://github.com/google-agentic-commerce/AP2/blob/v0.1.0/docs/specification.md#74-risk-signals). + + `v0.1-alpha` + +## Overview + +The **Fiduciary Circuit Breaker (FCB)** is a runtime governance pattern that complements AP2's mandate-based authorization. While mandates prove that an agent has authority to act, FCB monitors *how* the agent exercises that authority in real-time. + +### Why FCB? + +AP2 mandates validate authority at signing time: + +- ✅ "Agent has a valid IntentMandate with $50,000 budget" +- ✅ "This transaction is within the mandate constraints" + +But mandates don't address runtime behaviors: + +- ❌ "Agent already spent $30,000 today across 10 transactions" +- ❌ "Agent is making purchases 3x faster than normal" +- ❌ "Agent is buying from an unfamiliar vendor in a high-risk region" + +FCB fills this gap by providing **cross-transaction behavioral monitoring** that can trip and require human intervention when something looks wrong. + +## Conceptual Model + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AGENT GOVERNANCE STACK │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 3: RUNTIME GOVERNANCE (FCB) │ +│ ───────────────────────────────── │ +│ Question: "Should this action proceed RIGHT NOW?" │ +│ Evaluates: Cumulative risk, velocity, anomalies, thresholds │ +│ Output: ALLOW / TRIP (escalate to human) │ +│ │ +│ Layer 2: PAYMENT AUTHORIZATION (AP2 Mandates) │ +│ ───────────────────────────────────────────── │ +│ Question: "Does agent have cryptographic proof of authority?" │ +│ Evaluates: User-signed mandates, intent constraints │ +│ Output: Valid credential / Reject │ +│ │ +│ Layer 1: AGENT IDENTITY & DISCOVERY │ +│ ──────────────────────────────────── │ +│ Question: "Is this agent authentic?" │ +│ Evaluates: Agent cards, trust registries │ +│ Output: Verified identity / Untrusted │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## FCB States + +The FCB operates as a state machine: + +| State | Behavior | Entry Condition | +| ----- | -------- | --------------- | +| **CLOSED** | Normal operation. Agent acts autonomously. | Initial state; human approves from OPEN; or conditions met from HALF_OPEN | +| **OPEN** | All actions blocked. Requires human review. | Any trip condition fails from CLOSED; or conditions violated from HALF_OPEN | +| **HALF_OPEN** | Limited operations with enhanced monitoring. | Human approves with conditions from OPEN | +| **TERMINATED** | Permanently halted. No recovery. | Human rejects from OPEN; or timeout | + +### State Transitions + +```text +CLOSED ──[trip condition fails]──► OPEN + ▲ │ + │ ┌─────────────┼─────────────┐ + │ │ │ │ + │ [approve] [approve w/conditions] [reject] + │ │ │ │ + │ │ ▼ ▼ + └────────────────────┘ HALF_OPEN TERMINATED + │ + ┌─────────┴─────────┐ + │ │ + [conditions met] [conditions violated] + │ │ + ▼ ▼ + CLOSED OPEN +``` + +## Trip Conditions + +Trip conditions are predicate functions that evaluate agent behavior: + +| Type | Description | Example | +| ---- | ----------- | ------- | +| `VALUE_THRESHOLD` | Single transaction exceeds limit | Order > $100,000 | +| `CUMULATIVE_THRESHOLD` | Running total exceeds threshold | Daily spend > $500,000 | +| `VELOCITY` | Too many actions too quickly | > 10 transactions/minute | +| `AUTHORITY_SCOPE` | Action outside delegated domain | Modifying payment account | +| `ANOMALY` | ML model detects unusual pattern | Behavior inconsistent with baseline | +| `TIME_BASED` | Action during restricted period | Trade outside market hours | +| `DEVIATION` | Significant departure from baseline | Price 30% below historical average | +| `VENDOR_TRUST` | Untrusted counterparty | New vendor in high-risk region | +| `CREDENTIAL_CHECK` | Static identity/credential verification | Agent wallet holds KYB attestation | + +## Identity vs. Behavioral Risk + +AP2 v0.1 Section 7.4 identifies two complementary dimensions of risk in agent transactions: + +### Behavioral Risk (Runtime) + +Most trip conditions (`VALUE_THRESHOLD`, `VELOCITY`, `ANOMALY`, etc.) evaluate **what the agent does** — its runtime behavior against predefined thresholds. These are dynamic checks that may change with every transaction. + +### Identity Risk (Static) + +The `CREDENTIAL_CHECK` trip condition evaluates **what the agent is** — its identity, credentials, and trust signals. This addresses the spec's call-out that "the Shopping Agent's ID becomes synonymous with a bot's identity, which requires new methods of verification and trust." + +Examples of credential checks: + +- Agent wallet holds governance tokens from a recognized DAO +- Agent has a KYB (Know Your Business) attestation via [EAS](https://attest.org/) on Base +- Agent presents a W3C Verifiable Credential from a trusted issuer +- Agent identity is registered in a trust registry + +Credential check results use the standard `TripConditionResult` structure. The `message` field carries human-readable verification details, while implementation-specific data (attestation UIDs, chain references, credential schemas) should flow through `RiskPayload.custom_signals` to keep the core types ecosystem-agnostic. + +!!! note + + Future versions may separate identity evaluation into its own dimension within `RiskPayload` (e.g., `identity_evaluation`) to provide richer type support for credential data shapes that differ from behavioral thresholds. + +## Usage in AP2 Messages + +### Including RiskPayload in Messages + +The `RiskPayload` can be attached to any AP2 message via the `risk_data` DataPart: + +```json +{ + "messageId": "msg_123", + "parts": [ + { + "kind": "data", + "data": { + "ap2.mandates.PaymentMandate": { ... }, + "ap2.risk.RiskPayload": { + "fcb_evaluation": { + "fcb_state": "CLOSED", + "trips_evaluated": 8, + "trips_triggered": 0, + "trip_results": [ + { + "condition_type": "VALUE_THRESHOLD", + "status": "PASS", + "threshold": 100000, + "actual_value": 45000 + }, + { + "condition_type": "CUMULATIVE_THRESHOLD", + "status": "PASS", + "threshold": 500000, + "actual_value": 125000 + } + ], + "risk_score": 0.15, + "evaluated_at": "2026-02-03T14:30:00Z" + }, + "agent_modality": "HUMAN_NOT_PRESENT", + "agent_id": "agent_xyz", + "agent_type": "B2B_BUYER", + "session_id": "session_abc123", + "cumulative_session_value": 125000, + "transaction_count_today": 3 + } + } + } + ] +} +``` + +### FCB Trip with Human Escalation + +When FCB trips, the `human_escalation` field captures the escalation flow: + +```json +{ + "ap2.risk.RiskPayload": { + "fcb_evaluation": { + "fcb_state": "HALF_OPEN", + "previous_state": "OPEN", + "trips_evaluated": 8, + "trips_triggered": 2, + "trip_results": [ + { + "condition_type": "CUMULATIVE_THRESHOLD", + "status": "FAIL", + "threshold": 500000, + "actual_value": 525000, + "message": "Daily cumulative spend exceeds $500,000 limit" + }, + { + "condition_type": "VENDOR_TRUST", + "status": "WARNING", + "message": "New vendor not in approved registry" + } + ], + "risk_score": 0.72, + "human_escalation": { + "escalation_id": "esc_789", + "triggered_at": "2026-02-03T14:30:00Z", + "approver_id": "user_john_smith", + "decision": "APPROVE_WITH_CONDITIONS", + "decided_at": "2026-02-03T14:45:00Z", + "conditions": [ + "Add vendor to approved registry", + "Enhanced monitoring for 7 days" + ], + "notes": "Approved given strong counterparty history" + }, + "evaluated_at": "2026-02-03T14:30:00Z" + }, + "agent_modality": "HUMAN_NOT_PRESENT" + } +} +``` + +## Python Types + +```python +from ap2.models.risk import ( + RiskPayload, + FCBEvaluation, + FCBState, + TripConditionResult, + TripConditionType, + TripConditionStatus, + AgentModality, +) + +# Create an FCB evaluation +evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=3, + trips_triggered=0, + trip_results=[ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=100000, + actual_value=45000, + ) + ], + risk_score=0.15, +) + +# Create risk payload +risk_payload = RiskPayload( + fcb_evaluation=evaluation, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id="agent_xyz", + session_id="session_abc123", +) +``` + +## Go Types + +```go +import "github.com/google-agentic-commerce/ap2/samples/go/pkg/ap2/types" + +// Create an FCB evaluation +evaluation := types.NewFCBEvaluation(types.FCBStateClosed) +threshold := 100000.0 +actualValue := 45000.0 +riskScore := 0.15 +evaluation.AddTripResult(types.TripConditionResult{ + ConditionType: types.TripConditionValueThreshold, + Status: types.TripConditionStatusPass, + Threshold: &threshold, + ActualValue: &actualValue, +}) +evaluation.RiskScore = &riskScore + +// Create risk payload +riskPayload := types.NewRiskPayload(types.AgentModalityHumanNotPresent) +riskPayload.FCBEvaluation = evaluation +agentID := "agent_xyz" +riskPayload.AgentID = &agentID +``` + +## Benefits for Payment Ecosystem + +### For Merchants + +- Real-time visibility into agent behavior before accepting transaction +- Ability to require higher security for risky transactions + +### For Payment Networks + +- Standardized risk signals for authorization decisions +- Clear audit trail of FCB state and human approvals + +### For Issuers + +- Additional data points for fraud detection +- Visibility into agent vs. human-initiated transactions + +### For Users + +- Confidence that agents operate within guardrails +- Human oversight for exceptional cases + +## Risk Payload in Mandates + +As of v0.1-alpha, `RiskPayload` can be included directly on `IntentMandate`, `CartMandate`, and `PaymentMandateContents` via the optional `risk_payload` field. This allows risk signals to travel with the mandate chain, giving networks and issuers structured visibility into the agent's governance state at mandate-signing time. + +The `risk_payload` field is fully optional — mandates without it remain valid, ensuring backward compatibility. + +## References + +- [AP2 v0.1 Specification Section 7.4: Risk Signals](https://github.com/google-agentic-commerce/AP2/blob/v0.1.0/docs/specification.md#74-risk-signals) +- [Circuit Breaker Pattern - Michael Nygard, Release It! (2007)](https://pragprog.com/titles/mnee2/release-it-second-edition/) diff --git a/mkdocs.yml b/mkdocs.yml index 8527dcba..cd18b51f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,8 @@ nav: - Payment Mandate: ap2/payment_mandate.md - Security and Privacy Considerations: ap2/security_and_privacy_considerations.md - Implementation Considerations: ap2/implementation_considerations.md + - Extensions: + - Fiduciary Circuit Breaker: extensions/fiduciary_circuit_breaker.md - Glossary: glossary.md - FAQ: faq.md - Samples: @@ -145,6 +147,8 @@ plugins: - ap2/security_and_privacy_considerations.md - ap2/implementation_considerations.md - ap2/agent_authorization.md + "Extensions": + - extensions/fiduciary_circuit_breaker.md "Additional Resources": - glossary.md - faq.md