fix(hydra-gates): gate-7 follows delegation; gates 6/7 stop passing on an empty scope - #149
Merged
Merged
Conversation
…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.
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.
Two blockers to turning
enable-hydra-gates: trueon fleet-wide. Both live in shared infrastructure, so this PR is left for a human — a brokenrun-hydra-gates.shbreaks every repo's gate run.1. Gate-7 (
no-admin-idor) false-positived on ordinary delegationIt 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
decideskdevelopment: 11 findings, all 11 guarded.$this->responder->staffAction()requireStaff()—currentUid() !== nullandisStaff(), else 401/403$this->responder->citizenAction()validateProposalneeded three hops:validateProposal→approveProposal/rejectProposal→applyProposalDecision→staffAction. 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'slib/tree, confirmed by an actualclass <Name>declaration, and parsed. A call$this->prop->method(clears the routed method only whenmethodis 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
throwand a404do not seed a chain.NotFoundExceptionis 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:
respond()) — the sharpest control, proving resolution discriminates between methods of the same collaborator;throwcollaborator.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
_passover 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_GATESso the coverage summary lists them. Verified against decidesk with--base HEAD:Test evidence
test_check_no_admin_idor.py— 64/64 (55 pre-existing + 9 new)tests/run-helper-suites.sh— 17 passed, 0 failed, 2 pre-existing quarantines unchangedtests/test-hydra-gates-bin.sh— 25/25bash -n run-hydra-gates.shclean🤖 Generated with Claude Code