Skip to content

fix(hydra-gates): gate-7 follows delegation; gates 6/7 stop passing on an empty scope - #149

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/gate7-delegation-and-empty-scope
Aug 4, 2026
Merged

fix(hydra-gates): gate-7 follows delegation; gates 6/7 stop passing on an empty scope#149
rubenvdlinde merged 1 commit into
mainfrom
fix/gate7-delegation-and-empty-scope

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Two blockers to turning enable-hydra-gates: true on fleet-wide. Both live in shared infrastructure, so this PR is left for a human — a broken run-hydra-gates.sh breaks every repo's gate run.

1. Gate-7 (no-admin-idor) false-positived on ordinary delegation

It only recognised an authorization guard written in the routed method's own body. Any controller that centralises authorisation in an injected responder, or routes thin public actions through private helpers first, went red on correct code.

Measured on decidesk development: 11 findings, all 11 guarded.

shape count guard actually reached
$this->responder->staffAction() 8 requireStaff()currentUid() !== null and isStaff(), else 401/403
$this->responder->citizenAction() 3 401 for an anonymous caller (authenticated citizen by design)

validateProposal needed three hops: validateProposalapproveProposal/rejectProposalapplyProposalDecisionstaffAction. Zero real exposure.

Pattern 4 — resolved delegation

Evidence-based, not a naming convention:

a. Cross-class resolution. The controller's typed constructor-promoted properties give $prop -> ClassName. The class is resolved to a real file under the app's lib/ tree, confirmed by an actual class <Name> declaration, and parsed. A call $this->prop->method( clears the routed method only when method is demonstrably guard-bearing in the collaborator's own source. An unresolvable class clears nothing (fail closed).

b. Transitive closure to a fixpoint over same-class calls, so a chain of any length is followed.

Propagation uses a stricter signal than the existing one-hop Pattern 1: a bare throw and a 404 do not seed a chain. NotFoundException is not an authorisation guard, and chaining it would let "this can fail" stand in for "this checks who you are" arbitrarily far up the call graph. Every hop must also occur before the caller's first data mutation.

Both directions are tested

A clear that cannot be shown to still fail is only evidence about itself. 9 new tests — 3 assert the decidesk shapes pass, 6 assert the gate still catches:

  • a plainly unguarded method;
  • a collaborator method that exists but is not a guard (respond()) — the sharpest control, proving resolution discriminates between methods of the same collaborator;
  • an unresolvable collaborator type;
  • a chain terminating in nothing;
  • a guard that runs after the write;
  • a bare-throw collaborator.

Verified live against decidesk: 11 findings → 0, and 5 deliberately-injected unguarded shapes → all 5 still flagged.

2. Gates 6 and 7 reported PASS when the SCOPE was empty

Zero files in the diff meant an empty findings log, a count of 0, and a _pass over nothing inspected — the same failure family as #147, where a missing helper made gate-7 report PASS over 11 real unguarded endpoints.

They now _skip, which keeps them out of _EMITTED_GATES so the coverage summary lists them. Verified against decidesk with --base HEAD:

[gate-6] orphan-auth: SKIPPED — scope was empty — 0 lib/Service or lib/Controller PHP file(s) ...
[gate-7] no-admin-idor: SKIPPED — scope was empty — 0 lib/Controller PHP file(s) ...
[hydra-gates] GATES THAT DID NOT RUN: 4 6 7 24 33

Test evidence

  • test_check_no_admin_idor.py64/64 (55 pre-existing + 9 new)
  • tests/run-helper-suites.sh — 17 passed, 0 failed, 2 pre-existing quarantines unchanged
  • tests/test-hydra-gates-bin.sh25/25
  • bash -n run-hydra-gates.sh clean

🤖 Generated with Claude Code

…n an empty scope

Two blockers to enabling the Hydra gates fleet-wide.

1. gate-7 (no-admin-idor) false-positived on ordinary delegation.

It only recognised a guard written in the routed method's own body, so any
controller that centralises authorisation in an injected responder — or routes
thin public actions through private helpers first — went red on correct code.
Measured on decidesk development: 11 findings, ALL 11 guarded. 8 reach
$this->responder->staffAction() -> requireStaff() (currentUid() !== null AND
isStaff()), 3 reach citizenAction() (401 for an anonymous caller), and
validateProposal took three hops (validateProposal -> approveProposal /
rejectProposal -> applyProposalDecision -> staffAction). Zero real exposure.

Pattern 4 resolves this with evidence, not with a naming convention:

  a. Cross-class resolution. The controller's typed constructor-promoted
     properties give $prop -> ClassName; the class is resolved to a real file
     under the app's lib/ tree, confirmed by an actual `class <Name>`
     declaration, and PARSED. A call $this->prop->method( clears the routed
     method only when `method` is demonstrably guard-bearing in the
     collaborator's own source. An unresolvable class clears nothing.
  b. Transitive closure to a fixpoint over same-class calls, so a delegation
     chain of any length is followed.

Propagation uses a STRICTER signal than the existing one-hop Pattern 1: a bare
`throw` and a 404 do not seed a chain. NotFoundException is not an
authorisation guard, and chaining it would let "this can fail" stand in for
"this checks who you are" arbitrarily far up the call graph. Every hop must
also occur before the caller's first data mutation.

Both directions are covered by tests, because a clear that cannot be shown to
still fail is only evidence about itself. 9 new tests: 3 assert the decidesk
shapes now pass, 6 assert the gate still catches a plainly unguarded method, a
collaborator method that EXISTS but is not a guard (respond()), an
unresolvable collaborator type, a chain terminating in nothing, a guard that
runs after the write, and a bare-throw collaborator. 64/64 green.

2. gates 6 and 7 reported PASS when the SCOPE was empty.

Zero files in the diff meant an empty findings log, a count of 0, and a PASS
over nothing inspected — the same failure family as .github#147, where a
missing helper made gate-7 report PASS over 11 real unguarded endpoints. They
now _skip, which keeps them out of _EMITTED_GATES so the coverage summary
lists them under GATES THAT DID NOT RUN. Verified against decidesk with
--base HEAD: "GATES THAT DID NOT RUN: 4 6 7 24 33".

Full suite: 17 helper suites pass, 2 pre-existing quarantines unchanged,
25/25 bin entry-point tests pass.
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