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 ≥0 per schema)
  • br_trust.anomaly_score (must be 0..1)
  • br_trust.xdr_risk (must be 0..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) — for spent_usd. Non-finite or

negative inputs become 0.

  • clampToUnitInterval(v) — for anomaly_score. Non-finite inputs

become 0; out-of-range gets Math.min(1, Math.max(0, v)).

  • clampOptionalUnitInterval(v) — for xdr_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_usd assertion — 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 0
  • pnpm exec oxfmt --check / oxlint --type-aware — clean
  • pnpm test:fast7821/0 (+8 new tests, existing 20 synth tests

still green)

  • All 42 stress-suite tests pass

Finding ledger update

#293 findingSeverityStatus
Signal precedence drift🔴 HIGHFixed in #294
anomaly vs anomaly_score label🟢 LOWFixed in #294
spent_usd not clamped🟡 MEDIUMFixed in this PR
Empty models: [] semantics🟡 MEDIUMOpen — needs design call

The remaining MEDIUM finding (br_scope.models=[] ambiguity) needs a product decision before code:

  • Option (a): schema rejects [] — force null for "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