diff --git a/README.md b/README.md index bded569..7969826 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,19 @@ The main entry point. Creates an agent that listens for job events and manages s ```typescript const agent = await AcpAgent.create({ provider: providerAdapter, // required -- EVM or Solana provider + // Optional: inspect the exact job and counterparty before any fund() + // transaction is prepared. Throws and allow:false both fail closed. + fundPolicy: async ({ providerAddress, chainId, amount, job }) => { + const decision = await myCounterpartyPolicy({ + providerAddress, + chainId, + amount, + capability: job.description, + }); + return decision.allowed + ? { allow: true, evidence: decision.evidence } + : { allow: false, reason: decision.reason }; + }, }); agent.on("entry", async (session, entry) => { @@ -212,6 +225,11 @@ await agent.stop(); | `agent.getAddress()` | Get the agent's wallet address | | `agent.getSession(chainId, jobId)` | Get an active session | +When `fundPolicy` is configured, `session.fund()` invokes it once with the exact +provider wallet, chain, amount, and hydrated `AcpJob` before preparing any funding +transaction. The policy must return `{ allow: true }`; denial, an invalid decision, +or an exception aborts funding. Omitting the policy preserves existing behaviour. + ### JobSession Represents your participation in a single job. Tracks role, status, conversation history, and available actions. diff --git a/package.json b/package.json index 153f685..6b49562 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "prepare": "tsc", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsc && node --test test/*.test.mjs", "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js" diff --git a/src/acpAgent.ts b/src/acpAgent.ts index 437129c..966654c 100644 --- a/src/acpAgent.ts +++ b/src/acpAgent.ts @@ -43,6 +43,11 @@ import { type MultiHookConfig, } from "./core/hookEncoding.js"; import { AssetToken } from "./core/assetToken.js"; +import type { AcpJob } from "./acpJob.js"; +import { + enforceFundPolicy, + type FundPolicy, +} from "./core/fundPolicy.js"; import { withReprepare } from "./core/reprepareRetry.js"; import { JobSession } from "./jobSession.js"; import { AcpApiClient } from "./events/acpApiClient.js"; @@ -72,6 +77,8 @@ export type EntryHandler = ( export type CreateAgentInput = CreateAcpClientInput & { transport?: AcpChatTransport; api?: AcpJobApi; + /** Optional fail-closed gate evaluated immediately before every fund(). */ + fundPolicy?: FundPolicy; }; export type SetBudgetParams = { @@ -160,6 +167,7 @@ export class AcpAgent { private readonly clients: Map; private readonly transport: AcpChatTransport; private readonly api: AcpJobApi; + private readonly fundPolicy: FundPolicy | undefined; private started = false; private entryHandler: EntryHandler | null = null; private sessionMap = new Map(); @@ -169,20 +177,23 @@ export class AcpAgent { clients: Map, transport: AcpChatTransport, api: AcpJobApi, + fundPolicy?: FundPolicy, ) { this.clients = clients; this.transport = transport; this.api = api; + this.fundPolicy = fundPolicy; } static async create(input: CreateAgentInput): Promise { const { transport = new SseTransport(), api = new AcpApiClient(), + fundPolicy, ...clientInput } = input; const clients = await createAcpClients(clientInput); - const agent = new AcpAgent(clients, transport, api); + const agent = new AcpAgent(clients, transport, api, fundPolicy); const ctx = await agent.buildTransportContext(); if (transport instanceof AcpHttpClient) transport.setContext(ctx); @@ -212,6 +223,18 @@ export class AcpAgent { return this.api; } + async enforceFundPolicy(job: AcpJob, amount: AssetToken): Promise { + await enforceFundPolicy(this.fundPolicy, { + action: "fund", + job, + chainId: job.chainId, + jobId: job.id, + providerAddress: job.providerAddress, + clientAddress: job.clientAddress, + amount, + }); + } + getSupportedChainIds(): number[] { const ids: number[] = []; for (const client of this.clients.values()) { diff --git a/src/core/fundPolicy.ts b/src/core/fundPolicy.ts new file mode 100644 index 0000000..4fc9365 --- /dev/null +++ b/src/core/fundPolicy.ts @@ -0,0 +1,51 @@ +import type { AcpJob } from "../acpJob.js"; +import type { AssetToken } from "./assetToken.js"; + +/** The exact transaction context evaluated immediately before job funding. */ +export type FundPolicyContext = { + action: "fund"; + job: AcpJob; + chainId: number; + jobId: bigint; + providerAddress: string; + clientAddress: string; + amount: AssetToken; +}; + +export type FundPolicyDecision = { + allow: boolean; + reason?: string; + /** Optional machine-readable material the policy used to reach its decision. */ + evidence?: unknown; +}; + +export type FundPolicy = ( + context: FundPolicyContext +) => FundPolicyDecision | Promise; + +/** Raised before any funding transaction when a configured policy denies it. */ +export class FundPolicyDeniedError extends Error { + readonly decision: FundPolicyDecision; + + constructor(decision: FundPolicyDecision) { + super(decision.reason ?? "Funding denied by policy"); + this.name = "FundPolicyDeniedError"; + this.decision = decision; + } +} + +export async function enforceFundPolicy( + policy: FundPolicy | undefined, + context: FundPolicyContext +): Promise { + if (!policy) return; + + // A policy failure is deliberately fail-closed: throws propagate and an + // explicit allow=true is required before the SDK prepares any transaction. + const decision = await policy(context); + if (!decision || decision.allow !== true) { + throw new FundPolicyDeniedError( + decision ?? { allow: false, reason: "Funding policy returned no decision" } + ); + } +} diff --git a/src/index.ts b/src/index.ts index 1b0a7dd..63d3719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export * from "./core/chains.js"; export * from "./core/constants.js"; export * from "./core/assetToken.js"; export * from "./core/approvalGate.js"; +export * from "./core/fundPolicy.js"; // Provider interfaces & adapters export * from "./providers/types.js"; diff --git a/src/jobSession.ts b/src/jobSession.ts index 20ce4c1..6f2a66f 100644 --- a/src/jobSession.ts +++ b/src/jobSession.ts @@ -349,19 +349,19 @@ export class JobSession { ); } - private detectConfiguredHooks(selector: Hex): { + private detectConfiguredHooks(selector: Hex, job = this._job): { hasSub: boolean; hasFund: boolean; } { - if (!this._job) throw new Error("Job not loaded"); + if (!job) throw new Error("Job not loaded"); - const hook = this._job.hookAddress.toLowerCase(); + const hook = job.hookAddress.toLowerCase(); const router = MULTI_HOOK_ROUTER_ADDRESSES[this.chainId]?.toLowerCase(); const subHook = SUBSCRIPTION_HOOK_ADDRESSES[this.chainId]?.toLowerCase(); const fundHook = FUND_TRANSFER_HOOK_ADDRESSES[this.chainId]?.toLowerCase(); if (hook === router) { - const configured = (this._job.hookConfigs ?? {})[selector]; + const configured = (job.hookConfigs ?? {})[selector]; const lower = configured?.map((h) => h.toLowerCase()) ?? []; return { hasSub: lower.includes(subHook ?? ""), @@ -504,23 +504,34 @@ export class JobSession { async fund(amount?: AssetToken): Promise { if (!this._job) throw new Error("Job not loaded"); - const effectiveAmount = amount ?? this._job.budget; + // Hold one immutable job reference across the asynchronous policy decision + // and every downstream funding branch. fetchJob() may refresh this._job + // while a slow policy is running; funding must use the exact snapshot that + // the policy approved. + const job = this._job; + const effectiveAmount = amount ?? job.budget; const jobId = BigInt(this.jobId); - const hook = this._job.hookAddress.toLowerCase(); + // Evaluate the exact provider wallet, chain and amount before any funding + // branch prepares or sends an on-chain transaction. A denied or failed + // policy is intentionally fail-closed. + await this.agent.enforceFundPolicy(job, effectiveAmount); + + const hook = job.hookAddress.toLowerCase(); const router = ( MULTI_HOOK_ROUTER_ADDRESSES[this.chainId] ?? "" ).toLowerCase(); if (router && hook === router) { - const hookConfigs = (this._job.hookConfigs ?? {})[ACP_SELECTORS.fund]; + const hookConfigs = (job.hookConfigs ?? {})[ACP_SELECTORS.fund]; if (!hookConfigs || hookConfigs.length === 0) { throw new Error( "MultiHookRouter is attached but no sub-hooks are configured for the fund selector" ); } const { hasSub, hasFund } = this.detectConfiguredHooks( - ACP_SELECTORS.fund + ACP_SELECTORS.fund, + job ); let subscriptionTerms: @@ -537,7 +548,7 @@ export class JobSession { } if (hasFund) { - const intent = this._job.getFundRequestIntent(); + const intent = job.getFundRequestIntent(); if (!intent) { throw new Error( "FundTransferHook is configured on the router but no fund request intent was recorded" @@ -563,7 +574,10 @@ export class JobSession { return; } - const { hasSub, hasFund } = this.detectConfiguredHooks(ACP_SELECTORS.fund); + const { hasSub, hasFund } = this.detectConfiguredHooks( + ACP_SELECTORS.fund, + job + ); if (hasSub) { const terms = await this.agent.getProposedSubscriptionTerms( @@ -579,7 +593,7 @@ export class JobSession { return; } - const intent = this._job.getFundRequestIntent(); + const intent = job.getFundRequestIntent(); if (intent && hasFund) { const transferAmount = await intent.resolveAmount( this.chainId, @@ -591,7 +605,7 @@ export class JobSession { amount: effectiveAmount, transferAmount, destination: intent.recipientAddress, - clientAddress: this._job.clientAddress, + clientAddress: job.clientAddress, }); return; } @@ -599,7 +613,7 @@ export class JobSession { await this.agent.internalFund(this.chainId, { jobId, amount: effectiveAmount, - clientAddress: this._job.clientAddress, + clientAddress: job.clientAddress, }); } diff --git a/test/fundPolicy.test.mjs b/test/fundPolicy.test.mjs new file mode 100644 index 0000000..54ada2a --- /dev/null +++ b/test/fundPolicy.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + enforceFundPolicy, + FundPolicyDeniedError, +} from "../dist/core/fundPolicy.js"; +import { JobSession } from "../dist/jobSession.js"; + +const context = { + action: "fund", + job: {}, + chainId: 8453, + jobId: 17n, + providerAddress: "0x1111111111111111111111111111111111111111", + clientAddress: "0x2222222222222222222222222222222222222222", + amount: {}, +}; + +test("allows only an explicit allow decision", async () => { + let received; + await enforceFundPolicy(async value => { + received = value; + return { allow: true, evidence: { source: "policy" } }; + }, context); + + assert.equal(received, context); +}); + +test("throws a typed error for an explicit denial", async () => { + const decision = { allow: false, reason: "counterparty rejected" }; + + await assert.rejects( + enforceFundPolicy(async () => decision, context), + error => { + assert.ok(error instanceof FundPolicyDeniedError); + assert.equal(error.message, decision.reason); + assert.equal(error.decision, decision); + return true; + }, + ); +}); + +test("fails closed when a policy returns no decision", async () => { + await assert.rejects( + enforceFundPolicy(async () => undefined, context), + /Funding policy returned no decision/, + ); +}); + +test("propagates policy failures and preserves opt-in compatibility", async () => { + const failure = new Error("policy unavailable"); + await assert.rejects( + enforceFundPolicy(async () => { throw failure; }, context), + error => error === failure, + ); + await enforceFundPolicy(undefined, context); +}); + +test("fund uses the exact job snapshot approved by a slow policy", async () => { + let releasePolicy; + const policyPending = new Promise(resolve => { + releasePolicy = resolve; + }); + let policyStarted; + const policyStartedPromise = new Promise(resolve => { + policyStarted = resolve; + }); + let approvedJob; + let funded; + const agent = { + enforceFundPolicy: async job => { + approvedJob = job; + policyStarted(); + await policyPending; + }, + internalFund: async (_chainId, input) => { + funded = input; + }, + }; + const makeJob = clientAddress => ({ + budget: { source: clientAddress }, + clientAddress, + hookAddress: "0x0000000000000000000000000000000000000000", + hookConfigs: null, + getFundRequestIntent: () => null, + }); + const approvedSnapshot = makeJob( + "0x1111111111111111111111111111111111111111", + ); + const refreshedSnapshot = makeJob( + "0x2222222222222222222222222222222222222222", + ); + const session = new JobSession(agent, [], "17", 8453, ["client"]); + session._job = approvedSnapshot; + + const funding = session.fund(); + await policyStartedPromise; + session._job = refreshedSnapshot; + releasePolicy(); + await funding; + + assert.equal(approvedJob, approvedSnapshot); + assert.equal(funded.clientAddress, approvedSnapshot.clientAddress); + assert.equal(funded.amount, approvedSnapshot.budget); +});