-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add fail-closed pre-fund policy hook #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AgentTanuki
wants to merge
10
commits into
Virtual-Protocol:main
Choose a base branch
from
AgentTanuki:codex/pre-fund-policy-hook
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+228
−15
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
ad6b29c
docs: document pre-fund policy hook
AgentTanuki 694d6b8
feat: wire pre-fund policy into agent creation
AgentTanuki d067e9e
feat: export fund policy types
AgentTanuki 928e61e
feat: enforce policy before funding
AgentTanuki 2562197
test: expose policy test command
AgentTanuki 65a6be9
feat: add fail-closed fund policy primitive
AgentTanuki 19b4557
test: cover pre-fund policy decisions
AgentTanuki 8bc7d9c
fix: fund from the policy-approved job snapshot
AgentTanuki 3a28025
test: lock funding to the approved job snapshot
AgentTanuki cbaf094
fix: preserve the policy-approved job snapshot
AgentTanuki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FundPolicyDecision>; | ||
|
|
||
| /** 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<void> { | ||
| 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" } | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.