Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions hydra-gates/scripts/lib/check_system_elevation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
# SPDX-License-Identifier: EUPL-1.2
"""Gate 96 — system-elevation-reachability (ADR-099 rule 9).

`runAsSystem()` / `SystemOperationContext::run()` runs a callable as a
trusted userless principal: no RBAC, no tenancy, no owner. It exists for
work that genuinely has nobody to act for — an installation seeding its own
shipped registers, a migration, a repair step. A schema migration runs on
nobody's behalf, and pretending otherwise would mean inventing a user.

WHAT THIS GATE IS ACTUALLY DEFENDING AGAINST

Not somebody arguing for an escalation. The failure mode is somebody
reaching for the nearest thing that makes a refusal go away.

That reach is predictable, because ADR-099 put refusals everywhere an
identity can be missing: a schedule trigger that names nobody is refused, a
delegation without a grant is refused, an agent tool acting for an absent
user is refused. Every one of those refusals sits within a few lines of a
method that would make it succeed. A developer under time pressure, staring
at "this flow run has no owner", finds `runAsSystem()` in the same service
they already have injected — and the fix works, the test goes green, and
the run now executes with every access control switched off, permanently,
for every future run of that flow.

Nothing downstream can catch it. By the time the callable executes, the
caller is gone; there is no runtime assertion the method can make about who
invoked it. So the control has to be structural, and structural controls
drift unless something checks them.

THE THREE FORBIDDEN CALLERS, AND WHY THOSE THREE

ADR-099 names flow nodes, agent tools, and inbound request handling. They
are not arbitrary — each carries USER-AUTHORED DEFINITIONS or
USER-SUPPLIED INPUT across the boundary:

* a flow node executes a graph somebody drew in a browser;
* an agent tool executes a call a model chose, from text it was given;
* a controller executes a request a client sent.

Elevation reachable from any of them converts "a user can describe work"
into "a user can describe work that runs as root". The other direction —
elevation in a migration — has no user in the picture at all, which is the
whole distinction.

WHY THERE IS NO EXCLUSION ANNOTATION

Most gates in this suite take a reason-bearing `@gate exclude <why>`.
This one deliberately does not, and that is the point rather than an
omission.

An escape hatch on this rule would be used exactly when somebody is trying
to make a refusal go away — the case the gate exists for — and a
reason-bearing comment written in that moment ("needed for the migration
path") is indistinguishable from a legitimate one to every reviewer who
reads it afterwards. Green bought with a plausible sentence is worse than
red, because it ends the conversation.

If this gate fires on something that is genuinely legitimate, the answer is
to move the elevation OUT of the forbidden caller — into a repair step, a
migration, or a service the caller invokes without passing user input to it
— or to fix this gate. Both leave a visible diff. A comment does not.

WHAT IT CANNOT SEE

A dynamically dispatched call (`$svc->{$method}()`, a callable stored in a
variable, a container lookup by string) is invisible to it, exactly as it
is to the PHPUnit boundary test this generalises. It is a guard against
DRIFT, not a proof of absence — and it is stated here so nobody reads a
green as the stronger claim. The control that actually holds the line is
that the elevating service is not injected into node, tool or endpoint
classes.

FULL-TREE, deliberately NOT diff-scoped. A diff-scoped version reports
nothing on the ~99% of PRs that never open a node or a controller, so it
could not establish that the boundary holds — which is the only claim worth
making about a boundary. The finding set is small enough that noise is not
a risk: on a clean repo it is empty.

Exit codes: 0 clean · 1 findings · 4 no PHP under lib/ in this repo.
"""
import os
import re
import sys

# The calls that elevate. Matched as method calls / static calls rather than
# as bare words, so a docblock sentence about `runAsSystem` does not fire —
# comments and strings are masked out anyway, but two independent guards
# against a false positive are cheap and a false positive on this gate is
# what would get it switched off.
ELEVATION_RX = re.compile(
r'(?:->\s*runAsSystem\s*\(|SystemOperationContext\s*::\s*run\s*\()'
)

