Skip to content

Releases: premsreelathasugeendran/vaduno

v0.2.1 — fix two cap bypasses shipped in 0.2.0

Choose a tag to compare

@premsreelathasugeendran premsreelathasugeendran released this 29 Jul 22:59

0.2.1 — 2026-07-29

Two cap bypasses shipped in 0.2.0. Upgrade. Both were introduced by the
0.2.0 limiter itself — the release whose entire purpose was to make caps hold —
and both were found by a fact-check pass after publishing, not before.

Fixed

  • Rotating intent.agentId reset the cap. The reservation was scoped to
    agentId, a field this project's own threat model
    (CONTRIBUTING rule 4, types.ts)
    states the attacker controls. Reproduced: two guards sharing one limiter,
    $100 through a $50/day cap, by changing one string. The same test with a
    fixed agentId correctly denied — which is why the conformance suite missed
    it entirely.

    Reservations are now scoped to policy.id, which the operator sets. Note
    this also means the pre-0.2.0 behaviour is restored: the old in-memory history
    deliberately ignored agentId for exactly this reason, and 0.2.0 silently
    regressed a mitigation SECURITY.md had claimed since 0.1.0. If you want
    per-agent budgets, derive the scope from an id you assign to a trusted
    principal, never from the intent.

  • Reusing an intent.id ran the rail again while counting one charge. A
    replayed reservation returned ok and the guard fell through to the executor.
    Reproduced: the same intent id twice ran the rail twice, counted once; a
    throwing executor retried eight times ran the rail eight times and still
    counted one $50 charge. That directly contradicted the burn-on-failure
    guarantee 0.2.0 added, which exists so an executor timing out after the
    charge lands cannot be retried past the cap.

    A replayed reservation now returns status: "replayed"executed if the
    first attempt committed, unresolved if it never settled (money may have
    moved; the guard will not guess). When a mandate is present the mandate's
    consume-once registry still decides, because it holds strictly better
    information: the intent digest, so id reuse with different money is DENIED
    rather than replayed, and the settled outcome, so a failed attempt replays
    failed rather than unresolved.

Changed (breaking, and it is a one-day-old interface)

  • ReserveRequest.agentIdReserveRequest.scope. Any SpendLimiter you
    wrote against 0.2.0 needs this rename, and should key on scope. The
    Postgres schema column agent_idscope with it.
  • ReserveResult gains state on a replay, so the caller can tell "the rail
    ran" from "the rail may have run".
  • GuardResult's replayed variant has mandateId optional — an intent id
    can now be replayed by the limiter with no mandate involved.

Documentation corrections

The same pass found five claims a reader could have disproved:

  • The conformance suites are not published. @vaduno/guard ships only
    dist/, yet both suites' own docblocks told you to
    import … from "@vaduno/guard/test/…", CONTRIBUTING repeated it, and
    @vaduno/postgres@0.2.0's README says they "ship with @vaduno/guard" —
    that one is immutable on npm now. They are repo test files; copy them or send
    a PR.
  • "This project has shipped that bug twice" — written twice, shipped once.
    The maxUses gate was fixed six days before the first publish.
  • "All five packages are versioned together" — six.
  • The changelog claimed two breaking minor bumps; there had been one.

Tests 357 → 364, including regression tests for both bypasses.


npm install @vaduno/guard@0.2.1

Verified before release: 364 tests green on Node 18/20/22/24 across Ubuntu,
Windows and macOS; 40 more against a real Postgres 16 in CI; release gate clean
on all six packages; every demo runs; and the cross-process demo still shows
$100 spent with the per-instance default versus exactly $50 with a shared
limiter.

Still true: zero users, never run in production, no professional security
audit
. @vaduno/stripe has never run against Stripe in any mode. Both bugs
above were found by a fact-check pass against the source, not by an auditor and
not by a user — which is the argument for reading
docs/SECURITY-MODEL.md
before you trust any of it.

Report vulnerabilities via private advisory, not a public issue.

v0.2.0 — spend caps that hold across processes

Choose a tag to compare

@premsreelathasugeendran premsreelathasugeendran released this 29 Jul 12:55

Spend caps now hold across processes. Until this release they did not, and there was no way to make them.

The README admitted it: two guard processes each enforcing a $50/day cap let $100 through. That is the flagship guarantee failing in the shape most real deployments actually take, and documenting a hole is not the same as fixing it.

npm run demo:cross-process

Two real OS processes, one $50/day cap, $100 of payments attempted:

1. Per-instance limiter (the default)
   worker-A: 5 executed, 0 denied  →  spent $50.00
   worker-B: 5 executed, 0 denied  →  spent $50.00
   TOTAL SPENT: $100.00  against a $50.00 cap
   ❌ OVER the cap by $50.00 — two processes are two budgets.

