Skip to content

Fail closed when the gate cannot confirm an approval - #118

Merged
vvillait88 merged 1 commit into
mainfrom
fix/as-003-gate-fails-closed
Aug 28, 2026
Merged

Fail closed when the gate cannot confirm an approval#118
vvillait88 merged 1 commit into
mainfrom
fix/as-003-gate-fails-closed

Conversation

@vvillait88

Copy link
Copy Markdown
Contributor

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, 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 policy check is conditioned on having sent a policy. A null policy_result is 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

  • Bug fix (no breaking change)
  • New feature (no breaking change)
  • Breaking change (existing callers must update)
  • Docs, tests, or internal maintenance only

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 an allow carrying a non-passing policy_result for 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) and bun run test all 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 failOpen directions, and the ungated path that must keep working.

Both guards were drilled independently rather than assumed. Restoring the original decision == null fail-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 (nextjs delegates to web, which uses core), and no other decision == null pattern remains in src/.

Checklist

  • Tests cover the new behavior, and the suite passes locally
  • Lint, format, and type checks pass
  • Docs and README examples updated if the public surface changed
  • No secrets, credentials, or personal data in the diff or the tests

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.
@vvillait88
vvillait88 merged commit 9abf4a1 into main Aug 28, 2026
6 checks passed
@vvillait88
vvillait88 deleted the fix/as-003-gate-fails-closed branch August 28, 2026 00:24
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant