Fail closed when the gate cannot confirm an approval - #118
Merged
Conversation
The gate read `const allow = decision === 'allow' || decision == null`, so an assess response carrying no decision was treated as an approval. Paired with the API silently ignoring an unrecognised policy key, a merchant could send a misspelled rule and have both layers agree to let the request through, with a healthy 200 at every step. Only the literal string 'allow' is an approval now, so null, undefined and any decision value added later are not. Two failures that used to look identical are now separated, because they call for opposite handling. A missing decision is an UNREADABLE RESPONSE, indistinguishable from something truncated or mangled by a proxy. It is treated as api_error and honours the merchant's explicit failOpen choice exactly as an unreachable API already does, returning 503 rather than 403 so an agent retries instead of going off to get verified. What changed is that it is no longer a silent allow for merchants who never opted into failing open. An allow whose policy_result did not pass, on a request that DID send a policy, is a COMPLIANCE INTEGRITY failure. We asked for enforcement and the answer does not show it happened, so it denies regardless of failOpen: failing open there would reinstate the exact hole this closes. The check is conditioned on having sent a policy, because a null policy_result is the correct response for every ungated call and denying on it unconditionally would deny every ungated merchant request in production. The two tests that previously asserted this behavior were named "decision null/undefined treated as allow" and pinned the fail-open as correct. They now assert the opposite, alongside cases for an unrecognised decision value, both failOpen directions, and the ungated path that must keep working.
8 tasks
vvillait88
added a commit
that referenced
this pull request
Aug 28, 2026
…val (#119) ## Summary Version bump for the fail-closed change merged in #118, plus the `PolicyBlock` documentation from #117. Minor rather than patch, because runtime behavior changes for merchants. A response the gate cannot read as an approval is now refused where it was previously allowed: a missing or null decision, an unrecognised decision value, and an `allow` whose `policy_result` did not pass on a request that sent a policy. ## Type of change - [ ] Bug fix (no breaking change) - [ ] New feature (no breaking change) - [ ] Breaking change (existing callers must update) - [x] Docs, tests, or internal maintenance only The version bump itself is maintenance; the behavior it releases landed in #118 and is classified there. ## Public API No exported type, signature or wire-format changes. Behavior changes for three response shapes that should not occur against a current API, all of which now deny where they previously allowed. Nothing to migrate: a merchant seeing any of them in production has a real problem the gate was hiding, and `failOpen` still applies to the unreadable-response case, which is the only one of the three that is an availability question rather than a compliance one. ## Test plan `bun run lint`, `bun run typecheck` (including the examples project) and `bun run test` all clean on the bumped tree: 119 files, 1824 tests passing, 4 skipped. The behavior itself is verified in #118, including drills that reintroduce each guard's removal and confirm the tests fail. ## Checklist - [x] Tests cover the new behavior, and the suite passes locally - [x] Lint, format, and type checks pass - [x] Docs and README examples updated if the public surface changed - [x] No secrets, credentials, or personal data in the diff or the tests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The gate read
const allow = decision === 'allow' || decision == null, so an assess response carrying no decision was treated as an approval. Paired with the API silently ignoring an unrecognised policy key, a merchant could send a misspelled rule and have both layers agree to let the request through, with a healthy 200 at every step. Only the literal string'allow'is an approval now, sonull,undefinedand any decision value added later are not.Two failures that used to look identical are now separated, because they call for opposite handling:
api_errorand honours the merchant's explicitfailOpenchoice exactly as an unreachable API already does, returning 503 rather than 403 so an agent retries instead of going off to get verified. What changed is that it is no longer a silent allow for merchants who never opted into failing open.policy_resultdid not pass, on a request that DID send a policy, is a compliance integrity failure. We asked for enforcement and the answer does not show it happened, so it denies regardless offailOpen: failing open there would reinstate the exact hole this closes.The policy check is conditioned on having sent a policy. A null
policy_resultis the correct response for every ungated call, so denying on it unconditionally would deny every ungated merchant request in production.Server side of this finding landed in core as PR #709. Worked with Varun, going through the Q3 2026 penetration test findings.
Type of change
Classified as a bug fix rather than breaking because no exported surface changes and the only behavior difference is that requests which were previously approved without evidence are now refused. A merchant relying on that approval was relying on the defect. Merchants who want availability over strictness on an unreadable response already have
failOpen, and it still works for that case.Public API
None. No exported type, signature or response shape changes.
Behavior does change for two response shapes that should not occur against a current API: an assess response with no
decision, and anallowcarrying a non-passingpolicy_resultfor a request that sent a policy. Both now deny where they previously allowed. No migration is needed; a merchant seeing either in production has a real problem the gate was hiding.Test plan
bun run lint,bun run typecheck(including the examples project) andbun run testall clean: 119 files, 1824 tests passing (up from 1817), 4 skipped.Two existing tests asserted the vulnerable behavior and are rewritten. They were named "decision null/undefined treated as allow" and pinned the fail-open as correct, which is worth flagging on its own: the defect had passing coverage. Added alongside them: an unrecognised decision value, both
failOpendirections, and the ungated path that must keep working.Both guards were drilled independently rather than assumed. Restoring the original
decision == nullfail-open fails 5 tests; keeping the decision guard but dropping only the policy-evaluated guard fails 3. Every adapter was checked to route through this one code path (nextjsdelegates toweb, which uses core), and no otherdecision == nullpattern remains insrc/.Checklist