# Directory prefixes a call may NOT appear under, and the reason each is
# forbidden. The reason travels into the failure message: "forbidden" alone
# tells a developer they are blocked, not what to do instead.
FORBIDDEN = (
(
'lib/Service/Flow/Nodes/',
'a flow node executes a graph a user drew, so elevation here lets a '
'user describe work that runs with every access control off',
),
(
'lib/Flow/',
'a flow node executes a graph a user drew, so elevation here lets a '
'user describe work that runs with every access control off',
),
(
'lib/Controller/',
'a controller handles an inbound request, so elevation here runs '
'caller-supplied input as a trusted userless principal',
),
(
'lib/Service/Mcp/',
'an agent tool executes a call a model chose from text it was given, '
'so elevation here is reachable from a document',
),
(
'lib/Mcp/',
'an agent tool executes a call a model chose from text it was given, '
'so elevation here is reachable from a document',
),
(
'lib/Tool/',
'an agent tool executes a call a model chose from text it was given, '
'so elevation here is reachable from a document',
),
(
'lib/Tools/',
'an agent tool executes a call a model chose from text it was given, '
'so elevation here is reachable from a document',
),
)

# Where a legitimate elevation lives. Not an allowlist of files — an
# allowlist of KINDS, so a new repair step needs no gate change while a new
# controller still fails. Work in these places has no user to act for by
# construction: nobody is present during a migration.
PERMITTED = (
'lib/Migration/',
'lib/Repair/',
'lib/Command/',
'lib/BackgroundJob/',
'lib/Cron/',
)


def _mask(src: str) -> str:
"""Blank out comments and string literals, preserving line structure.

A gate that reads raw text reports the sentence describing the rule as a
violation of it — this file's own docblock would fail this gate. Newlines
survive so reported line numbers stay true.
"""
out = []
i = 0
n = len(src)
while i < n:
ch = src[i]
nxt = src[i + 1] if (i + 1) < n else ''
if ch == '/' and nxt == '/':
while i < n and src[i] != '\n':
out.append(' ')
i += 1
continue
if ch == '#' and nxt != '[':
# `#[Attribute]` is code; `# comment` is not.
while i < n and src[i] != '\n':
out.append(' ')
i += 1
continue
if ch == '/' and nxt == '*':
while i < n and not (src[i] == '*' and (i + 1) < n and src[i + 1] == '/'):
out.append('\n' if src[i] == '\n' else ' ')
i += 1
out.append(' ')
i += 2
continue
if ch in ('"', "'"):
quote = ch
out.append(' ')
i += 1
while i < n and src[i] != quote:
if src[i] == '\\':
out.append(' ')
i += 1
if i < n:
out.append('\n' if src[i] == '\n' else ' ')
i += 1
continue
out.append('\n' if src[i] == '\n' else ' ')
i += 1
out.append(' ')
i += 1
continue
out.append(ch)
i += 1
return ''.join(out)


def _php_files(root: str):
"""Every tracked-looking PHP file under lib/, sorted for a stable report."""
lib = os.path.join(root, 'lib')
if not os.path.isdir(lib):
return []

found = []
for dirpath, dirnames, filenames in os.walk(lib):
dirnames[:] = [d for d in dirnames if d not in ('vendor', 'node_modules')]
for name in filenames:
if name.endswith('.php'):
full = os.path.join(dirpath, name)
found.append(os.path.relpath(full, root).replace(os.sep, '/'))
return sorted(found)


def _forbidden_reason(rel: str):
"""Why this path may not elevate, or None when it may."""
for prefix, why in FORBIDDEN:
if rel.startswith(prefix):
return why
return None


def main() -> int:
root = sys.argv[1] if len(sys.argv) > 1 else '.'
files = _php_files(root)

if not files:
print('checked 0 PHP file(s) under lib/ [full tree]: '
'this repo ships no server code that could elevate')
return 4

