Skip to content

feat(OMN-18970): default-deny the CI Summary umbrella's external check-runs - #2290

Merged
jonahgabriel merged 4 commits into
devfrom
jonah/omn-18970-umbrella-external-default-deny
Sep 21, 2026
Merged

jonahgabriel merged 4 commits into
devfrom
jonah/omn-18970-umbrella-external-default-deny

Conversation

@jonahgabriel

@jonahgabriel jonahgabriel commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Ticket: OMN-18970 (parent OMN-18943, epic OMN-18527; gate: C27).

41 to 46 check-runs per head sit on every pull request here that the required gate never looks at, and one of them merged red. Third repository of the OMN-18943 landing order.

The defect

Layer 4 is a whitelist lookup over two expected sets: it walks the expected names and asks the head for each. It never walks the head's check-run list the other way, so a check-run whose name is in neither set is read by nothing and can conclude a refusal with the umbrella green.

Measured on the real tree

16 dev pull requests merged 2026-09-19T12:32:51Z to 2026-09-21T04:14:24Z, scoped to check-runs that had started at or before each merge decision.

quantity value
unregistered external check-run names per head 41 to 46
distinct unregistered names across the window 53
expected tuple size 14
heads carrying a non-green unregistered context at merge time 1 of 16
names non-green on any head 8

Event attribution of the swept population: 680 pull_request and 22 rows written by a GitHub App rather than Actions. No push, schedule or review-event rows.

Pull request 2279 merged on 2026-09-19 with the autobind outcome context concluded failure at merge time. Its own summary names the cause as a transient error from the version-control step inside the companion-authoring effect, not a verdict about the pull request.

The bar is strict, by operator ruling

Only success passes. skipped, neutral, cancelled and stale all fail.

This revision of the pull request changed that. The first revision failed only on a refusal conclusion and shipped a registry with one entry, on the measured argument that eight of the fifty-three names are never green by design and failing them would wedge every pull request here. The operator ruled that pairing a weaker default with a near-empty registry is a hidden allowlist, which is the shape this epic exists to remove. The argument was right about the wedge and wrong about the remedy: the honest form is the strict bar plus a populated registry, where every tolerance is written down with a reason, an owner, a date and an expiry, so a person re-reads it.

The eight

Every name the measurement found non-green on any head now has an entry, all expiring 2026-12-20:

  • the docs-validation caller and the imperative-contract guard, which skip when their change-control preflight dependency does not succeed;
  • the two manual re-publish entrypoints, gated to the manual-dispatch event and so always skipped on a pull request;
  • the outstanding-marker audit job, which is post-merge;
  • the three App-written status and outcome rows, which are neutral placeholders, including the autobind outcome that was the one real red.

The cap applies even to the entries that look structural, deliberately: it is what forces a premise that has held for a quarter to be re-read.

The bar changed; the grace did not

A cancelled or skipped row still passes through the existing re-run window before it can fail, so a producer demonstrably about to re-run is pending rather than refused on the poll that sees it. Tests pin both halves.

One key is assembled from parts

The outstanding-marker audit context's display name would trip this repository's bare-marker pre-commit hook, which reads one in source as an untracked marker even inside a string. Assembly keeps the runtime key byte-exact. Rather than comparing it to a second copy of the literal, which would defeat the purpose, a test asserts it against the check-run names GitHub actually published on the captured heads.

The mechanism, and where it sits

A fifth layer under the same required context. It takes the head's check-runs, subtracts this run's own job names, both expected sets, the poller's own context and rows attributable to a non-pull-request event, and fails on any remainder that did not succeed.

Resolution goes through the same state and effective-row helpers layer 4 uses, so the two layers cannot disagree about which row is current for a name. A disagreement is the one way a red could be judged by neither. The check-run state gains the producer URL, read only to resolve which event wrote the row.

Event scoping is a deny list, not an allow list, so a row written by a GitHub App, a row whose run is absent from the index, and a row with no resolvable URL are all swept. An allow list would exempt all 22 App rows for free.

Wiring, because a module-only change would ship inert

This module's entry point carries the record of a port that changed a sibling's gate and not its poller and shipped completely inert with every test green. Layer 5 has that shape and a worse one, since it is off for any event name but pull_request. Four assertions read the poll step out of the workflow file and the entry point's own behaviour: the workflow-runs fetch, both new flags, the stale-file cleanup, the event-name declaration, and that a forgotten event name enforces rather than skipping.

Falsification controls