2. Shared FileSpendLimiter (one budget, atomic reserve)
   worker-A: 5 executed, 0 denied  →  spent $50.00
   worker-B: 0 executed, 5 denied  →  spent $0.00
   TOTAL SPENT: $50.00  ✅ The cap held.

It runs in CI on Linux, Windows and macOS, and fails the build on any overspend.

Why a shared database would not have fixed this

This is the part worth reading, because getting it wrong is the obvious mistake.

SpendHistory.totalsSince() was a read-only query, so the guard could only ever check-then-act: read totals → execute → append. Two processes both read $0, both pass the $50 check, both spend. Pointing that interface at Postgres changes where the rows live, not the gap between the read and the append — the race survives untouched.

So the budget check moved inside the mutating call:

reserve(windows, amount)  →  execute  →  commit

SpendLimiter.reserve() evaluates every rolling window and records the reservation as one atomic step. It is the same fix maxUses needed when it moved inside claim() — the identical bug, one layer up.

Added

  • SpendLimiter — atomic reserve / commit / release. Ships MemorySpendLimiter (default, single process) and FileSpendLimiter (several processes, one box).
  • @vaduno/postgresPostgresSpendLimiter and PostgresConsumeStore, for caps and mandates that hold across multiple instances. Verified by both conformance suites against a real Postgres 16 in CI — 40 tests, no mocks, because the property being tested lives in the database. pg is an optional peer dependency; @vaduno/guard still has zero runtime dependencies.
  • A SpendLimiter conformance suite (23 cases per limiter) run against two independent handles on one backing store, plus guard.releaseSpend(intentId).
  • policyWindows(policy) — rolling windows derived in one place, so the advisory pre-check and the authoritative reserve can never disagree about a cap.

Changed — read this before upgrading

A failed execution now keeps its spend counted. Previously a thrown executor freed the entire reserved amount. But a timeout after the charge landed is indistinguishable from a clean failure, so an executor failing post-charge could be retried past any cap: N timeouts, N real charges, none of them counted — and with no mandate configured, nothing else bounded it.

The amount now stays reserved: counted against caps, but reclaimable. Call guard.releaseSpend(intentId) when you can prove the rail did not charge. It cannot un-count a successful execution, so a mistaken call is safe.

Everything else is additive. history still works as an advisory fast-fail, and code that does not pass limiter behaves exactly as before — single-process, as documented.

Two bugs this work exposed

A latent Windows bug, shipping since 0.1.0. FileMutex treated only EEXIST as lock contention. On Windows a file unlinked while a handle remains open sits in a delete-pending state, and an O_EXCL create against it is refused with EPERM — ordinary contention wearing a different errno, which propagated as a thrown acquire. On this path a thrown acquire is a denied payment. FileConsumeStore has had this since 0.1.0; its tests simply never contended hard enough. The limiter suite's 10 parallel reserves across two handles did.

The release gate was blind to new packages. It iterated a hardcoded list, so @vaduno/postgres would have shipped completely ungated while the gate printed "all packages look publishable" — the same failure as checking a README exists without reading it. Now discovered from the filesystem. It then immediately caught an unqualified "no keys" claim in the README written for that very package.

On testing concurrency

The conformance suites exist because these interfaces are satisfied by implementations that double-spend, and this project has shipped that bug twice.

Measured, against a deliberately naive check-then-act limiter: it passes all 19 sequential cases and fails only the 4 concurrent ones. If you write a store and test it sequentially, you will believe it works.

Both suites are exported. If you implement SpendLimiter or ConsumeStore for Redis, DynamoDB, MySQL or SQLite, run them — your harness supplies two independent handles on one backing store, which is what exercises the cross-process contract. A single handle is exactly how both original bugs hid.

Still true, and worth reading before you use this

Zero users, never run in production, no professional security audit. @vaduno/stripe has never run against Stripe in any mode. In-process, the guard can be routed around by a compromised runtime. Caps don't prevent prompt injection — they bound the loss.

docs/SECURITY-MODEL.md states the non-guarantees next to the guarantees. If a claim anywhere contradicts that file, that file is right.

Criticism of the concurrency and the cryptography is actively wanted — that is where the real bugs have been. Vulnerabilities: private advisory, not a public issue.

Tests 309 → 357, plus 40 more against real Postgres in CI.


npm install @vaduno/guard

@vaduno/guard · @vaduno/postgres · @vaduno/transparency · @vaduno/revocation · @vaduno/x402 · @vaduno/stripe

v0.1.1 — correct the claims that ship to npm

Choose a tag to compare

@premsreelathasugeendran premsreelathasugeendran released this 29 Jul 03:24

No code changed. dist/ is byte-identical to 0.1.0 — verified by diffing against the published registry tarballs, not just against git.

This release exists because the documentation shipped inside the packages was wrong in ways a reader could act on. npm renders each package's own README as its landing page, and three of them carried claims that were false as written. Correcting them in the repo root, where npm never looks, is not correcting them.