findings = []
elevating = 0

for rel in files:
try:
with open(os.path.join(root, rel), 'r', encoding='utf-8', errors='replace') as handle:
src = handle.read()
except OSError as exc:
# UNREADABLE IS NOT CLEAN. A file that could not be read is a file
# whose elevation is unverified, and reporting it as a pass would
# make the gate's green mean less than it claims.
print(f'FAIL {rel} could not be read ({exc}), so its elevation is UNVERIFIED.')
findings.append(rel)
continue

masked = _mask(src)
hits = [
idx + 1
for idx, line in enumerate(masked.split('\n'))
if ELEVATION_RX.search(line)
]
if not hits:
continue

elevating += 1
why = _forbidden_reason(rel)
if why is None:
continue

for line_no in hits:
print(f'FAIL {rel}:{line_no} — elevates to a trusted userless '
f'principal from a forbidden caller.')
print(f' {why}.')
findings.append(rel)

if findings:
permitted = ', '.join(PERMITTED)
print()
print(' ADR-099 rule 9: elevation is code-initiated only. Move the '
'work into a migration, repair step, command or background job '
f'({permitted}) — or refuse, naming the missing identity. There is '
'deliberately no exclusion annotation for this rule: an escape '
'hatch would be used exactly when somebody is making a refusal go '
'away, which is the case this gate exists for.')

print(f'\nchecked {len(files)} PHP file(s) under lib/ [full tree]: '
f'{elevating} elevate, {len(findings)} failure(s)')
return 1 if findings else 0


if __name__ == '__main__':
sys.exit(main())
26 changes: 25 additions & 1 deletion hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,31 @@ fi
# verdict for this gate absent a base showing growth,
# which this fixture's docs-only second commit does
# not supply for ANY gate.
_ARM6_ALLOWED=" 4 15 16 23 47 48 68 "
# 97 system-elevation-reachability — never diff-scoped, same posture as
# gate-23 and gate-68, and for a reason its own spec
# states: it establishes that a BOUNDARY holds, and a
# boundary cannot be established from a diff. A
# diff-scoped version reports nothing on the ~99% of PRs
# that never open a node or a controller.
# MEASURED, not assumed: `check_system_elevation.py`
# reads NO scope input at all — no BASE_REF, no diff, no
# file list — it walks lib/ itself. On this fixture it
# prints `checked 1 PHP file(s) under lib/ [full tree]:
# 0 elevate, 0 failure(s)` byte-for-byte identically at
# full scope and at --scope-to-diff --base HEAD~1,
# because there is no code path by which the scope could
# reach it. The fixture's ThingController calls
# `findAll()`, which is what gates 14/17/21 plant it for;
# it does not elevate, so a whole-tree read of it is an
# honest zero rather than a scope artifact.
# SEPARATELY: "FAIL the planted tree at full scope" is
# not a reachable verdict for this gate on THIS fixture —
# nothing in it elevates at all — so it is deliberately
# absent from the anti-widening list below. Its
# equivalent lives in gate-acceptance/system-elevation,
# whose planted arm FAILS and whose clean arm PASSES on
# a tree built for it.
_ARM6_ALLOWED=" 4 15 16 23 47 48 68 97 "

_wide_bad=""
while IFS= read -r _g; do
Expand Down
70 changes: 70 additions & 0 deletions hydra-gates/scripts/run-hydra-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10814,6 +10814,76 @@ else
_fail 96 "manifest-copy-style" "${_mcs_n} manifest string(s) break voice.md section 8 (em-dash / double-dash) - see ${_mcs_log}"
fi

