diff --git a/plugins/compliance/actions/check-agent-identity.ts b/plugins/compliance/actions/check-agent-identity.ts new file mode 100644 index 0000000..bb36290 --- /dev/null +++ b/plugins/compliance/actions/check-agent-identity.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import type { ActionDefinition } from '../../../core/schema/action'; +import type { WalletProvider } from '../../../core/wallet/wallet-provider'; +import { getIdentityRegistryAddress, getReputationRegistryAddress } from '../../erc8004/addresses'; + +export interface CheckAgentIdentityInput { + agentId: string; + network: string; + threshold?: number; +} + +export interface CheckAgentIdentityOutput { + agentId: string; + registered: boolean; + reputationScore: number | null; + meetsThreshold: boolean; + detail: string; +} + +const inputSchema = z.object({ + agentId: z.string().regex(/^\d+$/, 'agentId must be a numeric string'), + network: z.string(), + threshold: z.number().min(0).max(100).optional().default(50), +}); + +const GET_AGENT_ABI = [ + 'function getAgent(uint256 agentId) view returns (address owner, string uri, bool active)', +]; + +const GET_SUMMARY_ABI = [ + 'function getSummary(uint256 agentId, address[] clientAddresses, string tag1, string tag2) view returns (uint64 count, int128 summaryValue, uint8 summaryValueDecimals)', +]; + +export function checkAgentIdentityAction( + wallet: WalletProvider, +): ActionDefinition { + return { + name: 'compliance.check_agent_identity', + description: + 'Check an ERC-8004 agent identity for compliance. Verifies the agent is registered in the identity registry, has a reputation score, and meets a configurable threshold. Use this before transacting with an unknown agent.', + riskLevel: 'read', + requiresConfirmation: false, + networks: ['goat-mainnet', 'goat-testnet'], + zodInputSchema: inputSchema, + async execute(ctx, input) { + let registered = false; + let reputationScore: number | null = null; + let detail = ''; + + try { + const identityRegistry = getIdentityRegistryAddress(input.network); + const result = await wallet.callContract( + identityRegistry, + GET_AGENT_ABI, + 'getAgent', + [BigInt(input.agentId)], + ) as any[]; + + registered = result && result.length >= 3 && result[2] === true; + detail = registered + ? `Agent #${input.agentId} is registered (owner: ${(result as any[])[0]})` + : `Agent #${input.agentId} was found but is not active`; + } catch (e) { + detail = `Could not verify agent #${input.agentId}: ${(e as Error).message}`; + } + + // Try reputation check + if (registered) { + try { + const reputationRegistry = getReputationRegistryAddress(input.network); + const repResult = await wallet.callContract( + reputationRegistry, + GET_SUMMARY_ABI, + 'getSummary', + [BigInt(input.agentId), [], '', ''], + ) as any[]; + + if (repResult && repResult.length >= 2) { + const rawValue = Number(repResult[1]); + const decimals = Number(repResult[2]) || 0; + reputationScore = rawValue / Math.pow(10, decimals); + } + } catch { + // Reputation registry may not be available + detail += ' (reputation unavailable)'; + } + } + + const meetsThreshold = reputationScore !== null + ? reputationScore >= (input.threshold || 50) + : !registered; // if no reputation, only pass if not registered (no risk) + + return { + agentId: input.agentId, + registered, + reputationScore, + meetsThreshold, + detail, + }; + }, + }; +} \ No newline at end of file diff --git a/plugins/compliance/actions/validate-x402-payment.ts b/plugins/compliance/actions/validate-x402-payment.ts new file mode 100644 index 0000000..c77985b --- /dev/null +++ b/plugins/compliance/actions/validate-x402-payment.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import type { ActionDefinition } from '../../../core/schema/action'; +import { evmAddress } from '../../../core/schema/validators'; + +export interface ValidateX402PaymentInput { + paymentId: string; + to: string; + asset: string; + amount: string; + network: string; + merchantUrl?: string; +} + +export interface ValidateX402PaymentOutput { + valid: boolean; + checks: { + name: string; + passed: boolean; + detail: string; + }[]; + riskLevel: 'low' | 'medium' | 'high'; +} + +const inputSchema = z.object({ + paymentId: z.string().min(1, 'paymentId is required'), + to: evmAddress, + asset: z.string().min(2), + amount: z.string().min(1), + network: z.string(), + merchantUrl: z.string().url().optional(), +}); + +export function validateX402PaymentAction(): ActionDefinition< + ValidateX402PaymentInput, + ValidateX402PaymentOutput +> { + return { + name: 'compliance.validate_x402_payment', + description: + 'Validate an x402 payment request before submission. Checks recipient address format, amount bounds, network compatibility, and merchant gateway reachability.', + riskLevel: 'read', + requiresConfirmation: false, + networks: ['goat-mainnet', 'goat-testnet'], + zodInputSchema: inputSchema, + async execute(ctx, input) { + const checks: ValidateX402PaymentOutput['checks'] = []; + + // Check 1: Address is not zero address + const isZeroAddress = input.to === '0x0000000000000000000000000000000000000000'; + checks.push({ + name: 'recipient_not_zero', + passed: !isZeroAddress, + detail: isZeroAddress + ? 'Recipient is the zero address — funds would be burned' + : 'Recipient address is valid', + }); + + // Check 2: Amount is positive and reasonable + const amountNum = parseFloat(input.amount); + const amountSanity = amountNum > 0 && amountNum < 1000000; + checks.push({ + name: 'amount_reasonable', + passed: amountSanity, + detail: amountSanity + ? `Amount ${input.amount} ${input.asset} is within bounds` + : `Amount ${input.amount} is invalid or exceeds sanity limit (1M)`, + }); + + // Check 3: Network is supported + const supportedNetworks = ['goat-mainnet', 'goat-testnet']; + const networkSupported = supportedNetworks.includes(input.network); + checks.push({ + name: 'network_supported', + passed: networkSupported, + detail: networkSupported + ? `Network ${input.network} is supported` + : `Network ${input.network} is not in supported list: ${supportedNetworks.join(', ')}`, + }); + + // Check 4: Asset is recognized + const knownAssets = ['USDC', 'USDT', 'GOAT', 'BTC', 'ETH']; + const assetKnown = knownAssets.includes(input.asset.toUpperCase()); + checks.push({ + name: 'asset_recognized', + passed: assetKnown, + detail: assetKnown + ? `Asset ${input.asset} is recognized` + : `Asset ${input.asset} is not in known list — proceed with caution`, + }); + + // Check 5: Merchant URL is HTTPS (if provided) + if (input.merchantUrl) { + const isHttps = input.merchantUrl.startsWith('https://'); + checks.push({ + name: 'merchant_secure', + passed: isHttps, + detail: isHttps + ? 'Merchant gateway uses HTTPS' + : 'Merchant gateway does not use HTTPS — payment data may be intercepted', + }); + } else { + checks.push({ + name: 'merchant_secure', + passed: true, + detail: 'No merchant gateway URL provided — skipping HTTPS check', + }); + } + + const failedCount = checks.filter((c) => !c.passed).length; + let riskLevel: 'low' | 'medium' | 'high' = 'low'; + if (failedCount >= 3) riskLevel = 'high'; + else if (failedCount >= 1) riskLevel = 'medium'; + + return { + valid: failedCount === 0, + checks, + riskLevel, + }; + }, + }; +} \ No newline at end of file diff --git a/plugins/compliance/actions/validate-x402-response.ts b/plugins/compliance/actions/validate-x402-response.ts new file mode 100644 index 0000000..6071824 --- /dev/null +++ b/plugins/compliance/actions/validate-x402-response.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import type { ActionDefinition } from '../../../core/schema/action'; + +export interface ValidateX402ResponseInput { + statusCode: number; + headers: Record; + body: string; + expectedAmount?: string; + expectedAsset?: string; +} + +export interface ValidateX402ResponseOutput { + valid: boolean; + checks: { + name: string; + passed: boolean; + detail: string; + }[]; +} + +const inputSchema = z.object({ + statusCode: z.number().int(), + headers: z.record(z.string(), z.string()), + body: z.string(), + expectedAmount: z.string().optional(), + expectedAsset: z.string().optional(), +}); + +export function validateX402ResponseAction(): ActionDefinition< + ValidateX402ResponseInput, + ValidateX402ResponseOutput +> { + return { + name: 'compliance.validate_x402_response', + description: + 'Validate an HTTP 402 response from a merchant gateway. Checks that the 402 challenge conforms to the x402 spec, including required headers, proper encoding, and optionally matching expected payment terms.', + riskLevel: 'read', + requiresConfirmation: false, + networks: ['goat-mainnet', 'goat-testnet'], + zodInputSchema: inputSchema, + async execute(_ctx, input) { + const checks: ValidateX402ResponseOutput['checks'] = []; + + // Check 1: Status code is 402 + const is402 = input.statusCode === 402; + checks.push({ + name: 'status_402', + passed: is402, + detail: is402 + ? 'Response status is 402 (Payment Required)' + : `Expected 402, got ${input.statusCode}`, + }); + + // Check 2: Content-Type present + const contentType = input.headers['content-type'] || input.headers['Content-Type'] || ''; + const hasContentType = contentType.length > 0; + checks.push({ + name: 'content_type_present', + passed: hasContentType, + detail: hasContentType + ? `Content-Type: ${contentType}` + : 'Missing Content-Type header', + }); + + // Check 3: Body is not empty + const hasBody = input.body.length > 0; + checks.push({ + name: 'body_not_empty', + passed: hasBody, + detail: hasBody + ? `Response body is ${input.body.length} chars` + : 'Response body is empty', + }); + + // Check 4: Body contains x402 challenge markers + const has402Marker = input.body.includes('402') || input.body.includes('x402'); + checks.push({ + name: 'x402_challenge_marker', + passed: has402Marker, + detail: has402Marker + ? 'Response body contains x402/402 challenge markers' + : 'Response body does not contain expected x402 challenge markers — may not be a valid x402 response', + }); + + // Check 5: Payment-Info header present + const hasPaymentInfo = 'x-payment-info' in input.headers || + 'X-Payment-Info' in input.headers; + checks.push({ + name: 'payment_info_header', + passed: hasPaymentInfo, + detail: hasPaymentInfo + ? 'x-payment-info header present' + : 'Missing x-payment-info header — required by x402 spec', + }); + + // Check 6: Expected amount matches (if provided) + if (input.expectedAmount && hasBody) { + const amountMatches = input.body.includes(input.expectedAmount); + checks.push({ + name: 'amount_matches', + passed: amountMatches, + detail: amountMatches + ? `Response references expected amount ${input.expectedAmount}` + : `Expected amount ${input.expectedAmount} not found in response`, + }); + } + + const failedCount = checks.filter((c) => !c.passed).length; + return { + valid: failedCount === 0, + checks, + }; + }, + }; +} \ No newline at end of file diff --git a/plugins/compliance/index.ts b/plugins/compliance/index.ts new file mode 100644 index 0000000..ff43268 --- /dev/null +++ b/plugins/compliance/index.ts @@ -0,0 +1,3 @@ +export { validateX402PaymentAction } from './actions/validate-x402-payment'; +export { validateX402ResponseAction } from './actions/validate-x402-response'; +export { checkAgentIdentityAction } from './actions/check-agent-identity'; \ No newline at end of file