From 7d7d33a4c4f241c9cc57acc54802af2dc6c1fbe4 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Wed, 12 Aug 2026 22:32:30 +0100 Subject: [PATCH] Add Agent Guild policy for x402 payments --- README.md | 22 +- docs/REFERENCE.md | 5 + skills/mcpc/SKILL.md | 6 +- src/bridge/index.ts | 99 +++++- src/cli/commands/connect.ts | 83 ++++- src/cli/commands/sessions.ts | 4 + src/cli/index.ts | 53 +++- src/cli/output.ts | 2 +- src/lib/bridge-manager.ts | 23 ++ src/lib/types.ts | 8 + src/lib/x402/agent-guild-policy.ts | 293 ++++++++++++++++++ src/lib/x402/fetch-middleware.ts | 71 ++++- src/lib/x402/payment-policy.ts | 38 +++ test/unit/cli/output.test.ts | 14 + test/unit/lib/x402/agent-guild-policy.test.ts | 204 ++++++++++++ test/unit/lib/x402/fetch-middleware.test.ts | 138 +++++++++ 16 files changed, 1043 insertions(+), 20 deletions(-) create mode 100644 src/lib/x402/agent-guild-policy.ts create mode 100644 src/lib/x402/payment-policy.ts create mode 100644 test/unit/lib/x402/agent-guild-policy.test.ts diff --git a/README.md b/README.md index a14ce80e..82839e6c 100644 --- a/README.md +++ b/README.md @@ -754,6 +754,17 @@ Two schemes are supported, both signed by your local wallet: Flow: server returns HTTP 402 with a `PAYMENT-REQUIRED` header → `mcpc` picks the best scheme per your preference, signs, and retries with `PAYMENT-SIGNATURE` → server verifies and fulfills. Tools that advertise pricing in `_meta.x402` are signed proactively, skipping the 402 round-trip. +For guarded autonomous spending, `--x402-policy agent-guild` waits for the authoritative 402, +buys one short-lived [Agent Guild](https://agent-guild-5d5r.onrender.com) AGPD-1 decision, +and verifies its Ed25519 signature, issuer, freshness, policy thresholds, and exact payment fields +locally. The protected payment is never signed unless that credential says `allow`. Any decision +or verification failure blocks the payment. + +Guarded mode requires `--x402-max-amount ` as a local ceiling for the protected payment. +The decision is itself a paid x402 call on Base mainnet, but mcpc separately pins that purchase +to exact scheme, Base USDC, the Guild treasury and a $0.01 maximum; redirects fail closed. The +local wallet must hold enough Base USDC for both the decision and the protected tool call. + ### Wallet setup `mcpc` stores a single wallet in `~/.mcpc/wallets.json` (file permissions `0600`). @@ -819,6 +830,10 @@ mcpc connect mcp.apify.com @apify --x402 mcpc connect --x402 upto mcp.apify.com @apify mcpc connect mcp.apify.com @apify --x402 exact +# Require a signed, exact pre-payment decision and cap each tool payment at 1 USDC +mcpc connect mcp.apify.com @apify --x402 exact --x402-policy agent-guild \ + --x402-max-amount 1000000 + # The session now automatically handles 402 responses using your preference mcpc @apify tools-call expensive-tool query:="hello" @@ -827,7 +842,12 @@ mcpc @apify restart ``` When `--x402` is active, a fetch middleware wraps all HTTP requests to the MCP server. -If any request returns HTTP 402, the middleware transparently signs and retries. Your scheme preference is persisted in `sessions.json` and reused on every reconnect or restart. +If any request returns HTTP 402, the middleware transparently signs and retries. When a payment +policy is enabled, proactive signing is disabled until the authoritative 402 supplies the exact +resource URL; a signature already approved for the immediate retry may still be reused. Your +guarded retry receives its signature through call-local async state, so concurrent calls cannot +consume each other's approvals. Your scheme preference, payment policy and ceiling are persisted +in `sessions.json` and reused on every reconnect or restart. ### Supported networks diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index cd10ee8d..aeb5cfdf 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -120,6 +120,8 @@ Options: --stdio Launch all local stdio servers from selected config files --protocol-version Pin the MCP protocol version (see below) --x402 [scheme] Enable x402 auto-payment (see below) + --x402-policy Authorize each payment with a signed policy decision + --x402-max-amount Maximum atomic token amount for a guarded payment --json Output in JSON format Server formats: @@ -152,6 +154,9 @@ Protocol version: x402 payments (experimental): --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-policy agent-guild buys and locally verifies a short-lived signed + Agent Guild decision bound to the exact payment before the wallet signs. + Guarded mode also requires --x402-max-amount as a local spend ceiling. Output: For a single server, shows session, server info, capabilities, and tools. diff --git a/skills/mcpc/SKILL.md b/skills/mcpc/SKILL.md index 0cf56560..0689956d 100644 --- a/skills/mcpc/SKILL.md +++ b/skills/mcpc/SKILL.md @@ -243,7 +243,11 @@ mcpc @apify skills-get --raw # print the SKILL.md markdown (pipe to a (`--no-profile`, `--stdio`, `--proxy`, and `-H` are options of `connect`, not global flags.) -`mcpc` also has experimental `--x402` auto-payment for paid MCP tools — see `mcpc help x402`. +`mcpc` also has experimental `--x402` auto-payment for paid MCP tools. For autonomous wallets, +prefer `--x402-policy agent-guild --x402-max-amount `: it buys and locally verifies a +short-lived signed decision bound to the exact payment, enforces the local amount ceiling before +signing, and fails closed if the decision cannot be verified. +See `mcpc help x402`. ## Debugging diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 92ab2c33..cf7f1c8e 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -21,6 +21,7 @@ import type { IpcMessage, LoggingLevel, X402SchemePreference, + X402PaymentPolicyPreset, ServerDetails, } from '../lib/index.js'; import { @@ -28,6 +29,7 @@ import { MAX_PERSISTED_INSTRUCTIONS_CHARS, TRIMMED_INSTRUCTIONS_NOTICE, X402_SCHEME_PREFERENCES, + X402_PAYMENT_POLICY_PRESETS, } from '../lib/types.js'; import { createLogger, setVerbose, initFileLogger, closeFileLogger } from '../lib/index.js'; import { @@ -74,6 +76,7 @@ import type { ProxyConfig } from '../lib/types.js'; // x402 modules pull in the bundled viem (~1 MB of crypto code) — import types // only here and load the implementations lazily at the x402-gated call sites. import type { X402PaymentCache } from '../lib/x402/fetch-middleware.js'; +import type { X402PaymentPolicy, X402PaymentSignatureScope } from '../lib/x402/payment-policy.js'; import type { SignerWallet } from '../lib/x402/signer.js'; import type { FetchLike } from '@modelcontextprotocol/client'; @@ -96,6 +99,10 @@ interface BridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only set with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Optional fail-closed policy applied before every fresh x402 signature. */ + x402Policy?: X402PaymentPolicyPreset; + /** Required local atomic-unit ceiling when an x402 payment policy is enabled. */ + x402MaxAmountAtomic?: string; insecure?: boolean; // Skip TLS certificate verification } @@ -142,6 +149,11 @@ class BridgeProcess { // Shared payment signature cache — middleware reads/writes, bridge invalidates on payment-required results private x402PaymentCache: X402PaymentCache = { signature: null }; + // Built alongside the fetch middleware so HTTP and tool-result 402 paths + // authorize payments with the same fail-closed policy instance. + private x402PaymentPolicy: X402PaymentPolicy | null = null; + private x402PaymentSignatureScope: X402PaymentSignatureScope | null = null; + // Active async tasks (in-memory, also persisted to disk for crash recovery) private activeTasks: Map = new Map(); @@ -678,11 +690,27 @@ class BridgeProcess { return this.client?.getCachedTools()?.find((t: Tool) => t.name === name); }; const { createX402FetchMiddleware } = await import('../lib/x402/fetch-middleware.js'); + if (this.options.x402Policy === 'agent-guild') { + const { createAgentGuildPaymentPolicy } = await import('../lib/x402/agent-guild-policy.js'); + const { X402PaymentSignatureScope } = await import('../lib/x402/payment-policy.js'); + this.x402PaymentSignatureScope = new X402PaymentSignatureScope(); + this.x402PaymentPolicy = createAgentGuildPaymentPolicy({ + baseFetch: proxyFetch, + wallet, + ...(this.options.x402MaxAmountAtomic && { + maxAmountAtomic: this.options.x402MaxAmountAtomic, + }), + }); + } customFetch = createX402FetchMiddleware(proxyFetch, { wallet, getToolByName, paymentCache: this.x402PaymentCache, ...(this.options.x402 && { schemePreference: this.options.x402 }), + ...(this.x402PaymentPolicy && { paymentPolicy: this.x402PaymentPolicy }), + ...(this.x402PaymentSignatureScope && { + paymentSignatureScope: this.x402PaymentSignatureScope, + }), }); } @@ -1329,6 +1357,21 @@ class BridgeProcess { // Invalidate cache and sign fresh this.x402PaymentCache.signature = null; + if (this.x402PaymentPolicy) { + const decision = await this.x402PaymentPolicy({ + paymentRequired: { + x402Version: Number(paymentRequired.x402Version), + accepts: [parsed.accept], + ...(parsed.resource && { resource: parsed.resource }), + }, + selectedRequirements: parsed.accept, + ...(this.options.serverConfig.url && { requestUrl: this.options.serverConfig.url }), + }); + if (decision?.abort) { + throw new ClientError(`x402 payment blocked by policy: ${decision.reason}`); + } + } + let paymentSignatureBase64: string; try { const { signPayment } = await import('../lib/x402/signer.js'); const signed = await signPayment({ @@ -1336,7 +1379,7 @@ class BridgeProcess { accept: parsed.accept, resource: parsed.resource, }); - this.x402PaymentCache.signature = signed.paymentSignatureBase64; + paymentSignatureBase64 = signed.paymentSignatureBase64; logger.debug( `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` ); @@ -1346,7 +1389,12 @@ class BridgeProcess { } // Retry once with the new cached payment - const result = await retryFn(); + const result = this.x402PaymentSignatureScope + ? await this.x402PaymentSignatureScope.run(paymentSignatureBase64, retryFn) + : await (async () => { + this.x402PaymentCache.signature = paymentSignatureBase64; + return retryFn(); + })(); return { handled: true, result }; } @@ -1891,7 +1939,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--x402-policy ] [--x402-max-amount ] [--insecure]' ); process.exit(1); } @@ -1947,6 +1995,45 @@ async function main(): Promise { x402 = value as X402SchemePreference; } + let x402Policy: X402PaymentPolicyPreset | undefined; + const x402PolicyIndex = args.indexOf('--x402-policy'); + if (x402PolicyIndex !== -1) { + const value = args[x402PolicyIndex + 1]; + if ( + value === undefined || + !(X402_PAYMENT_POLICY_PRESETS as readonly string[]).includes(value) + ) { + console.error( + `--x402-policy requires one of: ${X402_PAYMENT_POLICY_PRESETS.join('|')} (got ${value ?? ''})` + ); + process.exit(1); + } + if (!x402) { + console.error('--x402-policy requires --x402'); + process.exit(1); + } + x402Policy = value as X402PaymentPolicyPreset; + } + + let x402MaxAmountAtomic: string | undefined; + const x402MaxAmountIndex = args.indexOf('--x402-max-amount'); + if (x402MaxAmountIndex !== -1) { + const value = args[x402MaxAmountIndex + 1]; + if (value === undefined || !/^[0-9]+$/.test(value) || BigInt(value) <= 0n) { + console.error('--x402-max-amount requires a positive atomic-unit integer'); + process.exit(1); + } + x402MaxAmountAtomic = value; + } + if (x402Policy && !x402MaxAmountAtomic) { + console.error('--x402-policy requires --x402-max-amount'); + process.exit(1); + } + if (x402MaxAmountAtomic && !x402Policy) { + console.error('--x402-max-amount requires --x402-policy'); + process.exit(1); + } + // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1975,6 +2062,12 @@ async function main(): Promise { if (x402) { bridgeOptions.x402 = x402; } + if (x402Policy) { + bridgeOptions.x402Policy = x402Policy; + } + if (x402MaxAmountAtomic) { + bridgeOptions.x402MaxAmountAtomic = x402MaxAmountAtomic; + } if (insecure) { bridgeOptions.insecure = true; } diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index 5d133e8a..f10b6723 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -25,6 +25,7 @@ import type { ProxyConfig, ServerDetails, X402SchemePreference, + X402PaymentPolicyPreset, } from '../../lib/types.js'; import { formatOutput, @@ -145,6 +146,8 @@ type ConnectSessionOptions = { proxyBearerToken?: string; protocolVersion?: string; x402?: X402SchemePreference; + x402Policy?: X402PaymentPolicyPreset; + x402MaxAmountAtomic?: string; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -292,12 +295,42 @@ export async function connectSession( // Validate --protocol-version (if provided) assertSupportedProtocolVersion(options.protocolVersion); + if (options.x402Policy && !options.x402) { + throw new ClientError('--x402-policy requires --x402'); + } + if (options.x402Policy && !options.x402MaxAmountAtomic) { + throw new ClientError('--x402-policy requires --x402-max-amount'); + } + if (options.x402MaxAmountAtomic && !options.x402Policy) { + throw new ClientError('--x402-max-amount requires --x402-policy'); + } + if ( + options.x402MaxAmountAtomic && + (!/^[0-9]+$/.test(options.x402MaxAmountAtomic) || BigInt(options.x402MaxAmountAtomic) <= 0n) + ) { + throw new ClientError('--x402-max-amount requires a positive atomic-unit integer'); + } + // Check if session already exists const existingSession = await getSession(name); if (existingSession) { const bridgeStatus = getBridgeStatus(existingSession); if (bridgeStatus === 'live') { + const paymentModeChanged = + (options.x402 !== undefined && options.x402 !== existingSession.x402) || + (options.x402Policy !== undefined && options.x402Policy !== existingSession.x402Policy) || + (options.x402MaxAmountAtomic !== undefined && + options.x402MaxAmountAtomic !== existingSession.x402MaxAmountAtomic) || + (existingSession.x402Policy !== undefined && + options.x402 !== undefined && + options.x402Policy === undefined); + if (paymentModeChanged) { + throw new ClientError( + `Session ${name} is already active with different x402 payment settings. ` + + `Close it before changing the scheme, policy, or amount ceiling.` + ); + } // Session exists and bridge is running - just show server info if (options.outputMode === 'human' && !options.quiet) { console.log(formatSuccess(`Session ${name} is already active`)); @@ -388,6 +421,24 @@ export async function connectSession( } logger.debug(`Using x402 wallet: ${wallet.address}`); } + // An explicit reconnect may omit payment flags. Preserve the stored policy + // and its ceiling rather than launching an unguarded bridge while the session + // record still claims it is guarded. An explicit conflicting mode is refused. + const effectiveX402 = options.x402 ?? existingSession?.x402; + const effectiveX402Policy = options.x402Policy ?? existingSession?.x402Policy; + const effectiveX402MaxAmountAtomic = + options.x402MaxAmountAtomic ?? existingSession?.x402MaxAmountAtomic; + if (existingSession?.x402Policy && options.x402 && !options.x402Policy) { + throw new ClientError( + `Session ${name} already uses x402 policy ${existingSession.x402Policy}; reconnect with ` + + `--x402-policy ${existingSession.x402Policy} --x402-max-amount ${existingSession.x402MaxAmountAtomic ?? ''}` + ); + } + if (effectiveX402Policy && (!effectiveX402 || !effectiveX402MaxAmountAtomic)) { + throw new ClientError( + 'Stored x402 policy is incomplete; reconnect with an explicit policy and ceiling' + ); + } // Create or update session record (without pid - that comes from startBridge) // Store serverConfig with headers redacted (actual values in keychain) @@ -402,7 +453,11 @@ export async function connectSession( server: sessionTransportConfig, ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), - ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402 && { x402: effectiveX402 }), + ...(effectiveX402Policy && { x402Policy: effectiveX402Policy }), + ...(effectiveX402MaxAmountAtomic && { + x402MaxAmountAtomic: effectiveX402MaxAmountAtomic, + }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -431,7 +486,11 @@ export async function connectSession( ...(headers && { headers }), ...(profileName && { profileName }), ...(proxyConfig && { proxyConfig }), - ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402 && { x402: effectiveX402 }), + ...(effectiveX402Policy && { x402Policy: effectiveX402Policy }), + ...(effectiveX402MaxAmountAtomic && { + x402MaxAmountAtomic: effectiveX402MaxAmountAtomic, + }), ...(options.insecure && { insecure: true }), }; @@ -526,7 +585,14 @@ export async function connectSession( */ async function findMatchingSession( parsed: { type: 'url'; url: string } | { type: 'config'; file: string; entry: string }, - options: { profile?: string; headers?: string[]; noProfile?: boolean } + options: { + profile?: string; + headers?: string[]; + noProfile?: boolean; + x402?: X402SchemePreference; + x402Policy?: X402PaymentPolicyPreset; + x402MaxAmountAtomic?: string; + } ): Promise { const storage = await loadSessions(); const sessions = Object.values(storage.sessions); @@ -570,6 +636,12 @@ async function findMatchingSession( .sort(); if (existingHeaderKeys.join(',') !== newHeaderKeys.join(',')) continue; + // Payment behavior is part of session identity. Reusing an unguarded + // session when the caller requested a policy would silently weaken it. + if (session.x402 !== options.x402) continue; + if (session.x402Policy !== options.x402Policy) continue; + if (session.x402MaxAmountAtomic !== options.x402MaxAmountAtomic) continue; + // Found a match return session.name; } @@ -590,6 +662,9 @@ export async function resolveSessionName( profile?: string; headers?: string[]; noProfile?: boolean; + x402?: X402SchemePreference; + x402Policy?: X402PaymentPolicyPreset; + x402MaxAmountAtomic?: string; } ): Promise { // First, check if an existing session matches this server + auth settings @@ -646,6 +721,8 @@ type BulkConnectOptions = { stdio?: boolean; protocolVersion?: string; x402?: X402SchemePreference; + x402Policy?: X402PaymentPolicyPreset; + x402MaxAmountAtomic?: string; insecure?: boolean; }; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 96cd022e..44f0e676 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -438,6 +438,10 @@ export async function restartSession( ...(profileName && { profileName }), ...(session.proxy && { proxyConfig: session.proxy }), ...(session.x402 && { x402: session.x402 }), + ...(session.x402Policy && { x402Policy: session.x402Policy }), + ...(session.x402MaxAmountAtomic && { + x402MaxAmountAtomic: session.x402MaxAmountAtomic, + }), ...(session.insecure && { insecure: session.insecure }), }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 3c3d18d1..71f35616 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -33,8 +33,8 @@ import * as tasks from './commands/tasks.js'; import * as grepCmd from './commands/grep.js'; import { clean } from './commands/clean.js'; import { MCPC_OAUTH_CALLBACK_HOSTS, MCPC_OAUTH_CALLBACK_PORTS } from '../lib/auth/oauth-utils.js'; -import type { OutputMode, X402SchemePreference } from '../lib/index.js'; -import { X402_SCHEME_PREFERENCES } from '../lib/index.js'; +import type { OutputMode, X402SchemePreference, X402PaymentPolicyPreset } from '../lib/index.js'; +import { X402_PAYMENT_POLICY_PRESETS, X402_SCHEME_PREFERENCES } from '../lib/index.js'; import { extractOptions, preProcessSkillArgv, @@ -89,6 +89,8 @@ interface HandlerOptions { * `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact). */ x402?: X402SchemePreference; + x402Policy?: X402PaymentPolicyPreset; + x402MaxAmountAtomic?: string; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -146,6 +148,27 @@ function getOptionsFromCommand(command: Command): HandlerOptions { } options.x402 = opts.x402 as X402SchemePreference; } + if (typeof opts.x402Policy === 'string') { + if (!(X402_PAYMENT_POLICY_PRESETS as readonly string[]).includes(opts.x402Policy)) { + throw new ClientError( + `Invalid --x402-policy value: "${opts.x402Policy}". Expected one of ${X402_PAYMENT_POLICY_PRESETS.join(', ')}.` + ); + } + if (!options.x402) throw new ClientError('--x402-policy requires --x402'); + options.x402Policy = opts.x402Policy as X402PaymentPolicyPreset; + } + if (typeof opts.x402MaxAmount === 'string') { + if (!/^[0-9]+$/.test(opts.x402MaxAmount) || BigInt(opts.x402MaxAmount) <= 0n) { + throw new ClientError('--x402-max-amount requires a positive atomic-unit integer'); + } + options.x402MaxAmountAtomic = opts.x402MaxAmount; + } + if (options.x402Policy && !options.x402MaxAmountAtomic) { + throw new ClientError('--x402-policy requires --x402-max-amount'); + } + if (options.x402MaxAmountAtomic && !options.x402Policy) { + throw new ClientError('--x402-max-amount requires --x402-policy'); + } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { @@ -489,6 +512,8 @@ Full docs: ${docsUrl}` .option('--stdio', 'Launch all local stdio servers from selected config files') .option('--protocol-version ', 'Pin the MCP protocol version (see below)') .option('--x402 [scheme]', 'Enable x402 auto-payment (see below)') + .option('--x402-policy ', 'Authorize each payment with a signed policy decision') + .option('--x402-max-amount ', 'Maximum atomic token amount for a guarded payment') .addHelpText( 'after', ` @@ -522,6 +547,9 @@ ${chalk.bold('Protocol version:')} ${chalk.bold('x402 payments (experimental):')} --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-policy agent-guild buys and locally verifies a short-lived signed + Agent Guild decision bound to the exact payment before the wallet signs. + Guarded mode also requires --x402-max-amount as a local spend ceiling. ${outputHelp([ 'For a single server, shows session, server info, capabilities, and tools.', 'Bulk connects list every session with its state, then a summary.', @@ -553,6 +581,10 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402Policy && { x402Policy: globalOpts.x402Policy }), + ...(globalOpts.x402MaxAmountAtomic && { + x402MaxAmountAtomic: globalOpts.x402MaxAmountAtomic, + }), ...(globalOpts.insecure && { insecure: true }), }); // Trailing blank line to match the spacing of other commands (human mode only). @@ -585,6 +617,10 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402Policy && { x402Policy: globalOpts.x402Policy }), + ...(globalOpts.x402MaxAmountAtomic && { + x402MaxAmountAtomic: globalOpts.x402MaxAmountAtomic, + }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -597,6 +633,11 @@ ${outputHelp([ ...(globalOpts.profile && { profile: globalOpts.profile }), ...(headers && { headers }), ...(globalOpts.noProfile && { noProfile: globalOpts.noProfile }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402Policy && { x402Policy: globalOpts.x402Policy }), + ...(globalOpts.x402MaxAmountAtomic && { + x402MaxAmountAtomic: globalOpts.x402MaxAmountAtomic, + }), }); } @@ -610,6 +651,10 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402Policy && { x402Policy: globalOpts.x402Policy }), + ...(globalOpts.x402MaxAmountAtomic && { + x402MaxAmountAtomic: globalOpts.x402MaxAmountAtomic, + }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -620,6 +665,10 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402Policy && { x402Policy: globalOpts.x402Policy }), + ...(globalOpts.x402MaxAmountAtomic && { + x402MaxAmountAtomic: globalOpts.x402MaxAmountAtomic, + }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/cli/output.ts b/src/cli/output.ts index 2a635336..4a9c0986 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1545,7 +1545,7 @@ export function formatSessionLine(session: SessionData): string { // x402 takes precedence when both happen to be present on the session record. let infoStr = ''; if (session.x402) { - infoStr = theme.yellow('[x402]'); + infoStr = theme.yellow(session.x402Policy ? `[x402:${session.x402Policy}]` : '[x402]'); } else if (!session.server.command && session.profileName) { infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')'); } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 1497ceaf..bcd054cc 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -21,6 +21,7 @@ import type { ProxyConfig, X402WalletCredentials, X402SchemePreference, + X402PaymentPolicyPreset, } from './types.js'; import { getSocketPath, @@ -120,6 +121,10 @@ export interface StartBridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only pass with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Optional fail-closed policy applied before every fresh x402 signature. */ + x402Policy?: X402PaymentPolicyPreset; + /** Required local atomic-unit ceiling for policy-guarded x402 payments. */ + x402MaxAmountAtomic?: string; insecure?: boolean; // Skip TLS certificate verification } @@ -153,6 +158,8 @@ export async function startBridge(options: StartBridgeOptions): Promise Date; +} + +function canonicalJson(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value === 'object') { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(',')}}`; + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('credential contains a non-finite number'); + } + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error('credential contains an unsupported value'); + return encoded; +} + +function decodeBase58(value: string): Buffer { + let number = 0n; + for (const char of value) { + const index = BASE58_ALPHABET.indexOf(char); + if (index < 0) throw new Error('invalid base58 value'); + number = number * 58n + BigInt(index); + } + let hex = number.toString(16); + if (hex.length % 2) hex = `0${hex}`; + const bytes = hex === '0' ? [] : [...Buffer.from(hex, 'hex')]; + for (const char of value) { + if (char !== '1') break; + bytes.unshift(0); + } + return Buffer.from(bytes); +} + +function publicKeyFromDid(did: string): Buffer { + const multibase = did.startsWith('did:key:') ? did.slice(8) : did; + if (!multibase.startsWith('z')) throw new Error('unsupported issuer DID'); + const decoded = decodeBase58(multibase.slice(1)); + if (decoded[0] !== 0xed || decoded[1] !== 0x01 || decoded.length !== 34) { + throw new Error('issuer DID is not an Ed25519 did:key'); + } + return decoded.subarray(2); +} + +function verifyCredential(credential: Record): boolean { + try { + const proof = credential.proof as Record | undefined; + if ( + proof?.type !== 'DataIntegrityProof' || + proof.cryptosuite !== 'eddsa-jcs-2022' || + typeof proof.proofValue !== 'string' || + !proof.proofValue.startsWith('z') + ) { + return false; + } + const issuer = String(credential.issuer || ''); + const verificationMethod = String(proof.verificationMethod || ''); + if (!issuer || verificationMethod.split('#', 1)[0] !== issuer) return false; + + const proofValue = proof.proofValue; + const { proofValue: _proofValue, ...proofConfig } = proof; + const { proof: _proof, ...document } = credential; + if ( + '@context' in proofConfig && + canonicalJson(proofConfig['@context']) !== canonicalJson(document['@context'] ?? null) + ) { + return false; + } + const hashData = Buffer.concat([ + createHash('sha256').update(canonicalJson(proofConfig)).digest(), + createHash('sha256').update(canonicalJson(document)).digest(), + ]); + const rawKey = publicKeyFromDid(issuer); + const derKey = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), rawKey]); + return verifySignature( + null, + hashData, + createPublicKey({ key: derKey, format: 'der', type: 'spki' }), + decodeBase58(proofValue.slice(1)) + ); + } catch { + return false; + } +} + +function exactAddress(value: unknown, label: string): string { + const address = String(value || '').toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(address)) { + throw new Error(`${label} is not an exact EVM address`); + } + return address; +} + +async function jsonResponse(fetcher: FetchLike, url: string, init?: RequestInit): Promise { + const response = await fetcher(url, init); + if (response.redirected || (response.status >= 300 && response.status < 400)) { + throw new Error(`redirect refused for ${new URL(url).pathname}`); + } + if (!response.ok) throw new Error(`HTTP ${response.status} for ${new URL(url).pathname}`); + return response.json(); +} + +/** Create mcpc's fail-closed Agent Guild policy preset. */ +export function createAgentGuildPaymentPolicy({ + baseFetch, + wallet, + decisionFetch: suppliedDecisionFetch, + host = DEFAULT_AGENT_GUILD_HOST, + maxRisk = 32.99, + minConfidence = 0.5, + ttlSeconds = 300, + maxAmountAtomic, + now = () => new Date(), +}: AgentGuildPolicyOptions): X402PaymentPolicy { + const base = host.replace(/\/$/, ''); + if (!maxAmountAtomic || !/^[0-9]+$/.test(maxAmountAtomic) || BigInt(maxAmountAtomic) <= 0n) { + throw new Error('Agent Guild payment policy requires a positive maxAmountAtomic ceiling'); + } + const protectedAmountCeiling = BigInt(maxAmountAtomic); + const baseOrigin = new URL(base).origin; + + const decisionFeePolicy: X402PaymentPolicy = async ({ + paymentRequired, + selectedRequirements, + requestUrl, + }) => { + try { + const paymentResource = new URL(String(paymentRequired.resource?.url || '')); + const decisionRequestUrl = new URL(String(requestUrl || '')); + const amount = BigInt(String(selectedRequirements.amount || '')); + const safe = + decisionRequestUrl.origin === baseOrigin && + decisionRequestUrl.pathname === '/wallet-binding/decision' && + paymentResource.origin === baseOrigin && + paymentResource.pathname === '/wallet-binding/decision' && + selectedRequirements.scheme === 'exact' && + selectedRequirements.network === BASE_MAINNET && + exactAddress(selectedRequirements.asset, 'decision asset') === BASE_MAINNET_USDC && + exactAddress(selectedRequirements.payTo, 'decision payTo') === AGENT_GUILD_TREASURY && + amount > 0n && + amount <= MAX_DECISION_FEE_ATOMIC; + return safe + ? undefined + : { abort: true, reason: 'Agent Guild decision fee exceeded its pinned local terms' }; + } catch { + return { abort: true, reason: 'Agent Guild decision fee challenge was invalid' }; + } + }; + + return async ({ paymentRequired, selectedRequirements, requestUrl }) => { + try { + const resource = String(paymentRequired.resource?.url || ''); + if (!/^https?:\/\//.test(resource)) { + throw new Error('authoritative payment resource is missing or is not HTTP(S)'); + } + if (requestUrl && new URL(resource).origin !== new URL(requestUrl).origin) { + throw new Error('payment resource origin does not match the MCP server origin'); + } + const amount = String(selectedRequirements.amount || ''); + if (!/^[0-9]+$/.test(amount) || BigInt(amount) <= 0n) { + throw new Error('amount is not a positive atomic-unit integer'); + } + if (BigInt(amount) > protectedAmountCeiling) { + return { abort: true, reason: 'payment exceeds the local maxAmountAtomic ceiling' }; + } + const expected = { + scheme: String(selectedRequirements.scheme || ''), + network: String(selectedRequirements.network || ''), + asset: exactAddress(selectedRequirements.asset, 'asset'), + amount, + pay_to: exactAddress(selectedRequirements.payTo, 'payTo'), + resource, + }; + const request = { + payment: expected, + capability: null, + policy: { max_risk: maxRisk, min_confidence: minConfidence }, + ttl_seconds: ttlSeconds, + }; + // A fresh isolated transport buys exactly this decision. Its narrow local + // policy pins origin, chain, token, treasury and a $0.01 fee ceiling, so + // obtaining policy evidence cannot become an unbounded wallet signature. + const decisionBaseFetch: FetchLike = async (url, init) => { + if (new URL(String(url)).origin !== baseOrigin) { + throw new Error('Agent Guild decision request left its pinned origin'); + } + return baseFetch(url, { ...init, redirect: 'manual' }); + }; + const decisionFetch = + suppliedDecisionFetch ?? + createX402FetchMiddleware(decisionBaseFetch, { + wallet, + paymentCache: { signature: null }, + schemePreference: 'exact', + paymentPolicy: decisionFeePolicy, + }); + const credential = (await jsonResponse(decisionFetch, `${base}/wallet-binding/decision`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(request), + })) as Record; + const issuerDocument = (await jsonResponse( + baseFetch, + `${base}/.well-known/agent-guild-did.json`, + { headers: { accept: 'application/json' }, redirect: 'manual' } + )) as Record; + const subject = (credential.credentialSubject || {}) as Record; + const sealedPayment = (subject.payment || {}) as Record; + const effectivePolicy = ((subject.policy as Record | undefined)?.effective || + {}) as Record; + const validFrom = new Date(String(credential.validFrom || '')); + const validUntil = new Date(String(credential.validUntil || '')); + const clock = now(); + const fresh = + Number.isFinite(validFrom.getTime()) && + Number.isFinite(validUntil.getTime()) && + validFrom <= clock && + clock <= validUntil && + validUntil.getTime() - validFrom.getTime() <= ttlSeconds * 1000; + const exact = + sealedPayment.scheme === expected.scheme && + sealedPayment.network === expected.network && + String(sealedPayment.asset || '').toLowerCase() === expected.asset && + sealedPayment.amount === expected.amount && + String(sealedPayment.pay_to || '').toLowerCase() === expected.pay_to && + sealedPayment.resource === expected.resource; + const policyExact = + Number(effectivePolicy.max_risk) <= maxRisk && + Number(effectivePolicy.min_confidence) >= minConfidence; + const proofValid = verifyCredential(credential) && credential.issuer === issuerDocument.did; + if ( + !proofValid || + !fresh || + !exact || + !policyExact || + subject.contract !== 'AGPD-1/1.0' || + subject.decision !== 'allow' + ) { + return { + abort: true, + reason: + typeof subject.reason === 'string' && proofValid && fresh && exact + ? subject.reason + : 'Agent Guild decision was invalid, stale, inexact, or did not allow payment', + }; + } + return undefined; + } catch (error) { + return { + abort: true, + reason: `Agent Guild payment verification unavailable: ${(error as Error).message}`, + }; + } + }; +} diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index 442eaa93..e10ee4d4 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -28,6 +28,8 @@ import { type SchemePreference, } from './signer.js'; import { createLogger } from '../logger.js'; +import { ClientError } from '../errors.js'; +import type { X402PaymentPolicy, X402PaymentSignatureScope } from './payment-policy.js'; const logger = createLogger('x402-middleware'); @@ -94,6 +96,12 @@ export interface X402FetchMiddlewareOptions { /** Payment scheme preference when multiple accepts are available (default: auto) */ schemePreference?: SchemePreference; + + /** Optional fail-closed policy invoked before every fresh payment signature. */ + paymentPolicy?: X402PaymentPolicy; + + /** Async-call-local signature handoff for policy-approved MCP retries. */ + paymentSignatureScope?: X402PaymentSignatureScope; } /** @@ -108,16 +116,27 @@ export function createX402FetchMiddleware( baseFetch: FetchLike, options: X402FetchMiddlewareOptions ): FetchLike { - const { wallet, getToolByName, paymentCache, schemePreference } = options; + const { + wallet, + getToolByName, + paymentCache, + schemePreference, + paymentPolicy, + paymentSignatureScope, + } = options; return async (url: string | URL, init?: RequestInit): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests + // A policy needs the authoritative resource URL from a real 402 challenge. + // Disable speculative metadata signing rather than guessing that binding. const paymentSignature = await getOrSignPayment( init, wallet, getToolByName, paymentCache, - schemePreference + schemePreference, + Boolean(paymentPolicy), + paymentSignatureScope ); if (paymentSignature) { const enhancedInit = injectPayment(init, paymentSignature); @@ -138,7 +157,9 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + paymentPolicy, + String(url) ); } @@ -154,7 +175,9 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + paymentPolicy, + String(url) ); } @@ -172,7 +195,9 @@ async function getOrSignPayment( wallet: SignerWallet, getToolByName: ((name: string) => Tool | undefined) | undefined, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + challengeOnly = false, + paymentSignatureScope?: X402PaymentSignatureScope ): Promise { if (!init?.body) { return undefined; @@ -189,14 +214,27 @@ async function getOrSignPayment( return undefined; } + // A guarded bridge passes its approved signature through AsyncLocalStorage, + // making the handoff private to the exact retry even when calls overlap. + const scopedSignature = paymentSignatureScope?.get(); + if (scopedSignature) { + logger.debug(`Using scoped payment signature for tool "${toolName}"`); + return scopedSignature; + } + // The bridge can populate this cache after receiving a payment-required // CallToolResult. That retry must not depend on proactive tools/list metadata: // challenge-first servers may omit _meta.x402 entirely. - if (paymentCache.signature) { + if (!challengeOnly && paymentCache.signature) { logger.debug(`Using cached payment signature for tool "${toolName}"`); return paymentCache.signature; } + // A guarded wallet needs the authoritative resource URL from a real 402. + // Cached signatures reached this point only after policy approval, but a new + // speculative signature based on tools/list metadata would not have that URL. + if (challengeOnly) return undefined; + if (!getToolByName) { return undefined; } @@ -247,7 +285,9 @@ async function handle402Fallback( baseFetch: FetchLike, wallet: SignerWallet, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + paymentPolicy?: X402PaymentPolicy, + requestUrl?: string ): Promise { // Extract PAYMENT-REQUIRED header (case-insensitive) const paymentRequiredBase64 = @@ -269,6 +309,17 @@ async function handle402Fallback( return response402; } + if (paymentPolicy) { + const decision = await paymentPolicy({ + paymentRequired: header, + selectedRequirements: accept, + ...(requestUrl && { requestUrl }), + }); + if (decision?.abort) { + throw new ClientError(`x402 payment blocked by policy: ${decision.reason}`); + } + } + // Sign the payment try { const result = await signPayment({ @@ -281,8 +332,10 @@ async function handle402Fallback( `402 fallback payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); - // Cache the freshly signed payment for subsequent calls - paymentCache.signature = result.paymentSignatureBase64; + // Unguarded sessions retain the existing session-level cache behavior. + // Guarded HTTP fallback retries directly below, so caching it would permit + // an unrelated later tool call to reuse resource-specific approval. + if (!paymentPolicy) paymentCache.signature = result.paymentSignatureBase64; // Retry with payment signature (once only) const retryInit = injectPayment(originalInit, result.paymentSignatureBase64); diff --git a/src/lib/x402/payment-policy.ts b/src/lib/x402/payment-policy.ts new file mode 100644 index 00000000..89df6be1 --- /dev/null +++ b/src/lib/x402/payment-policy.ts @@ -0,0 +1,38 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { PaymentRequiredAccept, PaymentRequiredHeader } from './signer.js'; + +/** Exact payment proposal presented to a policy before any wallet signature exists. */ +export interface X402PaymentPolicyContext { + paymentRequired: PaymentRequiredHeader; + selectedRequirements: PaymentRequiredAccept; + /** Actual HTTP URL receiving the signature, when the transport exposes one. */ + requestUrl?: string; +} + +export interface X402PaymentPolicyBlock { + abort: true; + reason: string; +} + +/** Return nothing to allow, or an explicit block to abort before signing. */ +export type X402PaymentPolicy = ( + context: X402PaymentPolicyContext +) => Promise; + +/** + * Carries one policy-approved signature only through the async retry that owns it. + * This prevents a concurrent tool call from consuming a signature approved for a + * different resource or payee through the bridge's legacy process-wide cache. + */ +export class X402PaymentSignatureScope { + private readonly storage = new AsyncLocalStorage(); + + get(): string | undefined { + return this.storage.getStore(); + } + + run(signature: string, retry: () => Promise): Promise { + return this.storage.run(signature, retry); + } +} diff --git a/test/unit/cli/output.test.ts b/test/unit/cli/output.test.ts index dee35e83..2e60c6ad 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -2225,6 +2225,20 @@ describe('formatSessionLine', () => { expect(output).not.toContain('[proxy:'); }); + + it('should make a guarded x402 session visible', () => { + const session: SessionData = { + name: '@guarded', + server: { url: 'https://mcp.example.com' }, + x402: 'exact', + x402Policy: 'agent-guild', + createdAt: '2025-01-01T00:00:00Z', + }; + + const output = formatSessionLine(session); + + expect(output).toContain('[x402:agent-guild]'); + }); }); describe('logTarget', () => { diff --git a/test/unit/lib/x402/agent-guild-policy.test.ts b/test/unit/lib/x402/agent-guild-policy.test.ts new file mode 100644 index 00000000..19d3d48b --- /dev/null +++ b/test/unit/lib/x402/agent-guild-policy.test.ts @@ -0,0 +1,204 @@ +import { createHash, generateKeyPairSync, sign as signEd25519, type KeyObject } from 'node:crypto'; + +import { createAgentGuildPaymentPolicy } from '../../../../src/lib/x402/agent-guild-policy.js'; +import type { X402PaymentPolicyContext } from '../../../../src/lib/x402/payment-policy.js'; +import type { SignerWallet } from '../../../../src/lib/x402/signer.js'; + +const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const NOW = new Date('2026-08-12T12:00:00.000Z'); + +function canonicalJson(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value === 'object') { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function encodeBase58(bytes: Uint8Array): string { + let number = BigInt(`0x${Buffer.from(bytes).toString('hex')}`); + let output = ''; + while (number > 0n) { + output = BASE58[Number(number % 58n)] + output; + number /= 58n; + } + for (const byte of bytes) { + if (byte !== 0) break; + output = `1${output}`; + } + return output || '1'; +} + +function issuerFixture(): { did: string; privateKey: KeyObject } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const rawPublicKey = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + const multicodec = Buffer.concat([Buffer.from([0xed, 0x01]), rawPublicKey]); + return { did: `did:key:z${encodeBase58(multicodec)}`, privateKey }; +} + +function signedDecision( + issuer: { did: string; privateKey: KeyObject }, + payment: Record, + mutate?: (subject: Record) => void +): Record { + const subject: Record = { + id: 'did:key:zProvider', + contract: 'AGPD-1/1.0', + payment, + policy: { effective: { max_risk: 32.99, min_confidence: 0.5 } }, + decision: 'allow', + reason: 'exact signed allow', + }; + mutate?.(subject); + const unsigned = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + id: 'urn:agent-guild:payment-decision:test', + type: ['VerifiableCredential', 'AgentGuildPaymentDecision'], + issuer: issuer.did, + validFrom: '2026-08-12T11:59:00.000Z', + validUntil: '2026-08-12T12:04:00.000Z', + credentialSubject: subject, + }; + const proofConfig = { + '@context': unsigned['@context'], + type: 'DataIntegrityProof', + cryptosuite: 'eddsa-jcs-2022', + created: unsigned.validFrom, + verificationMethod: `${issuer.did}#${issuer.did.slice(8)}`, + proofPurpose: 'assertionMethod', + }; + const hashData = Buffer.concat([ + createHash('sha256').update(canonicalJson(proofConfig)).digest(), + createHash('sha256').update(canonicalJson(unsigned)).digest(), + ]); + const signature = signEd25519(null, hashData, issuer.privateKey); + return { + ...unsigned, + proof: { ...proofConfig, proofValue: `z${encodeBase58(signature)}` }, + }; +} + +const WALLET: SignerWallet = { + privateKey: `0x${'11'.repeat(32)}`, + address: `0x${'88'.repeat(20)}`, +}; + +const CONTEXT: X402PaymentPolicyContext = { + paymentRequired: { + x402Version: 2, + resource: { url: 'https://seller.example/research/42' }, + accepts: [], + }, + selectedRequirements: { + scheme: 'exact', + network: 'eip155:8453', + asset: `0x${'77'.repeat(20)}`, + amount: '25000', + payTo: `0x${'66'.repeat(20)}`, + maxTimeoutSeconds: 300, + extra: {}, + }, +}; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('createAgentGuildPaymentPolicy', () => { + it('allows only a fresh, exact, issuer-pinned signed decision', async () => { + const issuer = issuerFixture(); + const baseFetch = vi.fn().mockResolvedValue(response({ did: issuer.did })); + const decisionFetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return response(signedDecision(issuer, request.payment)); + }); + const policy = createAgentGuildPaymentPolicy({ + baseFetch: baseFetch as never, + decisionFetch: decisionFetch as never, + wallet: WALLET, + host: 'https://guild.example', + maxAmountAtomic: '1000000', + now: () => NOW, + }); + + await expect(policy(CONTEXT)).resolves.toBeUndefined(); + expect(decisionFetch).toHaveBeenCalledTimes(1); + expect(baseFetch).toHaveBeenCalledWith( + 'https://guild.example/.well-known/agent-guild-did.json', + expect.any(Object) + ); + }); + + it('blocks a signed credential whose amount was changed', async () => { + const issuer = issuerFixture(); + const baseFetch = vi.fn().mockResolvedValue(response({ did: issuer.did })); + const decisionFetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return response( + signedDecision(issuer, request.payment, (subject) => { + (subject.payment as Record).amount = '25001'; + }) + ); + }); + const policy = createAgentGuildPaymentPolicy({ + baseFetch: baseFetch as never, + decisionFetch: decisionFetch as never, + wallet: WALLET, + maxAmountAtomic: '1000000', + now: () => NOW, + }); + + await expect(policy(CONTEXT)).resolves.toEqual( + expect.objectContaining({ + abort: true, + reason: expect.stringMatching(/invalid, stale, inexact/), + }) + ); + }); + + it('fails closed when the decision service is unavailable', async () => { + const policy = createAgentGuildPaymentPolicy({ + baseFetch: vi.fn() as never, + decisionFetch: vi.fn().mockRejectedValue(new Error('offline')) as never, + wallet: WALLET, + maxAmountAtomic: '1000000', + now: () => NOW, + }); + + await expect(policy(CONTEXT)).resolves.toEqual({ + abort: true, + reason: 'Agent Guild payment verification unavailable: offline', + }); + }); + + it('blocks before buying a decision when the payment exceeds the local ceiling', async () => { + const decisionFetch = vi.fn(); + const policy = createAgentGuildPaymentPolicy({ + baseFetch: vi.fn() as never, + decisionFetch: decisionFetch as never, + wallet: WALLET, + maxAmountAtomic: '24999', + now: () => NOW, + }); + + await expect(policy(CONTEXT)).resolves.toEqual({ + abort: true, + reason: 'payment exceeds the local maxAmountAtomic ceiling', + }); + expect(decisionFetch).not.toHaveBeenCalled(); + }); + + it('requires an explicit positive local payment ceiling', () => { + expect(() => + createAgentGuildPaymentPolicy({ baseFetch: vi.fn() as never, wallet: WALLET }) + ).toThrow('requires a positive maxAmountAtomic ceiling'); + }); +}); diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts index b4cd7639..38e13589 100644 --- a/test/unit/lib/x402/fetch-middleware.test.ts +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -15,6 +15,10 @@ import { type X402PaymentCache, } from '../../../../src/lib/x402/fetch-middleware.js'; import type { PaymentRequiredAccept, SignerWallet } from '../../../../src/lib/x402/signer.js'; +import { + X402PaymentSignatureScope, + type X402PaymentPolicy, +} from '../../../../src/lib/x402/payment-policy.js'; // --------------------------------------------------------------------------- // Mocks — vi.mock is hoisted above local const declarations @@ -79,6 +83,16 @@ function toolsCallBody(toolName: string): string { }); } +function paymentRequiredHeader(accept: PaymentRequiredAccept): string { + return Buffer.from( + JSON.stringify({ + x402Version: 2, + resource: { url: 'https://seller.example/paid-tool' }, + accepts: [accept], + }) + ).toString('base64'); +} + beforeEach(() => { mockSignPayment.mockReset(); mockSignPayment.mockResolvedValue({ @@ -215,6 +229,130 @@ describe('createX402FetchMiddleware proactive sign', () => { const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; expect(accept.scheme).toBe('upto'); }); + + it('waits for an authoritative 402 before a policy authorizes a fresh signature', async () => { + const tool = makePaidTool({ accepts: [EXACT_ACCEPT], ...EXACT_ACCEPT }); + const policy = vi.fn().mockResolvedValue(undefined); + const baseFetch = vi + .fn() + .mockResolvedValueOnce( + new Response('', { + status: 402, + headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader(EXACT_ACCEPT) }, + }) + ) + .mockResolvedValueOnce(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: { signature: null }, + schemePreference: 'exact', + paymentPolicy: policy, + }); + + await fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('paid-tool'), + }); + + expect(baseFetch).toHaveBeenCalledTimes(2); + expect(new Headers(baseFetch.mock.calls[0]?.[1]?.headers).has('PAYMENT-SIGNATURE')).toBe(false); + expect(policy).toHaveBeenCalledWith({ + paymentRequired: expect.objectContaining({ + resource: { url: 'https://seller.example/paid-tool' }, + }), + selectedRequirements: expect.objectContaining({ scheme: 'exact' }), + requestUrl: 'https://example.test/mcp', + }); + expect(mockSignPayment).toHaveBeenCalledTimes(1); + }); + + it('does not create a wallet signature when the policy blocks', async () => { + const policy = vi + .fn() + .mockResolvedValue({ abort: true, reason: 'payee is not trusted' }); + const baseFetch = vi.fn().mockResolvedValue( + new Response('', { + status: 402, + headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader(EXACT_ACCEPT) }, + }) + ); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + paymentCache: { signature: null }, + schemePreference: 'exact', + paymentPolicy: policy, + }); + + await expect( + fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('paid-tool'), + }) + ).rejects.toThrow('x402 payment blocked by policy: payee is not trusted'); + expect(baseFetch).toHaveBeenCalledTimes(1); + expect(mockSignPayment).not.toHaveBeenCalled(); + }); + + it('ignores the process-wide cache in policy mode', async () => { + const cachedPayload = { + x402Version: 2, + payload: { signature: '0xapproved', authorization: { from: WALLET.address } }, + }; + const cachedSignature = Buffer.from(JSON.stringify(cachedPayload)).toString('base64'); + const policy = vi.fn(); + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const cache: X402PaymentCache = { signature: cachedSignature }; + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + paymentCache: cache, + paymentPolicy: policy, + }); + + await fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('paid-tool'), + }); + + expect(new Headers(baseFetch.mock.calls[0]?.[1]?.headers).has('PAYMENT-SIGNATURE')).toBe(false); + expect(policy).not.toHaveBeenCalled(); + expect(mockSignPayment).not.toHaveBeenCalled(); + expect(cache.signature).toBe(cachedSignature); + }); + + it('hands a guarded retry signature through call-local async context', async () => { + const scopedPayload = { + x402Version: 2, + payload: { signature: '0xscoped', authorization: { from: WALLET.address } }, + }; + const scopedSignature = Buffer.from(JSON.stringify(scopedPayload)).toString('base64'); + const scope = new X402PaymentSignatureScope(); + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + paymentCache: { signature: null }, + paymentPolicy: vi.fn(), + paymentSignatureScope: scope, + }); + + await Promise.all([ + scope.run(scopedSignature, () => + fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('paid-tool'), + }) + ), + fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('other-tool'), + }), + ]); + + const paidHeaders = baseFetch.mock.calls.map((call) => + new Headers(call[1]?.headers).get('PAYMENT-SIGNATURE') + ); + expect(paidHeaders).toEqual([scopedSignature, null]); + }); }); // ---------------------------------------------------------------------------