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.