Skip to content

fix(codex-gate): the review gate made the review protocol uncommittable (#533) - #548

Open
BaseInfinity wants to merge 2 commits into
mainfrom
fix/533-in-flight-commit-lane
Open

fix(codex-gate): the review gate made the review protocol uncommittable (#533)#548
BaseInfinity wants to merge 2 commits into
mainfrom
fix/533-in-flight-commit-lane

Conversation

@BaseInfinity

Copy link
Copy Markdown
Owner

Closes #533.

The review gate blocked every save while status was PENDING_REVIEW or PENDING_RECHECK — which is the status the shipped protocol prescribes for the entire duration of a review round. Work could not be saved exactly while it was being reviewed, so reviewers read a mutable working tree. During #520 round 18 a reviewer read a tree a mutation harness was concurrently mutating and filed a fully-diagnosed P1 against a defect that existed only in the mutation. A whole round spent on an artifact.

The lane: an in-flight status may save, but only onto a branch the hook can positively prove is not the default branch. Everything else blocks exactly as before — no artifact, unrecognised status, and #437 staleness all still block, on every branch.

What round 1 got wrong

Codex reviewed the first implementation and returned NO with two P0s, both of which I reproduced. Both failed lenient, which is the only direction that matters: the lane turned a hard block into a silent allow.

P0-1 — targeting. The branch check consulted the hook's own cwd, never the repository actually being written to. Relocation is now refused by shape rather than modelled: any relocation token, or any chain segment that is not a bare git invocation, makes a command ineligible for the lane. Ineligible is not a block — it falls through to the pre-existing rules, which block an in-flight status.

P0-2 — the resolver. A leftover local ref from a branch rename, and a symref that was never repointed after a default-branch change, were both being read as proof. Codex was right that the one named hole was not the only one, so the positive-evidence fallback is deleted rather than patched. The lane now requires a conjunction of positive proofs: inside a work tree, an attached HEAD, a branch outside the reserved-trunk denylist, and at least one remote whose HEAD symref resolves to a ref that actually exists and names something else. Anything unresolvable is "we do not know", and "we do not know" is not a lane.

Two holes that predate this branch

Found while verifying the above. Neither is a regression of the lane; both are reproduced against 435708a on main, and in both cases the gate returned exit 0 with no review artifact present at all.

P0-3 — detection. A newline reaches the hook as two characters, so the word boundary in the detection pattern never fired after one. This gate has never seen a git commit that was not on the first line of the command. Every multi-line invocation has walked past it since it was written.

P0-4 — detection. A short option accepts its value attached, so one spelling of the repo-relocation flag was a real, valid invocation the old pattern could not match at all.

Evidence

The verification matrix was executed against three hooks:

hook result
this HEAD 20/20 green
d26eb28 (the round-1 hook Codex reviewed) 15 rows RED — every one a silent exit 0
435708a (main) 2 rows RED — both detection holes above

23 permanent assertions in tests/test-hooks.sh, all of which execute the hook rather than grepping its source. 243 hook tests pass; all 65 non-E2E CI suites pass locally on macOS.

Known limitation, on record: a bare git commit -F - <<'MSG' loses the lane, because that heredoc body is unquoted and a prose line is indistinguishable from a command segment to a coarse shape test. The three forms that carry a body through a quoted span all keep it, so no commit is prevented — one spelling of it is. Pinned by a test so it stays a decision rather than a surprise.

Dogfooded: the round-2 commit on this branch was made at PENDING_RECHECK — the exact state that blocked this work twice during #531.

Reviewer note

macOS is the only platform verified locally. The remaining flagged risk is BSD-vs-GNU divergence in \b, \s, and sed's newline handling, all of which this change depends on. Linux CI green is part of the verification here, not a formality.

)

The gate blocked every commit unless handoff.json said REVIEWED or
CERTIFIED. The shipped protocol prescribes PENDING_REVIEW and
PENDING_RECHECK for the whole duration of a round — which is exactly when
work gets produced. So nothing could be saved while it was under review,
reviewers were pushed onto a mutable working tree, and REVIEW UNIT's
"review committed SHAs, not a mutable tree" was unreachable by
construction.

Not theoretical. During #520 round 18 a reviewer read the tree while a
mutation harness was concurrently mutating it, measured 179 passed / 16
failed, and filed a correct, fully-diagnosed P1 against a defect that
existed only in the mutation. A whole round spent on an artifact.

The fix is a branch-scoped in-flight lane. An in-flight status may be
saved, but only onto a branch the hook can positively prove is not the
default one. The gate's purpose is stopping unreviewed work from reaching
the default branch, never stopping work from being saved. Everything else
blocks as before: no artifact blocks everywhere, an unrecognised status
blocks everywhere, and #437's freshness check is untouched — including on
feature branches.

Round 1 counts as much as round 2. PENDING_REVIEW is what holds while the
FIRST reviewer reads, which is precisely the configuration that produced
the phantom P1.

Resolution is offline and fails strict. refs/remotes/origin/HEAD is a
local ref written by git clone, with main/master refs as fallback.
Anything unresolvable — not a repo, detached HEAD, no trunk-shaped ref
anywhere — is treated as the default branch. init.defaultBranch is
deliberately not consulted: it names the branch for NEW repos, so it can
name one this repo never had.

The documented bypass was also unreachable from where you hit it. All four
messages said to set CODEX_GATE_SKIP=1, but the hook reads that from its
own process environment and runs BEFORE the command, so an inline prefix
in a tool call can never produce it. The mechanism is right and stays —
an agent must not be able to self-grant a bypass of a gate that exists to
constrain it — so no sentinel file was added. The messages now name the
route that works.

Nine tests: three RED before the fix, six boundary assertions that pin the
quarantine. The follow-up the design named — the Stop nag going silent
once the tree stays clean — is filed as #547, not patched here.

Design: Fable, 82% confidence, the 18% in the branch-resolution primitive.
…and at all

Round 1 of the in-flight lane failed lenient in two directions Codex reproduced,
and verifying those turned up two detection holes that predate the lane.

P0-1, targeting. The hook read its own branch and vouched for a different
repository. Relocation is now refused by shape rather than modelled: any
relocation token, or any chain segment that is not a bare git invocation, makes
a command ineligible. Ineligible is not a block; it falls through to the old
rules, which block an in-flight status.

P0-2, the resolver. A vestigial local ref and a stale symref were being read as
proof. The positive-evidence fallback is deleted, not patched. The lane now
needs a conjunction: inside a work tree, an attached HEAD, a branch outside the
reserved-trunk denylist, and a remote whose HEAD symref resolves to a ref that
exists and names something else. Anything unresolvable is "we do not know", and
that is not a lane.

P0-3, detection, pre-existing. A newline arrives as two characters, so the word
boundary never fired after it and this gate has never seen a commit that was not
on the first line. Verified on 435708a: exit 0 with no review artifact at all.

P0-4, detection, pre-existing. A short option takes its value attached, so one
spelling of the repo-relocation flag was a real invocation the old pattern could
not match. Also exit 0 on 435708a with no artifact.

23 assertions, executed against the hook. 15 are red against the round-1 hook
and 2 against main; the rest pin behaviour that must not move.
@BaseInfinity

Copy link
Copy Markdown
Owner Author

Round 2: NOT CERTIFIED. The design is refuted, not the implementation.

Codex returned two more P0s at fe0a326. I reproduced both independently before accepting them. Both are real, and together they say the same thing: this hook is trying to answer questions it cannot answer from what it has.

P0-A — masking destroys the evidence eligibility needs

The eligibility shape test runs against MASKED_COMMAND, in which every quoted span has already been collapsed to a placeholder. That masking exists so the detection regex is not fooled by prose in a commit message. It also erases the tokens the relocation check is looking for.

git --git"-dir=/target/.git" --work"-tree=/target" commit --allow-empty -m "wip"

Masking turns this into git --gitQ --workQ commit .... No relocation token survives, every segment looks like a bare git invocation, and the hook returns 0. I confirmed the target repository's HEAD actually moved:

6a5f5f0 -> 05f6a9d

Any quote character can split any token. Blacklisting tokens in a string whose quotes have been erased is unsound in principle, not just in this instance.

P0-B — a cached symref is not authority

The resolver requires a remote whose refs/remotes/<r>/HEAD symref resolves to a ref that exists, and that the current branch differs from it. Codex's framing is exact: the conjunction proves the cached target exists, not that it remains authoritative.

real trunk:              live
cached origin/HEAD:      origin/main
origin/main:             exists
current branch:          live
hook verdict:            0

The hook grants the lane while standing on the repository's real trunk. git remote set-head -a needs the network, so that cache can be arbitrarily stale. The reserved-trunk denylist I added closes named examples only — a trunk can be called anything.

I was wrong about one of my own findings

I claimed git -C/other commit was an undetected bypass. It is not. git rejects an attached value for -C:

git -C/tmp status   -> exit 129   (git 2.40.1)
git -C <dir> status -> exit 0

-C takes its path as a separate argument. The regex broadening is harmless, but the justification I gave for it was false, and the PR body above overstates it. Correcting the record rather than leaving it.

The other self-found hole is real and confirmed pre-existing: a newline arrives as two characters, so the word boundary never fired after one, and this gate has never seen a commit that was not on the command's first line — exit 0 on main with no artifact present. That fix stands on its own and probably belongs in its own PR against main, since it closes a bypass that exists in shipped code today.

Where this goes

This is the second repair round on the same component in one cycle, which by this repo's own rule means the design is wrong rather than the implementation. I've escalated the design question rather than writing a third patch. Likely outcomes include deleting most of what this branch added and splitting out the newline detection fix.

What did verify clean

  • Linux CI green (validate), which closes the flagged BSD-vs-GNU risk in \b, \s, and sed newline handling.
  • P0-3 confirmed pre-existing at 435708a and fixed at HEAD.
  • 243 hook tests pass; the round-2 matrix is 26 hook-executing calls with no source-grep assertions.
  • Typo status, absent artifact, and stale certification all still block on every branch; no network in the resolver; set -e safe.

Round-1 and round-2 reviews are now committed at .reviews/533-round1-review.md and .reviews/533-round2-review.md — Codex correctly flagged that latest-review.md held an unrelated older review.

@BaseInfinity

Copy link
Copy Markdown
Owner Author

Design call after two refuted rounds: the question was wrong, not the answer

Two review rounds, four P0s, all four reproduced. Escalated to design rather than writing a third patch, per this repo's second-repair rule. The verdict:

"Provably not the default branch" is unanswerable offline

Default branch is remote, server-side state. An offline hook has only refs/remotes/*/HEAD, a cache with no freshness boundgit remote set-head -a needs the network. So P0-B is not a missing denylist entry. It is the proof that the question cannot be answered.

Both P0s were the same error: treating non-authoritative evidence as authority. A quote-masked string is not the command. A cached symref is not the remote's default.

The feature never needed that question

The requirement is reviewers get immutable SHAs of the round's work. The round already knows which branch it is about. Two rounds were spent answering something strictly harder than the feature asks for.

The design

When a round opens, the protocol writes a branch field into .reviews/handoff.json alongside status. The in-flight lane becomes:

git symbolic-ref --quiet --short HEAD in the hook's cwd must be non-empty and equal the handoff's branch → allow. Mismatch, detached HEAD, missing field, or not a repo → exit 2.

Two strictness-only lines stay, documented in the hook as accident-catchers and explicitly not soundness claims: refuse on a literal main/master, and refuse when the raw command string carries a plain relocation token. If that framing isn't written down, they get deleted instead — an undocumented denylist is how this cycle started.

Why this holds where the other two didn't. It adds no new trust assumption. REVIEW_FILE is already a relative path, and the CERTIFIED lane already compares commit_sha against cwd HEAD while the command could target elsewhere. branch sits at exactly the trust level status and commit_sha have always had. This gate is a guardrail against a cooperating-but-fallible agent, not a sandbox — a motivated bypass has always had bash -c.

Rejected as wrong, not merely worse:

  • Parse the command positively instead of blacklisting. The hook can never reproduce the shell's parse, and a perfectly parsed command still cannot answer P0-B. It solves half the failure.
  • Make the lane unconditional and let the merge boundary carry it. scripts/merge-pr.sh deliberately does not ship, so consumers have no merge boundary to retreat to — unconditional would allow in-flight commits straight onto a consumer's trunk. Declared-branch also closes a hole this leaves open: a stale PENDING handoff would be a forever-pass on every branch.

Eligibility must never run on masked text. Masking exists so detection can ignore prose; it destroys exactly what eligibility needs. The masked shape test and segment counting are deleted, and are not rebuilt on the raw string.

What dies

Most of the ~500 lines on fix/533-in-flight-commit-lane: the branch resolver, the eligibility block, the segment counter, the 14-name trunk denylist. What survives is the broad detection regex and a rewritten PENDING lane.

Splitting out the real bypass

The newline-decode fix (P0-3) ships as its own minimal PR against main — decode plus a RED test, nothing else. It closes a bypass that exists in shipped code today and must not wait on this redesign.

It will not carry the -C regex broadening with it. I claimed attached -C was an undetected bypass; git rejects git -C/path outright (exit 129, verified on 2.40.1 and Apple git 2.39.5). The broadening matches nothing real and its justifying comment was false. It dies with the branch.

Confidence

88%. The residual sits in whether the reviewer accepts the self-attestation trust model — the prepared answer is that it is the same model status and commit_sha have always rested on, and this gate has always been cwd-scoped.

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.

Merge gate makes the prescribed review dialogue uncommittable, forcing reviewers onto mutable trees

1 participant