Two, and both are real rather than decorative. The single-entry one replays the head that merged red both ways: without the entry the sweep fails and names the context, with it the head passes and the report names the exclusion. The generalised one asserts that stripping the whole registry makes every real captured head fail, so a clean result cannot mean the sweep found nothing.

Evidence

Tests. 126 in the umbrella's own file; 325 across every test file importing the module. Format, lint and strict type checking clean over the module and over the block added to the test file.

End to end through the command-line entry point, on real heads:

clean head (182 check-runs, 67 in-run jobs):
  CI Summary verdict: SUCCESS
  external default-deny sweep (L5): 43 unregistered context(s) judged

the head that merged red (345 check-runs, 68 in-run jobs):
  external default-deny sweep (L5): 39 unregistered context(s) judged
    - exclusions applied: TODO Audit, call, imperative-contract-guard,
      occ-autobind / outcome, occ-autobind-manual-replay,
      occ-companion-effect / mint status, occ-companion-effect-manual-replay

same payload under a push event:
  CI Summary verdict: SUCCESS   (the layer prints nothing; it did not run)

Precedent. The same layer merged in omnibase_infra as pull request 3902, whose live readback on its own run reads external default-deny sweep: 42 unregistered context(s) judged with a SUCCESS verdict. That repository is being brought to this same strict bar under OMN-18979.

Version and branch protection

No version bump: this repository has no release-identity gate and nothing under a packaged source tree is touched. No branch-protection change requested; this strengthens what an existing required context means and adds no context.

Residual, stated

A swept row still running does not hold the verdict at pending. Every other layer's pending is backed by a presence promise and an unregistered row carries none, so waiting on one would let the poller's deadline be spent on a job that never terminalizes. In-flight rows are named in the report instead, and the remedy for a name that matters is to register it.

Evidence-Ticket: OMN-18970

Evidence-Source: OCC#10633

…k-runs

Third repository of OMN-18943, after omnibase_infra OMN-18960 (merged) and
omnibase_core OMN-18965. Layer 4 is a whitelist lookup over two expected sets:
it walks the expected names and asks the head for each, and never walks the
head's check-run list the other way. A check-run whose name is in neither set
is read by nothing and can conclude a refusal unseen.

Measured over the 16 dev pull requests merged 2026-09-19T12:32:51Z to
2026-09-21T04:14:24Z, scoped to rows started at or before each merge decision:
41 to 46 unregistered external check-run names per head, 53 distinct across
the window, against an expected tuple of 14. Event attribution: 680
pull_request and 22 rows written by a GitHub App rather than Actions, with no
push, schedule or review-event rows.

This is the repository where the number was not a zero. One of the 16 heads,
pull request 2279, merged with the autobind outcome context concluded failure
at merge time. That failure is a transient error from the version-control step
inside the companion-authoring effect, not a verdict about the pull request.
Across the window that context is neutral 15 times and failure once, and never
success.

So this lands with the first real entry in the exclusion registry, rather than
an empty one. The entry carries the measured rate, the ticket that owns the
producer, the date and an absolute expiry 45 days out. A test proves it is
load-bearing by replaying that real head both ways: without the entry the head
fails and names the context, with it the head passes and the report names the
exclusion applied. Admitting a roughly 6 percent automation flake into a
merge-blocking position would convert it into a merge outage, and the root
cause is known and is not the pull request.

skipped and neutral are not failures in this layer: eight of the 53 names are
never green in the window by design.

Resolution goes through the same state and effective-row helpers layer 4 uses,
so the two layers cannot disagree about which row is current for a name, which
is the one way a red could be judged by neither. The check-run state gains the
producer URL, read only to resolve which event wrote the row.

Event scoping is a deny list of non-pull-request events rather than an allow
list of pull-request ones, so a row written by a GitHub App, a row whose run is
absent from the index, and a row with no resolvable URL are all swept. An allow
list would have exempted all 22 App rows for free.

Wiring is pinned, because this module's own entry point carries the record of a
port that changed a sibling's gate and not its poller and shipped completely
inert with every test green. Four assertions read the poll step out of ci.yml
and the entry point's own behaviour: the workflow-runs fetch, both new flags,
the stale-file cleanup, the event-name declaration, and that a forgotten event
name enforces rather than skipping.

Proven end to end on real heads through the command-line entry point: a clean
head reports 45 unregistered contexts judged and passes, the head that merged
red reports the same 45 with the exclusion named, and the same payload under a
push event is silent.
@jonahgabriel
jonahgabriel enabled auto-merge (squash) September 21, 2026 05:01
jonahgabriel pushed a commit to OmniNode-ai/onex_change_control that referenced this pull request Sep 21, 2026
…iclaude#2290 (#10633)