Fixed

The quickstart taught the weaker merchant matcher. @vaduno/guard's README showed merchants: { allow: ["openai", "anthropic", "aws"] }. A bare token matches the agent-supplied merchant.id — a field a compromised agent controls. A dotted pattern like "openai.com" matches the URL host, which it does not. Anyone who copy-pasted the old example got a policy weaker than they had reason to expect. Now ["openai.com", "anthropic.com", "aws.amazon.com"], matching the demos.

"Never holds funds or keys" was imprecise everywhere it shipped — in all five package descriptions and in the guard, stripe and transparency READMEs. The accurate claim, which SECURITY.md has always made, is no keys to funds: no custody, no card PANs, no wallet or bank credentials. Mandates are signed with an Ed25519 key belonging to the issuer, which cannot move money, and a guard that only validates and consumes needs nothing but the public half. A total compromise of Vaduno still cannot originate a payment.

@vaduno/stripe called itself "test-mode-only". That implies it was tested against Stripe test mode. It has never contacted api.stripe.com in any mode. The decision path, signature check and deadline are verified against an in-process mock of the issuing_authorization.request webhook. The disclaimer now sits at the top of the README, where someone deciding whether to trust it will see it.

@vaduno/stripe told readers to size their handler against a 2-second deadline. Stripe's authorization window is ~2s, but the adapter's decisionTimeoutMs defaults to 1300ms so its fail-closed DECLINE lands inside that window rather than racing it. A handler tuned to 2s gets declined by the adapter first.

@vaduno/guard misstated its own citation. It read "prompt-injection attacks against commerce agents succeed in 86% of attempts". WASP measures web agents, reports attacks that partially succeed, and gives 86% as an upper bound. Overstating the threat you exist to mitigate is not a harmless error.

@vaduno/guard claimed a retry storm runs the rail "exactly once". At most once — a denied or failed intent runs it zero times, and the weaker claim is the one that is always true.

@vaduno/guard never mentioned that spend caps are per-instance. It documented the cross-process ConsumeStore requirement for consume-once and stopped, leaving a reader to assume rolling caps came along. They do not: two guard processes each enforcing a $50/day cap will let $100 through.

Added (repository — not shipped in any package)

A ConsumeStore conformance suite, 17 cases per store. ConsumeStore is what makes consume-once hold across processes, and a Postgres/Redis implementation is the single most useful thing anyone could contribute. "Implement the interface" was not a real invitation, because the interface's types are satisfied by an implementation that double-spends — precisely the bug this project shipped once already, invisible to every sequential test. Now there's an oracle: duplicate claims, budget exhaustion, idempotent settle, and concurrent races of both identical and distinct intents, run against two independent handles on one backing store. A single handle is exactly how the original bug hid. See CONTRIBUTING.md.

The release gate now asserts README content, not just its existence. Checking that a README exists while never reading it is how a claim corrected in three other files stayed live on npm for two days. scripts/check-release.mjs now fails closed on unqualified "never holds keys", on "no keys,", and on "test-mode-only" — and the trap was verified by reintroducing the old sentence and confirming the gate rejects it.

Changed

  • vitest ^2.1.9^3.2.6 (lockfile resolves 3.2.7), clearing one critical advisory — GHSA-5xrq-8626-4rwp, arbitrary file read/execute when the Vitest UI server is listening, never applicable here because the UI is never started. Dependabot counts it once per manifest, so it appeared as six alerts; it is one advisory. A devDependency either way — consumers are unaffected.
  • Test count 275 → 309.
  • Added .gitattributes (eol=lf) so a Windows-side edit stops turning a one-word fix into a whole-file diff.

Verified before release

  • dist/ byte-identical to published 0.1.0 across all five packages
  • Tarballs contain only LICENSE, README.md, package.json and dist/ — no src, tests, .env, sourcemaps or absolute build paths
  • 309 tests green on Node 18/20/22/24, Ubuntu + Windows + macOS
  • All five installed from the registry into a clean project and exercised as a real consumer: allow, per-transaction denial, lookalike-domain denial (evil-openai.com does not match openai.com), ledger verification, and imports of all five packages

Still true, and worth reading before you use this

Zero users, never run in production, no professional security audit. @vaduno/stripe has never run against Stripe. In-process, the guard can be routed around by a compromised runtime. Caps don't prevent prompt injection — they bound the loss.

docs/SECURITY-MODEL.md states the non-guarantees next to the guarantees. If a claim anywhere contradicts that file, that file is right.

Criticism of the concurrency and the cryptography is actively wanted — that is where the real bugs have been. Vulnerabilities: private advisory, not a public issue.


npm install @vaduno/guard

@vaduno/guard · @vaduno/transparency · @vaduno/revocation · @vaduno/x402 · @vaduno/stripe