Skip to content

fix(gate-50): the guard window crossed method boundaries (#429) - #437

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-50-window-method-clip
Aug 13, 2026
Merged

fix(gate-50): the guard window crossed method boundaries (#429)#437
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-50-window-method-clip

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #429.

The defect

The guard window is eleven lines of code following the read, counted over the file, with no notion of where the method ends. A guard belonging to a different method therefore cleared an unguarded read in the method above it.

#420 changed the window's budget (comment lines no longer spend it) but not its extent, so that fix moved this defect closer rather than away: blanking comments means real code lines from the next method arrive inside the window sooner.

Reproduced on c26f9a3 — one file, one variable

[gate-50] security-config-fail-mode: PASS                        <- the defect

Delete readRegister() and nothing else:

[gate-50] security-config-fail-mode: FAIL — 1 unsafe security-config read(s)
lib/Controller/WindowController.php:6: security-relevant config read of "api_token" has no fail-mode guard within 10 lines

The fix

Clip the window at the enclosing method's closing brace, using the brace walk gate-49's checker already runs over masked text — kept local to this checker so gate-50 gains no new import and no new wiring failure mode.

_strip_php_comments grows a for_structure mode that additionally blanks string contents and heredoc bodies. That copy is only ever brace-walked, never searched for a guard, so no guard vocabulary changes meaning.

Fails in the loose direction. A walk that never balances yields a span to EOF, i.e. exactly the pre-#429 window — a false negative at worst, never a finding at a line where no guard could be written.

Findings now name the method the window was clipped to; without it the reader cannot tell "unguarded" from "the guard is just out of range", which is the question the clip decides.

Acceptance — FAMILY J in test_gate_45_to_55_acceptance.sh

Against this branch: whole suite green. Against origin/main's runner (a real revert via HYDRA_GATES_RUNNER_UNDER_TEST=, not git checkout --): 2 failures, both belonging to the evidence arm.

arm before after role
J1 a guard in the NEXT method does not clear this read (+ names readToken()) PASS (wrong) FAIL evidence
J2 the issue's positive control — the read alone FAIL FAIL control
J3 guard genuinely in the SAME method, inside budget PASS PASS anti-false-positive control
J4 same method, one code line outside budget FAIL FAIL budget control — proves the clip did not shrink the budget it clips
J5 a } inside a string literal and inside a heredoc body PASS PASS robustness control

J5 is load-bearing rather than decorative: measured directly on the helper, walking the un-blanked text computes readToken() as ending on its second line — spans (4,6) un-blanked vs (4,15) blanked — which would clip the real guard out and invent a finding.

The budget boundary was characterised separately in both arms — guard 9 filler lines down → PASS, 10 down → FAIL, identical before and after. The clip changes the window's extent and not its budget.

Fleet numbers — 6 repos, whole-tree scope

gate-50 is not a delta gate, so a whole-tree run exercises it. Six fresh development clones.

repo base this branch finding set
procest PASS 0 PASS 0 identical
opencatalogi FAIL 4 FAIL 4 identical
openregister PASS 0 PASS 0 identical
softwarecatalog PASS 0 (47 demoted in .notes) PASS 0 (47) identical
docudesk PASS 0 (1 demoted) PASS 0 (1) identical
larpingapp PASS 0 PASS 0 identical

Zero new findings, zero removed. Six identical verdicts is what a dead rig looks like, so the zero was measured rather than assumed, two ways:

1. Exposure, at the checker layer — running this branch's own _method_spans/_strip_php_comments (extracted from the runner, so it cannot drift) over every lib/**/*{Controller,Service}.php in the six trees:

repo security-relevant reads windows that crossed a method boundary verdict flips
procest 3 0 0
opencatalogi 45 2 0
openregister 8 4 0
softwarecatalog 59 5 0
docudesk 14 11 0
larpingapp 1 1 0

23 of 130 reads were structurally in the #429 shape — their window did reach past the enclosing method's brace. None flipped, because in every one of the 23 the read either had a guard inside its own method or had none in either window. That harness reports verdict-flips=1 when handed the issue's fixture, so its zero is a measured zero.

2. Positive control at the verdict layer, on the real trees — plant the #429 shape into each repo and re-run both arms through the shipped runner: base PASS/FAIL n unchanged, this branch FAIL n+1 naming readToken(). (Table in the thread.)

Positive-control table (verdict layer, real trees)

repo base this branch probe named
procest PASS FAIL — 1 readToken()
opencatalogi FAIL — 4 FAIL — 5 readToken()
openregister PASS FAIL — 1 readToken()
softwarecatalog PASS FAIL — 1 readToken()
docudesk PASS FAIL — 1 readToken()
larpingapp PASS FAIL — 1 readToken()

Exactly +1 each, and opencatalogi's four pre-existing findings are preserved.

⚠️ The first run of this control fired on nothing — 0 probe hits in both arms — because gate-50's scope comes from _enum_tracked, i.e. git ls-files, and the planted file was untracked. A control that produces no hits in either arm is indistinguishable from a fix that does nothing; the numbers above are from the corrected run, with the probe staged.

juanclaude-conduction and others added 2 commits August 13, 2026 09:47
The window was eleven lines of CODE following the read, counted over the
FILE, with no notion of where the method ends. A guard belonging to a
DIFFERENT method therefore cleared an unguarded read in the method above it.

Reproduced on c26f9a3, one file, one variable:

  readToken() unguarded + an ordinary guarded readRegister() below it
                                        PASS                  <- the defect
  delete readRegister() and nothing else
                                        FAIL — 1, api_token at :6

#420 changed the window's BUDGET (comments no longer spend it) but not its
EXTENT, so that fix moved this defect closer rather than away: blanking
comments means real code lines from the next method arrive sooner.

The window is now clipped at the enclosing method's closing brace, via the
same brace walk gate-49 runs over masked text — kept local to this checker
so gate-50 gains no new import and no new wiring failure mode.
_strip_php_comments grows a for_structure mode that also blanks string
contents and heredoc bodies; that copy is only ever brace-walked, never
searched for a guard, so no guard vocabulary changes meaning.

FAILS LOOSE. A walk that never balances yields a span to EOF, i.e. exactly
the pre-#429 window — a false negative at worst, never a finding at a line
where no guard could be written.

Findings now name the method the window was clipped to, because without it
the reader cannot tell "unguarded" from "the guard is out of range".

FAMILY J in test_gate_45_to_55_acceptance.sh. J1 is the evidence arm (PASS
on origin/main, FAIL here). J2-J5 hold their verdict in BOTH arms and are
labelled CONTROLs: J2 the issue's positive control, J3 a guard genuinely in
the same method inside budget (anti-false-positive), J4 the same method one
code line OUTSIDE budget (proves the clip did not shrink the budget), J5 a
} inside a string literal and a heredoc body (load-bearing: walking the
un-blanked text computes readToken() as ending on its second line).
Both branches appended a new test family to the tail of
test_gate_45_to_55_acceptance.sh, so git could not tell they were
independent additions rather than competing edits.

