2026-05-11-envelope-synth-defensive-clamps
Fix: envelope synth defensive clamps (MEDIUM finding from #293)
Date: 2026-05-11 Status: shipped Slug: envelope-synth-clamp-spent-usd Branch: fix/envelope-synth-clamp-spent-usd Closes: the MEDIUM spent_usd finding from #293; also closes a class of related issues on anomaly_score and xdr_risk discovered during the fix.
Summary
synthesizeEnvelope previously passed three numeric values from injected deps straight through to the signed payload without clamping:
br_budget.spent_usd(must be≥0per schema)br_trust.anomaly_score(must be0..1)br_trust.xdr_risk(must be0..1, optional)
A misbehaving or malicious dep returning negative / NaN / Infinity / out-of-range values produced signed envelopes that fail their own verify round-trip — i.e., the envelope is cryptographically valid but the schema validator rejects it. This is a defense-in-depth gap: the schema catches the bad value at verify time, but synth shouldn't produce one in the first place.
What changed
src/security/trust-envelope/synth.ts
Added three small clamp helpers used at the dep-call sites:
clampToNonNegativeFinite(v)— forspent_usd. Non-finite or
negative inputs become 0.
clampToUnitInterval(v)— foranomaly_score. Non-finite inputs
become 0; out-of-range gets Math.min(1, Math.max(0, v)).
clampOptionalUnitInterval(v)— forxdr_risk. Same range, but
garbage drops the field entirely rather than clamping to 0. Rationale: a clamped-to-zero value would falsely signal "external XDR has verified low risk" — absence is more honest than a fabricated low score.
Each call site now reads:
const spentUsd = clampToNonNegativeFinite(await deps.getBudgetSpent?.(...));
const anomalyScore = clampToUnitInterval(await deps.getAnomalyScore?.(...));
const xdrRiskRaw = agentId && deps.getXdrRisk ? ... : undefined;
const xdrRisk = clampOptionalUnitInterval(xdrRiskRaw);
src/security/trust-envelope/__tests__/envelope-stress.test.ts
- Flipped the previously-failing
spent_usdassertion — it now expects
0, not -100. The FINDING: comment is replaced with "Resolved 2026-05-11".
- Added 7 new assertions covering NaN/Infinity for spent_usd, range
clamp for anomaly_score, garbage-drops-field for xdr_risk, valid xdr_risk passes through unchanged.
- Added one integration test: post-clamp envelope round-trips through
TrustEnvelopeSchema.safeParse with spent_usd=-50 / anomaly_score=99 / xdr_risk=Infinity — proves the three clamps stay in sync with the schema's constraints.
Verification
pnpm tsgo— exit 0pnpm exec oxfmt --check/oxlint --type-aware— cleanpnpm test:fast— 7821/0 (+8 new tests, existing 20 synth tests
still green)
- All 42 stress-suite tests pass
Finding ledger update
| #293 finding | Severity | Status |
|---|---|---|
| Signal precedence drift | 🔴 HIGH | Fixed in #294 |
anomaly vs anomaly_score label | 🟢 LOW | Fixed in #294 |
spent_usd not clamped | 🟡 MEDIUM | Fixed in this PR |
Empty models: [] semantics | 🟡 MEDIUM | Open — needs design call |
The remaining MEDIUM finding (br_scope.models=[] ambiguity) needs a product decision before code:
- Option (a): schema rejects
[]— forcenullfor "no restriction",
non-empty array for "allowlist".
- Option (b): routing-gates treats
[]as "deny all" by removing the
length > 0 check.
Neither is wrong; they're different contracts. Filing as a follow-up issue is the right next step.
What this PR does NOT do
- Doesn't add envelope-claim verification in the gauntlet (still the
Issue #284 follow-up — expose claims on response headers so external observers can verify drift).
- Doesn't touch the
br_scope.models=[]ambiguity. - Doesn't change the verify side — the schema already rejected these
values; the change is purely on the synth side so we don't _produce_ invalid envelopes.
Lockstep
- TS / Python SDK / MCP — no public API surface change
- OpenAPI — no new routes
- Ship log — this file