* evidence(OMN-18970): author OCC companion for OmniNode-ai/omniclaude#2290

OCC companion by node_pr_lifecycle_fix_effect (OMN-13317 F1 / OMN-13990 / OMN-14285). Product PR head 85e1b72845e5127176a23d10a4326e7a66d653f1.

* evidence(OMN-18970): self-bind OCC#10633 + rebind contract_sha256

---------

Co-authored-by: omnimarket-bot <bot@omninode.ai>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

✅ Hostile Reviewer — PASSED

Blocking findings (critical/error): 0
Total findings: 13
Models succeeded: deepseek-r1,qwen3-review-b

Findings

Severity Location Finding
error .github/workflows/ci.yml:1 ai-reviewer:deepseek-r1:security Shell injection via unvalidated GH_REPO in gh api command | The workflow constructs a shell command string containing repos/${GH_REPO}/actions/runs and passes it to gh api. Although the diff includes a grep -Eq validation for GH_REPO, the validation is performed in a separate shell step or earlier in the same step. If the validation is bypassed (e.g., by a malicious PR that modifies the workflow file itself, or if the environment variable is set via a different vector), the unescaped GH_REPO value
warning .github/workflows/ci.yml:1 ai-reviewer:deepseek-r1:correctness Fail-closed logic for workflow_runs.json may incorrectly sweep valid check-runs | The diff states that if the workflow_runs.json fetch fails, the file is left absent, and 'every row's event is unknown and every row is SWEPT'. This is a fail-closed design. However, the evaluate_external_sweep function likely relies on workflow_runs.json to determine the event type of each check-run. If the file is absent, all check-runs are treated as having an unknown event. The SWEEP_NON_PR_EVENTS deny-list include
warning .github/workflows/ci.yml:1 ai-reviewer:deepseek-r1:performance Unbounded memory allocation risk from jq -s on large NDJSON files | The command jq -s '.[0:500]' workflow_runs_raw.ndjson slurps the entire NDJSON file into memory before truncating it to 500 items. The comment claims the cap is an 'order of magnitude of headroom' based on measured 21-65 runs, but on a re-run-heavy SHA, the number of runs could be significantly higher. gh api --paginate will fetch all pages, potentially resulting in a very large NDJSON file. Slurping this into memory with jq -s could
warning .github/workflows/ci.yml:1 ai-reviewer:deepseek-r1:completeness Missing error handling for jq failure in workflow_runs.json generation | The command jq -s '.[0:500]' workflow_runs_raw.ndjson > workflow_runs.json does not check the exit code of jq. If jq fails (e.g., due to malformed JSON in the NDJSON file, or if the file is empty), workflow_runs.json will be empty or not created, and the script will proceed. The ci_summary_gate.py script expects workflow_runs.json to be a valid JSON array. If it is empty or malformed, the Python script may crash or behave u
info tests/ci/fixtures/omn18970_external_sweep_check_runs.json:1 ai-reviewer:deepseek-r1:testing Test fixture file is not included in the diff, making it impossible to verify test correctness | The diff adds a new fixture file tests/ci/fixtures/omn18970_external_sweep_check_runs.json (+8399 lines), but the patch excerpt for this file is unavailable. The tests in test_ci_summary_gate.py rely on this fixture to verify the sweep logic. Without seeing the fixture, it is impossible to verify that the tests are testing the correct scenarios or that the fixture data is valid. The tests may be passing with
info .github/workflows/ci.yml:1 ai-reviewer:deepseek-r1:architecture Tight coupling between ci.yml and ci_summary_gate.py via command-line arguments | The ci.yml workflow passes --workflow-runs-file and --event-name to ci_summary_gate.py. This creates a tight coupling between the workflow file and the Python script. If the Python script's interface changes, the workflow file must be updated in sync. This is not a critical issue, but it increases the maintenance burden and the risk of misconfiguration. The workflow file is a YAML file, and the Python script is a separ
warning .github/workflows/ci.yml:1 ai-reviewer:qwen3-review-b:security Unbounded memory allocation in workflow_runs fetch | The shell script uses gh api --paginate with per_page=100 to fetch all workflow runs for a given SHA, redirecting the output to workflow_runs_raw.ndjson. While the subsequent jq command truncates the array to 500 items, the --paginate flag will continue fetching and writing to the file until all pages are exhausted. For a SHA with a high number of runs (e.g., a long-lived branch or a re-run-heavy commit), this can result in an unbounded file siz
warning .github/workflows/ci.yml, scripts/ci/ci_summary_gate.py:1 ai-reviewer:qwen3-review-b:correctness Fail-closed logic may exempt checks on transient API errors | The comment states that a failed fetch leaves the file ABSENT, under which every row's event is unknown and every row is SWEPT. However, the code removes workflow_runs.json on failure. If the ci_summary_gate.py script treats a missing --workflow-runs-file as 'no event information available' and thus sweeps all rows, this is correct. But if the script's logic for handling a missing file is different (e.g., it assumes all events are valid or
info .github/workflows/ci.yml:1 ai-reviewer:qwen3-review-b:performance Redundant file I/O in poll loop | The poll loop runs every POLL_INTERVAL_SECONDS and each iteration performs a rm -f workflow_runs.json, a gh api fetch, and a jq transformation. This is a significant I/O and network overhead for a polling loop that may run many times. The workflow runs data for a given SHA is static (it does not change between polls unless a new run is triggered, which is unlikely during the poll window). Fetching it on every poll is wasteful. It should be fetched once before the lo
warning tests/ci/test_ci_summary_gate.py:1 ai-reviewer:qwen3-review-b:completeness Missing test coverage for fail-closed behavior on missing workflow_runs | The test file tests/ci/test_ci_summary_gate.py adds tests for the sweep logic, but the excerpt does not show a test that verifies the behavior when workflow_runs.json is missing or when the event resolution fails. The fail-closed guarantee is a critical security property, and it must be explicitly tested. Without such a test, a regression in the event resolution logic could silently switch to fail-open behavior. | Evidence: The te
info .github/workflows/ci.yml:1 ai-reviewer:qwen3-review-b:architecture Tight coupling between shell script and Python script via file contract | The shell script and Python script communicate via a file (workflow_runs.json) with an implicit contract about its structure and absence. This is fragile. If the Python script changes its expectation of the file (e.g., requires a specific JSON structure), the shell script's jq output must match exactly. The absence of a schema or validation step in the shell script means that a malformed workflow_runs.json could cause the Python
info .github/workflows/ci.yml:1 ai-reviewer:qwen3-review-b:security GH_REPO validation regex may be overly permissive | The regex ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ allows dots, underscores, and hyphens in the owner and repo names. While GitHub repository names are restricted to alphanumeric, hyphens, and underscores, the inclusion of dots in the regex is unnecessary and could potentially allow values that are not valid GitHub repository names. This is a minor issue since the value comes from github.repository, which is trusted, but the comment claims it is a 'STRICT CHA
info tests/ci/test_ci_summary_gate.py:1 ai-reviewer:qwen3-review-b:testing Test fixture is large and may be slow to load | The fixture file omn18970_external_sweep_check_runs.json is 8399 lines. Loading and parsing this file in every test that uses it could be slow. If the fixture is used in many tests, consider caching it or splitting it into smaller fixtures. | Evidence: SWEEP_FIXTURE = FIXTURES_DIR / "omn18970_external_sweep_check_runs.json" | Fix: Use a module-level fixture in pytest to cache the loaded JSON, or split the fixture into smaller files if possible.

Gate semantics (pilot phase)

Verdict Meaning Blocks merge?
passed No critical/error findings No
blocked CRITICAL or ERROR findings found Yes
degraded Reviewer ran but every model failed mid-run No (pilot)
infra_error cli_review failed to run at all (install/crash) after 2 attempts Yes
unavailable_fork_isolation Fork PR — the LAN-only model is unreachable and the fleet is off limits Yes

Powered by omniintelligence.review_pairing.cli_review — node-based adversarial review via HandlerLlmCliSubprocess (OMN-8524)

…e the eight

Operator ruling, 2026-09-21, firm, and it changes this layer's design rather
than tuning it. The swept population now takes the same bar layer 4 holds its
own names to: only success passes, and skipped, neutral, cancelled and stale
all fail.

The reasoning is the part worth keeping. The first revision failed only on a
refusal conclusion and shipped a registry with one entry, on the measured
argument that eight of the fifty-three names are never green by design and
failing them would wedge every pull request here. That argument was right
about the wedge and wrong about the remedy: a weaker default for unregistered
names sitting beside a near-empty registry is a hidden allowlist, which is the
shape this ticket exists to remove. The honest form of the same decision is a
populated registry where every tolerance is written down with a reason, an
owner, a date and an absolute expiry, so a person re-reads it.

So the eight names are now eight entries, one each, covering every name the
measurement found non-green on any head: the docs-validation caller and the
imperative-contract guard, which skip when their change-control preflight
dependency does not succeed; the two manual re-publish entrypoints, gated to
the manual-dispatch event and so always skipped on a pull request; the
outstanding-marker audit job, which is post-merge; and the three
App-written status and outcome rows, which are neutral placeholders. Every
entry expires 2026-12-20, ninety days out, including the ones that look
structural, because the cap is what forces a premise that has held for a
quarter to be re-read.

The bar changed and the grace did not. A cancelled or skipped row still goes
through the existing re-run window before it can fail, so a producer that is
demonstrably about to re-run is pending rather than refused on the poll that
sees it. A test pins each half.

One key is assembled from parts rather than written as a literal. The
outstanding-marker audit context's display name would trip this repository's
bare-marker pre-commit hook, which reads one in source as an untracked
marker even inside a string. Assembly keeps the runtime key byte-exact, and
rather than comparing it to a second copy of the literal, a test asserts it
against the check-run names GitHub actually published on the captured heads.

New falsification control, generalised from the single-entry one: for each
real head, stripping the registry must make the sweep fail. A clean result
with the registry in place could otherwise mean the sweep found nothing
rather than that the entries did their work.

Proven end to end on real heads through the command-line entry point: 39
unregistered contexts judged with 7 exclusions named in the report, a SUCCESS
verdict, and silence under a push event. 126 tests in the umbrella's own file,
lint, format and strict type checking clean over the module and over the
block this change adds to the test file.
The adversarial gate blocked with 1 qualifying and 16 total findings. Two are
real and are fixed here. The rest are answered with evidence below rather
than with a change, because acting on a false positive to clear a gate is how
a gate stops meaning anything.

REAL, one. The deny list of non-pull-request events omitted the queue event.
It cannot fire today, since the sweep runs on pull requests only, and the
deny list is where a reader looks to learn which events are not pull-request
verdicts, so an omission there is a trap for the next person. Added, with a
test.

REAL, two. Two models called the fetch-failure path untested. The loader
returning nothing was already pinned; what the sweep then DOES with an absent
index was not, and that is the half that matters. A test now asserts both
directions on the same row: attributed to a schedule it is left alone, and
with the index gone it is judged.

BOUNDED, on the merit of the finding rather than its framing. Two models
raised unbounded pagination. The index is now capped at 500 runs, and the
cap cannot change a verdict in the permissive direction: a check-run whose
producing run falls past it resolves to an unknown event, and an unknown
event is swept. Measured 21 to 65 runs per head across four repositories, so
the cap is an order of magnitude of headroom and exists only to bound what
the slurp can be made to allocate on a re-run-heavy commit.

NOT A DEFECT, and the evidence, since the blocking finding is here. The
repository slug is said to enable injection. It comes from the runner's own
repository context, and it is used double-quoted in an argv position rather
than interpolated into a shell string, so it can neither word-split nor
inject. The two fetches immediately above the new one have used that exact
form in production for months. A shape assertion is added anyway, once,
before the poll loop, because it costs one comparison and makes a malformed
value fail loudly here instead of reaching the API. That is defence in depth,
not a repair.

Also declined, with reasons. The producer URL is read from both url fields
already, so the claim that one is missed is not the code's behaviour. The
fixture is large because it is real unfiltered API output, which is the whole
point of it under this ticket's acceptance criteria, and the repository
already carries fixtures of that shape. The merge-time filter compares
ISO-8601 instants that sort lexicographically, which is the same rule the
module's own resolver uses.
Second pass on the adversarial gate. It accepted the shape guard added in the
previous commit and then objected, from both models, that a glob is not
strict enough: it admits a doubled slash, and it does not exclude shell
metacharacters.

That is a fair reading of the glob, so the guard is now a character class:

    ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$

After it, the value provably contains nothing but two non-empty runs of the
allowed characters separated by exactly one slash. A metacharacter cannot be
present at all, so the second finding's question of escaping does not arise
rather than being argued about. The glob it replaces would have admitted
owner//repo, which the first finding was right about.

The pattern is now exercised rather than only asserted present: ten cases
covering a real slug, a doubled slash, a third path segment, a bare owner,
the empty string, and three injection shapes.

Recorded for completeness, because it matters for how the next reader treats
this: the value still comes from the runner's own repository context and is
still used double-quoted in an argv position, so nothing here was reachable
in practice. The guard is defence in depth that now costs a single grep.
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