Resolved by keeping BOTH: FAMILY I (gate-48, the plain-git-pathspec blind
spot, #428) as it landed on main, then FAMILY J (gate-50, the guard window
crossing a method boundary, #429). Neither family references the other and
both assert on their own fixtures.

Verified after the resolve: no conflict markers remain, both family headers
are present exactly once, bash -n is clean and shellcheck exits 0.
@rubenvdlinde
rubenvdlinde merged commit 73d496a into main Aug 13, 2026
34 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gate-50-window-method-clip branch August 13, 2026 08:46
rubenvdlinde pushed a commit that referenced this pull request Aug 13, 2026
…move its heredoc handling into php_mask

#429 (#437) reworked gate-50's window inside the runner while this branch was
converting the same block off its private _strip_php_comments. Resolved so
BOTH survive:

  * #429's _method_spans stays, and the runner still clips the guard window at
    the enclosing method's brace.
  * _strip_php_comments is gone. The structural copy it fed is now
    php_mask(src, blank_strings=True); the guard-search copy is php_mask(src).

#429 had added HEREDOC handling to the private copy, which php_mask lacked.
Deleting the copy without moving that first would have been a capability loss
dressed as a cleanup, so php_mask learned heredocs and nowdocs — a strict
correctness gain for gates 5, 8, 50, 59 and 64, which until now parsed a
<<<SQL body as code (a // inside it blanked a line, an apostrophe opened a
literal that ran to the next stray quote, a { mis-balanced every brace walk).
Nine arms, four of them mutation-checked EVIDENCE.

Also fixes three QUADRATIC scans this branch introduced or inherited, found by
timing at three input sizes rather than one:

  _skip_tag              '<a b="' x N  ->  12.6 / 134 / 1190 ms  (x94)
  html_comment_spans     '{{ ' x N
  has_prelude/loadApp    'registerAutoloading( ' x N -> 41 / 388 / 3038 ms

A tag cannot contain an unquoted '<', so the scan now aborts there; one missed
'}}' settles the question for the whole remainder; and the paren walk is a
single-pass table instead of a rescan per call site. All paths now measure
x5-x17 for a 9x input.
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.

gate-50: the guard window crosses method boundaries — a guard in the NEXT method clears an unguarded read

2 participants