Skip to content

[Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898

Description

@hotlong

Splitting the Option 2 half of #7432 into its own decision card so it stays in the maintainer inbox after #7432 closes on PR #7836 (Option 1, the instance fix). The evidence is the dev's caller census on #7432, comment 5257880748.

Background

isAuthGateAllowlisted(undefined) returns true — it treats "no path" as allow-listed (packages/core/src/security/auth-gate.ts:67, if (!rawPath) return true). That is a fail-open default: any caller that reaches the ADR-0069 gate with an absent/empty path silently exempts the request. Option 1 (#7836) guards the one REST call site that could reach it by omission. Option 2 is the question of whether to remove the class at its source rather than guarding each call site.

Premises (each with a re-check command — run before acting)

  • Fail-open leg live: git show origin/main:packages/core/src/security/auth-gate.ts | sed -n '60,70p'if (!rawPath) return true.
  • Census result — no caller depends on no path ⇒ exempt: 4 production call sites, bucketed by intent (comment 5257880748). Re-derive: git grep -n "isAuthGateAllowlisted" origin/main -- 'packages/**/*.ts' (excluding tests).
    • A1 packages/rest enforceAuth — path-bearing; now guarded by fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836.
    • A2 packages/runtime/src/http-dispatcher.ts enforceAuthGate — path-bearing (cleanPath: string required).
    • A3 packages/core/src/security/auth-gate.ts evaluateAuthGate — path required, non-nullable.
    • B1 packages/core/src/security/anonymous-deny.ts shouldDenyAnonymous — genuinely pathless, already guards the falsy branch before the helper sees it.
  • The one thing a flip must price in (A2): http-dispatcher.ts computes cleanPath = path.replace(/\/$/, ''), so ${prefix}/ yields cleanPath === '', reachable on a shipped transport (adapters/hono). Today that reaches only the discovery payload (gate-exempt by design) or a 404 — not a bypass. Re-check: git grep -n "replace(/\\\\/\$/" origin/main -- packages/runtime/src/http-dispatcher.ts.

The concrete question

Do we flip isAuthGateAllowlisted (and its seams) fail-closed — a falsy/empty path is not exempt — removing the fail-open class at source, and if so, how do we handle the A2 bare-root ${prefix}/ case?

Options

  • A (recommended) — fail-close at source + normalize. Split the helper so a falsy/empty path returns false (not exempt), keeping an explicit exemption only for the genuinely-body-routed seams (B1's contract). Normalize cleanPath '' → '/' in http-dispatcher.ts so ${prefix}/ resolves to root rather than the empty string. Removes the fail-open default; the per-call-site guards become belt-and-suspenders. Cross-domain: the helper is packages/core (domain:engine-core), the normalization is packages/runtime (domain:cli) ⇒ if chosen, contract-first split (core first, runtime after).
  • B (defer / status quo). Land Option 1 (fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836) only; leave the helper's no path ⇒ exempt. The remaining seams are safe by construction today (the hono adapter populates path; A2's empty-path reaches only discovery/404). Re-open if a second transport adapter or a synthetic-request caller appears — the promotion trigger already recorded on finding: REST's enforceAuth passes req.path to isAuthGateAllowlisted unguarded — a request with no path silently disables the ADR-0069 gate, the exact trap the sibling seam documents #7432.
  • C — fail-close, gate the bare root. Flip the helper as in A but do not normalize: GET ${prefix}/ becomes 403 for a gated session. Smaller diff, but changes the observable behaviour of the bare-root discovery request for gated sessions.

Recommendation: A, with an honest appetite caveat

Fail-closed is the declared-=-enforced direction and the census removes the migration risk (zero current dependency on the old behaviour). But nobody hits this today, so the pull is preventive, not user-facing — which is exactly why it is an appetite call rather than a restore-invariant I would just dispatch. If the appetite is not there now, B is defensible: the seams are safe today and the promotion trigger is already recorded.

Four-lens analysis

  1. Platform long-term coherence — A removes a helper that carries two meanings (a real allow-list decision, and "no path" silently reusing the allow answer); B leaves that latent trap guarded only by caller discipline.
  2. Measured business pull — zero today: the adapter always sets path, and A2's empty-path case reaches only a gate-exempt discovery payload. The pull is preventing a future transport author from re-opening the hole, not a user-visible defect. This axis lowers urgency and is the strongest argument for B/defer.
  3. AI-agent error-resistance — A is strongest: a future adapter author cannot disable the gate by omitting path; the failure becomes a compile/behaviour signal instead of a silent fail-open. B relies on every future caller remembering the guard fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836 adds at one site.
  4. Startup scope discipline — A is a real (small) change across two lanes plus a normalization; B adds zero surface. Given zero current pull, deferring is the scope-disciplined choice; adopting A is justified only as cheap preventive hardening of a security default.

Lenses 1/3 argue for A, lenses 2/4 argue for B — a genuine appetite trade-off, which is why it is escalated rather than decided from the seat.

Related: #7432 (parent finding, closes via #7836), census comment 5257880748, sibling seam anonymous-deny.ts.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions