Skip to content

fix(gate): pin primary diff evidence to PR head - #281

Open
itsHabib wants to merge 2 commits into
mainfrom
codex/gate-pin-diff-head
Open

fix(gate): pin primary diff evidence to PR head#281
itsHabib wants to merge 2 commits into
mainfrom
codex/gate-pin-diff-head

Conversation

@itsHabib

@itsHabib itsHabib commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Bind Gate's primary diff evidence to the exact PR head already captured by the run's view evidence. The primary path now fetches the merge-base diff through an immutable base/head compare pair, so neither a moved head nor an A→B→A double force-push can substitute bytes from another head into the authorization record.

What this adds

Exact-head primary diff evidence:

  • Read and validate the pull's base/head commit pair, refusing a mismatch against the viewed headRefOid.
  • Fetch the normal-size diff from the SHA-pinned compare/<base>...<head> endpoint and record the verified head.
  • Pin both a moved-head refusal and the A→B→A race with deterministic mutants, then close the matching Gate follow-up.

Validation

  • go test ./cmd/gate/internal/evidence
  • Live gh pr diff and SHA-pinned compare output have identical SHA-256 digests for this PR.
  • gofmt -l .
  • go vet ./...
  • golangci-lint run ./...
  • go test ./...

Dossier task: tsk_01KZXTYKG0B7A3HSKTE4D15BZT

@itsHabib

itsHabib commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

itsHabib commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@itsHabib
itsHabib requested a lite review from Copilot September 4, 2026 07:53
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T09:21:42.935348Z 9c19941 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 2m 2s —— View job


Code Review — PR #281: fix(gate): pin primary diff evidence to PR head

  • Gather context
  • Read changed files
  • Analyze changes
  • Post review

Reviewed head: 486812a. Three files, one security-property fix, one new test file, one FOLLOWUPS update.

Summary

The fix is correct and the threat model is right. gh pr diff fetches by PR number with no commit pin, so a force-push between the view read and the diff read could previously record bytes from the wrong head. The new fetchPrimaryDiff closes that window by re-reading pulls/<n> immediately after the diff succeeds and refusing on a head mismatch. The fallback path (localDiff) already had this property; the primary path now matches it.


evidence.gofetchPrimaryDiff (lines 230–247)

The logic is correct:

func fetchPrimaryDiff(pr PRRef, viewHead string, fetchers primaryDiffFetchers) (diffResult, error) {
    diff, err := fetchers.diff(pr)
    if err != nil { return diffResult{}, err }
    pull, err := fetchers.pull(pr)
    if err != nil { return diffResult{}, err }
    _, head, err := parsePullHeads(pull)
    if err != nil { return diffResult{}, err }
    if head != viewHead {
        return diffResult{}, fmt.Errorf("evidence: pr head moved during gather: view %s, pulls %s", viewHead, head)
    }
    return diffResult{Diff: string(diff), Head: head}, nil
}
  • Correct ordering: diff → pull re-read → compare → refuse.
  • Zero value diffResult{} returned on mismatch — no partial evidence escapes.
  • Error message includes both SHAs, matching the fallback path's message exactly (good for log pattern matching).

Minor observation — SHA format validation is absent here but present in localDiff.

localDiff (localdiff.go:68-70) applies reSHA.MatchString after parsePullHeads before the head flows into git argv:

if !reSHA.MatchString(base) || !reSHA.MatchString(head) {
    return diffResult{}, fmt.Errorf("evidence: non-hex commit id from api ...")
}

fetchPrimaryDiff skips this. The rationale is clear: the SHA here flows into diffResult.Head (an artifact string), not into git or a URL, so injection is not a concern. The omission is intentional, not a gap. Worth a brief comment on fetchPrimaryDiff explaining why — otherwise a future reader may pattern-match against localDiff and add it unnecessarily, or flag it as a missing check.

GatherFrombody.Head now populated on the primary path (line 180)

Previously body.Head was only populated by the fallback. Now both paths set it from the verified re-read head. This is a correct provenance improvement — the recorded evidence now declares which head the diff was actually taken against.


diff_test.go — Test quality

Both tests are well-constructed.

TestFetchPrimaryDiffRefusesMovedHead — the sequencing assertion is the best part:

diffRead := false
diff: func(PRRef) (json.RawMessage, error) {
    diffRead = true
    return []byte("diff for the moved head"), nil
},
pull: func(PRRef) (json.RawMessage, error) {
    if !diffRead {
        t.Fatal("pull head was read before the diff")
    }
    return pullHeads(..., moved), nil
},

This doesn't just test the outcome — it pins the sequencing invariant (pull is always called after diff). If the implementation reordered the reads, the test would catch it. Good.

Zero-value check on mismatch:

