From ad6b29cfc8639686b749abc5c724fa4244f42071 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:25:46 +0100 Subject: [PATCH 01/10] docs: document pre-fund policy hook --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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. From 694d6b808a90d0102a8765724b0d7e4f162f3ba0 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:26:33 +0100 Subject: [PATCH 02/10] feat: wire pre-fund policy into agent creation --- src/acpAgent.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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()) { From d067e9eb1a746e4fb6efe4172a3f85ae80e5a60f Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:26:36 +0100 Subject: [PATCH 03/10] feat: export fund policy types --- src/index.ts | 1 + 1 file changed, 1 insertion(+) 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"; From 928e61e968ea86f9d655367ff1ed2cfd3e4e75bc Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:26:39 +0100 Subject: [PATCH 04/10] feat: enforce policy before funding --- src/jobSession.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/jobSession.ts b/src/jobSession.ts index 20ce4c1..e292325 100644 --- a/src/jobSession.ts +++ b/src/jobSession.ts @@ -507,6 +507,11 @@ export class JobSession { const effectiveAmount = amount ?? this._job.budget; const jobId = BigInt(this.jobId); + // 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(this._job, effectiveAmount); + const hook = this._job.hookAddress.toLowerCase(); const router = ( MULTI_HOOK_ROUTER_ADDRESSES[this.chainId] ?? "" From 2562197e4bdab68fc8eefa2f89b61056de22f8a6 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:26:46 +0100 Subject: [PATCH 05/10] test: expose policy test command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 65a6be92cf48176ce285172d67dd47c5cae85ca7 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:27:05 +0100 Subject: [PATCH 06/10] feat: add fail-closed fund policy primitive --- src/core/fundPolicy.ts | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/core/fundPolicy.ts 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" } + ); + } +} From 19b45575467c98bf7f605db29adcfbf89292c505 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:27:08 +0100 Subject: [PATCH 07/10] test: cover pre-fund policy decisions --- test/fundPolicy.test.mjs | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/fundPolicy.test.mjs diff --git a/test/fundPolicy.test.mjs b/test/fundPolicy.test.mjs new file mode 100644 index 0000000..98875a9 --- /dev/null +++ b/test/fundPolicy.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + enforceFundPolicy, + FundPolicyDeniedError, +} from "../dist/core/fundPolicy.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); +}); From 8bc7d9c55eeb8c6a6f8d256e1dbeb697ef89aa93 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:43:02 +0100 Subject: [PATCH 08/10] fix: fund from the policy-approved job snapshot --- src/jobSession.ts | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/jobSession.ts b/src/jobSession.ts index e292325..14a07e9 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,28 +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); // 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(this._job, effectiveAmount); + await this.agent.enforceFundPolicy(job, effectiveAmount); - const hook = this._job.hookAddress.toLowerCase(); + 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: @@ -542,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" @@ -568,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( @@ -584,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, @@ -596,7 +605,7 @@ export class JobSession { amount: effectiveAmount, transferAmount, destination: intent.recipientAddress, - clientAddress: this._job.clientAddress, + clientAddress: job.clientAddress, }); return; } @@ -604,7 +613,7 @@ export class JobSession { await this.agent.internalFund(this.chainId, { jobId, amount: effectiveAmount, - clientAddress: this._job.clientAddress, + clientAddress: job.clientAddress, }); } @@ -749,10 +758,3 @@ export class JobSession { const isOwnMessage = this.agentAddresses.has(e.from.toLowerCase()); result.push({ role: isOwnMessage ? "assistant" : "user", - content: isOwnMessage ? e.content : `[${e.from}]: ${e.content}`, - }); - } - } - return result; - } -} From 3a280254ddf9b6c6fa72b2c4af6857901bfadef9 Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 17:43:28 +0100 Subject: [PATCH 09/10] test: lock funding to the approved job snapshot --- test/fundPolicy.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/fundPolicy.test.mjs b/test/fundPolicy.test.mjs index 98875a9..54ada2a 100644 --- a/test/fundPolicy.test.mjs +++ b/test/fundPolicy.test.mjs @@ -5,6 +5,7 @@ import { enforceFundPolicy, FundPolicyDeniedError, } from "../dist/core/fundPolicy.js"; +import { JobSession } from "../dist/jobSession.js"; const context = { action: "fund", @@ -55,3 +56,51 @@ test("propagates policy failures and preserves opt-in compatibility", async () = ); 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); +}); From cbaf094480fa6f576bec0876c96673af39f85f3f Mon Sep 17 00:00:00 2001 From: AgentTanuki Date: Fri, 7 Aug 2026 19:24:08 +0100 Subject: [PATCH 10/10] fix: preserve the policy-approved job snapshot --- src/jobSession.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/jobSession.ts b/src/jobSession.ts index 14a07e9..6f2a66f 100644 --- a/src/jobSession.ts +++ b/src/jobSession.ts @@ -758,3 +758,10 @@ export class JobSession { const isOwnMessage = this.agentAddresses.has(e.from.toLowerCase()); result.push({ role: isOwnMessage ? "assistant" : "user", + content: isOwnMessage ? e.content : `[${e.from}]: ${e.content}`, + }); + } + } + return result; + } +}