# ---------------------------------------------------------------------------
# GATE 97 — system-elevation-reachability (ADR-099 rule 9)
#
# NUMBERED 97, NOT 96. This gate was written as 96 and #581 landed
# `manifest-copy-style` on that number first — two gates, one number, which is
# precisely what gate-95 (adr-number-collision) exists to stop for ADRs. A gate
# id is a citation key in the same way: it appears in COVERAGE lines, in
# `_ARM6_ALLOWED`, in fixture `expect.conf` files and in PR bodies, and each is
# a pointer that only works while the number resolves to one thing.
#
# `runAsSystem()` / `SystemOperationContext::run()` runs a callable as a
# trusted userless principal: no RBAC, no tenancy, no owner. It is for work
# that genuinely has nobody to act for — an installation seeding its own
# shipped registers, a migration, a repair step. It MUST NOT be reachable
# from a flow node, an agent tool, or the handling of an inbound request.
#
# WHAT THIS DEFENDS AGAINST is not somebody arguing for an escalation. It is
# somebody reaching for the nearest thing that makes a refusal go away —
# and ADR-099 deliberately put refusals everywhere an identity can be
# missing, each one within a few lines of a method that would make it
# succeed. The fix works, the test goes green, and every future run of that
# flow executes with access control off.
#
# Nothing downstream can catch it: by the time the callable runs the caller
# is gone, so no runtime assertion inside the method can say who invoked it.
# The control has to be structural, and structural controls drift.
#
# NO EXCLUSION ANNOTATION, deliberately. An escape hatch on this rule would
# be used exactly when somebody is making a refusal go away — the case the
# gate exists for — and a reason written in that moment reads identically to
# a legitimate one afterwards. Green bought with a plausible sentence is
# worse than red, because it ends the conversation. A genuine false positive
# is fixed by moving the elevation out of the forbidden caller, or by fixing
# this gate. Both leave a visible diff; a comment does not.
#
# GENERALISES a PHPUnit test. openregister's SystemOperationContextBoundaryTest
# pins the call-site set in ONE app; this binds the fleet, so an app that
# never wrote such a test is covered too.
#
# FULL-TREE, not diff-scoped: a diff-scoped version reports nothing on the
# ~99% of PRs that never open a node or a controller, so it could not
# establish that the boundary holds — the only claim worth making about a
# boundary. Noise is not a risk; on a clean repo the finding set is empty.
#
# NOTE ON PLACEMENT: top level, outside any `_FAILED` guard — a gate that
# only runs once everything else passed is green-but-dead.
# ---------------------------------------------------------------------------
_sel_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-system-elevation.log
: > "${_sel_log}"
set +e
python3 "${SCRIPT_DIR}/lib/check_system_elevation.py" . > "${_sel_log}" 2>&1
_sel_rc=$?
# `set +e`, not `set -e`: errexit off is the state this script actually runs
# in. See the note at the top of this file.
set +e

if [ "${_sel_rc}" -eq 0 ]; then
_pass 97 "system-elevation-reachability"
elif [ "${_sel_rc}" -eq 4 ]; then
_skip 97 "system-elevation-reachability" na "this repo ships no PHP under lib/, so it has no server code that could elevate to a trusted userless principal. See ${_sel_log}."
elif ! _helper_finished "${_sel_log}" '^checked [0-9]+ PHP file'; then
# A CRASH IS NOT A FINDING.
_sel_why=$(head -3 "${_sel_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200)
_skip 97 "system-elevation-reachability" wiring "check_system_elevation.py exited ${_sel_rc} without printing its terminal 'checked N PHP file(s)' summary, so the elevation boundary is UNVERIFIED by this run. Checker output: ${_sel_why:-<empty>}. See ${_sel_log}."
else
_sel_n=$(grep -cE '^FAIL ' "${_sel_log}" 2>/dev/null || true)
case "${_sel_n}" in ''|*[!0-9]*) _sel_n=1 ;; esac
_fail 97 "system-elevation-reachability" "${_sel_n} elevation(s) reachable from a flow node, agent tool or endpoint — see ${_sel_log}"
fi

# ---------------------------------------------------------------------------
# GATE 83 — contract-surface-shift (ADR-084)
#
Expand Down
Loading
Loading