if result != (diffResult{}) {
    t.Fatalf("moved head returned partial evidence: %+v", result)
}

This ensures no bytes from the wrong head leak out of a refused call. ✅

Minor nit — pullHeads uses string concatenation:

func pullHeads(base, head string) json.RawMessage {
    return []byte(`{"base":{"sha":"` + base + `"},"head":{"sha":"` + head + `"}}`)
}

Fine for the fixed hex-string inputs used in these tests, but fragile if the helper is reused with arbitrary strings (quote injection). Not a defect in the current test, just flagging that the helper isn't general-purpose.


FOLLOWUPS.md

The landed description accurately matches the code:

after a successful gh pr diff, the primary path re-reads pulls/<n> and refuses unless head.sha == view.headRefOid; the recorded evidence now carries the verified head. A deterministic moved-head mutant proves the mismatched diff never becomes recordable evidence. The fallback path already had this property.

TestFetchPrimaryDiffRefusesMovedHead is exactly that deterministic mutant. ✅


Verdict

No blocking issues. The core security property is correct and tested. One optional improvement: a short comment on fetchPrimaryDiff (or its doc comment) explaining why the SHA hex validation present in localDiff is intentionally absent here — the reason is sound (different data flow), it just isn't stated.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 486812a8ef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +243 to +246
if head != viewHead {
return diffResult{}, fmt.Errorf("evidence: pr head moved during gather: view %s, pulls %s", viewHead, head)
}
return diffResult{Diff: string(diff), Head: head}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind the diff fetch to an immutable commit pair

When a writer can force-push twice during this gather, the post-read still accepts bytes from the wrong head: the view sees A, the writer pushes B, gh pr diff returns B's diff, the writer restores A, and this check sees A and stamps B's bytes as evidence for A. I checked gh 2.96.0's gh pr diff --help; its positional target is <number> | <url> | <branch>, not a commit SHA, so the first response has no immutable binding to verify here. Gate can therefore authorize A using evidence from B despite its exact-head invariant; fetch the diff through a commit-pinned compare or otherwise bind the returned bytes to the requested SHA.

AGENTS.md reference: AGENTS.md:L160-L161

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9c19941: the primary path now fetches the diff from the SHA-pinned compare/... endpoint after validating the pull pair against the viewed head. Added a deterministic A→B→A mutant and verified the compare bytes match gh pr diff byte-for-byte on this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The head-pinning behavior is implemented with a clear refusal path and is covered by focused tests for both the success and moved-head mutant scenarios.

Pull request overview

This PR tightens Gate’s evidence gathering so the primary gh pr diff output is cryptographically/forensically bound to the exact PR head already captured by the run’s view evidence, preventing an in-flight head move from contaminating the authorization record with bytes from a different head.

Changes:

  • Reworks the primary diff collection path to re-read pulls/<n> after gh pr diff and refuse if head.sha mismatches the viewed headRefOid, while recording the verified head on accepted evidence.
  • Adds targeted unit tests covering both the “pinned head” success case and the deterministic moved-head mutant refusal.
  • Marks the corresponding Gate follow-up as completed in cmd/gate/docs/FOLLOWUPS.md.
File summaries
File Description
cmd/gate/internal/evidence/evidence.go Pins primary diff evidence to the viewed PR head by re-reading the pull after diff fetch and refusing mismatches before recording evidence.
cmd/gate/internal/evidence/diff_test.go Adds regression tests ensuring mismatched-head diffs cannot become recordable evidence and that pull re-read occurs after diff success.
cmd/gate/docs/FOLLOWUPS.md Closes the documented follow-up for pinning the primary diff path to the evaluated head.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 486812a8ef6c8fe98f4acd94cb033aad7e8948b3

@itsHabib

itsHabib commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

itsHabib commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@itsHabib
itsHabib requested a lite review from Copilot September 4, 2026 09:16
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 2m 10s —— View job


Code Review — PR #281 (re-review at 9c19941)

  • Gather context
  • Read changed files at HEAD
  • Analyze the A→B→A fix (Codex P1 addressed in 9c19941)
  • Post review

Reviewed head: 9c19941. This re-review covers the second commit only — the first (486812a) was already reviewed. The Codex P1 finding (A→B→A double force-push) is the one thing to evaluate here.

The Codex P1 finding

The first commit's fetchPrimaryDiff ordered operations as: diff → pull re-read → compare → refuse. That still had a race:

  1. View sees A
  2. Attacker pushes B
  3. gh pr diff <n> (mutable) returns B's bytes
  4. Attacker restores A
  5. Post-read sees A → matches view → B's bytes are stamped as evidence for A

The second commit rewrites the approach entirely.

The fix: read first, fetch immutably

New ordering in fetchPrimaryDiff (evidence.go:232–254):

