Releases: premsreelathasugeendran/vaduno
Release list
v0.2.1 — fix two cap bypasses shipped in 0.2.0
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.agentIdreset 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,
$100through a$50/daycap, by changing one string. The same test with a
fixedagentIdcorrectly 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 ignoredagentIdfor exactly this reason, and 0.2.0 silently
regressed a mitigationSECURITY.mdhad 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.idran the rail again while counting one charge. A
replayed reservation returnedokand 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$50charge. 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"—executedif the
first attempt committed,unresolvedif 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
failedrather thanunresolved.
Changed (breaking, and it is a one-day-old interface)
ReserveRequest.agentId→ReserveRequest.scope. AnySpendLimiteryou
wrote against 0.2.0 needs this rename, and should key onscope. The
Postgres schema columnagent_id→scopewith it.ReserveResultgainsstateon a replay, so the caller can tell "the rail
ran" from "the rail may have run".GuardResult'sreplayedvariant hasmandateIdoptional — 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/guardships 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.
ThemaxUsesgate 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.1Verified 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
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— atomicreserve/commit/release. ShipsMemorySpendLimiter(default, single process) andFileSpendLimiter(several processes, one box).@vaduno/postgres—PostgresSpendLimiterandPostgresConsumeStore, 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.pgis an optional peer dependency;@vaduno/guardstill has zero runtime dependencies.- A
SpendLimiterconformance suite (23 cases per limiter) run against two independent handles on one backing store, plusguard.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
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.jsonanddist/— 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.comdoes not matchopenai.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