Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions plugins/compliance/actions/check-agent-identity.ts
Original file line number Diff line number Diff line change
@@ -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<CheckAgentIdentityInput, CheckAgentIdentityOutput> {
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,
};
},
};
}
121 changes: 121 additions & 0 deletions plugins/compliance/actions/validate-x402-payment.ts
Original file line number Diff line number Diff line change
@@ -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,
};
},
};
}
115 changes: 115 additions & 0 deletions plugins/compliance/actions/validate-x402-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { z } from 'zod';
import type { ActionDefinition } from '../../../core/schema/action';

export interface ValidateX402ResponseInput {
statusCode: number;
headers: Record<string, string>;
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,
};
},
};
}
3 changes: 3 additions & 0 deletions plugins/compliance/index.ts
Original file line number Diff line number Diff line change
@@ -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';