pull(<n>) → parse base/head SHAs → validate hex → head == viewHead? → compare(<base>...<head>)

Key properties:

  • Pull is read first, extracting the commit pair as fixed SHA strings before any diff network call.
  • The head check precedes the compare fetch — if the head has moved, the compare call never runs. This is also fast-fail: no wasted network round-trip.
  • The compare URL contains literal SHA strings (repos/{repo}/compare/{base}...{head}), not any mutable PR-number reference. GitHub's compare endpoint returns the diff for those exact commits regardless of what the live HEAD is at request time. The A→B→A race is structurally closed: B is pushed during the compare request, but the URL already names A, so GitHub returns A's diff.

The SHA hex validation now appears before the head check (evidence.go:241–243), which is better than the previous version (which had it only in localDiff). Both paths are now consistent.

Test coverage

Three tests in diff_test.go together prove the threat model:

TestFetchPrimaryDiffPinsViewedHead (happy path): The compare function receives the exact base/head pair parsed from the pull response, and the result contains the correct diff content and verified head. ✅

TestFetchPrimaryDiffRefusesMovedHead (moved-head mutant): The pull response reports a different head than the viewed one. compareCalled remains false — the compare fetch never runs. Zero-value evidence is returned. ✅

TestFetchPrimaryDiffPinsABARaceToCommitPair (A→B→A mutant): liveHead is viewed (A) at pull-read time. Inside the compare closure, liveHead is set to moved (B) and then deferred back to A. The test asserts gotHead == viewed — the compare function receives A's immutable SHA string, not the momentarily-live B. The result contains A's diff and A's head. ✅

This is an accurate model of the real system: gh api repos/{repo}/compare/{base}...{sha-of-A} returns A's content regardless of the live head. The author also verified byte-identity between gh pr diff and the SHA-pinned compare output on this PR.

GatherFrom integration (evidence.go:172–181)

The var r diffResult shadowing issue that existed in the first commit is gone. The new code correctly declares r in the outer scope and reassigns it in the fallback branch without shadowing:

r, err := primaryDiff(pr, viewed.HeadRefOid)
if tooLarge(err) {
    r, err = fallbackDiff(pr, view)
    body.Method = "local-merge-base"
}

body.MergeBase is empty for the primary path (the ... operator in the compare URL encodes the merge-base implicitly), written with omitempty — correct, the fallback explicitly computes and records it.

FOLLOWUPS.md

The update accurately describes what landed: SHA-pinned compare, both mutants tested, fallback property unchanged. ✅

Verdict

No blocking issues. The Codex P1 is fully addressed. The fix closes the A→B→A race structurally by replacing a mutable PR-number diff fetch with a SHA-pinned commit-pair compare, validates both SHAs before use, checks the head before any diff network call, and covers all three threat scenarios with deterministic tests.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 9c19941d9a7f6a195116f05b31b934ae92ae052a

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new api diff evidence path does not record merge_base, contradicting the stated provenance/audit guarantees and reducing reconstructability from state alone.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +251 to +255
if len(diff) == 0 {
return diffResult{}, fmt.Errorf("evidence: empty diff at head %s", head)
}
return diffResult{Diff: string(diff), Head: head}, nil
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c19941d9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +222 to +223
return gh("api", "-H", "Accept: application/vnd.github.v3.diff",
fmt.Sprintf("repos/%s/compare/%s...%s", pr.Repo, base, head))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid the compare endpoint's 300-file truncation

For a PR changing more than 300 files but remaining below the old 20,000-line limit, this endpoint can return a successful, nonempty response while omitting later files: GitHub's Compare two commits documentation limits the comparison's changed-file output to 300, and the repository's own cmd/gate/docs/features/evidence-local-diff/spec.md:27-28 already notes that truncation. The len(diff) != 0 check therefore accepts partial evidence, allowing the floor and model verifiers to authorize a head without seeing changes after the cap; use the commit-pinned local diff path or otherwise prove completeness before recording this response.

AGENTS.md reference: AGENTS.md:L160-L161

Useful? React with 👍 / 👎.

if len(diff) == 0 {
return diffResult{}, fmt.Errorf("evidence: empty diff at head %s", head)
}
return diffResult{Diff: string(diff), Head: head}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the base side of the immutable compare

When the base branch advances after gathering, this result records only Head, leaving MergeBase empty and discarding the base SHA that selected the returned bytes. Consequently the artifact cannot identify or reproduce the supposedly immutable (base, head) comparison from state alone, so an audit cannot establish which effective PR diff was judged; persist the compared base SHA or resolve and store its merge base alongside the head.

AGENTS.md reference: cmd/gate/AGENTS.md:L3-L6

Useful? React with 👍 / 👎.

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.

2 participants