Skip to content
Open
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
11 changes: 6 additions & 5 deletions cmd/gate/docs/FOLLOWUPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,17 @@ wiring `gate` into the merge tail.
5 of 7 real PRs park, with no notification, so they park silently. Emit something on park
(stdout/file/console to start) so a parked run surfaces instead of waiting to be noticed.

- [ ] **Pin the primary diff path to the evaluated head.**
- [x] **Pin the primary diff path to the evaluated head.**
Surfaced by the evidence-local-diff skeptic panel (2026-07-16). The oversized-PR fallback
refuses unless `pulls.head == view.headRefOid`, but the primary path (`gh pr diff <n>`) fetches
by PR number with no head pin: a force-push to an innocent head between the view read and the
diff read records that head's diff, and a force-push back before merge still satisfies
`--match-head-commit`. Window is a sub-call race needing push access + green CI on the decoy, but
gate's threat model includes adversarial agents with push access. Fix: after a successful
`gh pr diff`, re-read `pulls/<n>` and refuse unless `head.sha == view.headRefOid` (shrinks the
window to a sub-call race); airtight variant fetches the under-cap diff SHA-pinned via the
`compare` endpoint. The fallback path already has this property.
gate's threat model includes adversarial agents with push access. **Landed:** the primary path
reads `pulls/<n>`, refuses unless `head.sha == view.headRefOid`, then fetches the merge-base diff
through the SHA-pinned `compare/<base>...<head>` endpoint; the recorded evidence carries that
verified head. Deterministic moved-head and A→B→A mutants prove that mismatched or substituted diff
bytes never become recordable evidence. The fallback path already had this property.

- [x] **Refuse to reseal a mismatched anchor as crash recovery.**
Surfaced by codex on the tenant-move review (workbench#59, 2026-07-17); the gate judge blocked
Expand Down
96 changes: 96 additions & 0 deletions cmd/gate/internal/evidence/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package evidence

import (
"encoding/json"
"strings"
"testing"
)

func TestFetchPrimaryDiffPinsViewedHead(t *testing.T) {
const (
base = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
head = "1111111111111111111111111111111111111111"
)
result, err := fetchPrimaryDiff(PRRef{Repo: "o/r", Number: 7}, head, primaryDiffFetchers{
pull: func(PRRef) (json.RawMessage, error) {
return pullHeads(base, head), nil
},
compare: func(_ PRRef, gotBase, gotHead string) (json.RawMessage, error) {
if gotBase != base || gotHead != head {
t.Fatalf("compare pair = %s...%s, want %s...%s", gotBase, gotHead, base, head)
}
return []byte("the diff"), nil
},
})
if err != nil {
t.Fatalf("fetchPrimaryDiff: %v", err)
}
if result.Diff != "the diff" || result.Head != head {
t.Fatalf("result = %+v, want diff and exact viewed head", result)
}
}

// This is the moved-head mutant: the pull read reports a different head than
// the view. No diff fetch may run and no bytes may escape as evidence.
func TestFetchPrimaryDiffRefusesMovedHead(t *testing.T) {
const (
viewed = "1111111111111111111111111111111111111111"
moved = "2222222222222222222222222222222222222222"
)
compareCalled := false
result, err := fetchPrimaryDiff(PRRef{Repo: "o/r", Number: 7}, viewed, primaryDiffFetchers{
pull: func(PRRef) (json.RawMessage, error) {
return pullHeads("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", moved), nil
},
compare: func(PRRef, string, string) (json.RawMessage, error) {
compareCalled = true
return []byte("diff for the moved head"), nil
},
})
if err == nil {
t.Fatalf("moved head returned recordable evidence: %+v", result)
}
if !strings.Contains(err.Error(), "pr head moved during gather") ||
!strings.Contains(err.Error(), viewed) || !strings.Contains(err.Error(), moved) {
t.Fatalf("moved-head refusal lost its evidence: %v", err)
}
if result != (diffResult{}) {
t.Fatalf("moved head returned partial evidence: %+v", result)
}
if compareCalled {
t.Fatal("moved head reached the compare diff fetch")
}
}

// This is the double-force-push mutant from review: the PR begins at A, moves
// to B during the diff read, then returns to A. The compare fetch can still
// receive only A's immutable commit pair.
func TestFetchPrimaryDiffPinsABARaceToCommitPair(t *testing.T) {
const (
base = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
viewed = "1111111111111111111111111111111111111111"
moved = "2222222222222222222222222222222222222222"
)
liveHead := viewed
result, err := fetchPrimaryDiff(PRRef{Repo: "o/r", Number: 7}, viewed, primaryDiffFetchers{
pull: func(PRRef) (json.RawMessage, error) { return pullHeads(base, liveHead), nil },
compare: func(_ PRRef, gotBase, gotHead string) (json.RawMessage, error) {
liveHead = moved
defer func() { liveHead = viewed }()
if gotBase != base || gotHead != viewed {
t.Fatalf("mutable pair reached compare: %s...%s", gotBase, gotHead)
}
return []byte("diff for immutable A"), nil
},
})
if err != nil {
t.Fatalf("fetchPrimaryDiff under A-B-A race: %v", err)
}
if liveHead != viewed || result.Head != viewed || result.Diff != "diff for immutable A" {
t.Fatalf("A-B-A result = %+v, live head %s", result, liveHead)
}
}

func pullHeads(base, head string) json.RawMessage {
return []byte(`{"base":{"sha":"` + base + `"},"head":{"sha":"` + head + `"}}`)
}
65 changes: 54 additions & 11 deletions cmd/gate/internal/evidence/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type diffBody struct {
PR PRRef `json:"pr"`
Diff string `json:"diff"`
// Provenance — reconstructable from state alone which path produced the
// diff and which commits it spans. "api" = GitHub's merge-base diff;
// diff and which commits it spans. "api" = GitHub's SHA-pinned compare diff;
// "local-merge-base" = the oversized-PR fallback.
Method string `json:"method,omitempty"`
Head string `json:"head,omitempty"`
Expand Down Expand Up @@ -115,6 +115,11 @@ type reviewFetchers struct {
panel func(PRRef, string, []rawComment, []Comment) reviewpanel.Evidence
}

type primaryDiffFetchers struct {
pull func(PRRef) (json.RawMessage, error)
compare func(PRRef, string, string) (json.RawMessage, error)
}

// Gather records view, diff, and comments evidence for a PR and returns their ids.
func Gather(st *state.Store, run string, pr PRRef) (Bundle, error) {
viewID, view, err := View(st, run, pr)
Expand Down Expand Up @@ -160,23 +165,20 @@ func GatherFrom(st *state.Store, run string, pr PRRef, viewID string, view json.
return b, fmt.Errorf("evidence: parse PR head: %w", err)
}

// method "api" records only that GitHub served the diff — not head/merge_base:
// gh pr diff reads by PR number and doesn't report which head it rendered, so
// stamping the view's head would claim a span this path never verified. The
// fallback path controls exact SHAs and stamps them.
// The primary path reads the pull's immutable commit pair, checks that its
// head matches the view, then asks GitHub for that pair's compare diff. The
// fallback controls the same exact SHAs locally when GitHub rejects the diff
// as oversized.
body := diffBody{PR: pr, Method: "api"}
diff, err := gh("pr", "diff", fmt.Sprint(pr.Number), "-R", pr.Repo)
r, err := primaryDiff(pr, viewed.HeadRefOid)
if tooLarge(err) {
var r diffResult
r, err = fallbackDiff(pr, view)
body.Diff, body.Method, body.MergeBase, body.Head = r.Diff, "local-merge-base", r.MergeBase, r.Head
body.Method = "local-merge-base"
}
if err != nil {
return b, err
}
if body.Method == "api" {
body.Diff = string(diff)
}
body.Diff, body.MergeBase, body.Head = r.Diff, r.MergeBase, r.Head
a, err := st.Append(state.KindEvidence, run, nil, body)
if err != nil {
return b, err
Expand Down Expand Up @@ -211,6 +213,47 @@ func GatherFrom(st *state.Store, run string, pr PRRef, viewID string, view json.
return b, nil
}

func primaryDiff(pr PRRef, viewHead string) (diffResult, error) {
return fetchPrimaryDiff(pr, viewHead, primaryDiffFetchers{
pull: func(pr PRRef) (json.RawMessage, error) {
return gh("api", fmt.Sprintf("repos/%s/pulls/%d", pr.Repo, pr.Number))
},
compare: func(pr PRRef, base, head string) (json.RawMessage, error) {
return gh("api", "-H", "Accept: application/vnd.github.v3.diff",
fmt.Sprintf("repos/%s/compare/%s...%s", pr.Repo, base, head))
Comment on lines +222 to +223

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 👍 / 👎.

},
})
}

// fetchPrimaryDiff binds the primary diff to an immutable commit pair. The
// pull read must still match the view evidence, and the diff is then fetched by
// those exact base/head SHAs rather than by mutable PR number. An A→B→A
// force-push during the fetch cannot substitute B's bytes for A's evidence.
func fetchPrimaryDiff(pr PRRef, viewHead string, fetchers primaryDiffFetchers) (diffResult, error) {
pull, err := fetchers.pull(pr)
if err != nil {
return diffResult{}, err
}
base, head, err := parsePullHeads(pull)
if err != nil {
return diffResult{}, err
}
if !reSHA.MatchString(base) || !reSHA.MatchString(head) {
return diffResult{}, fmt.Errorf("evidence: non-hex commit id from api (base=%q head=%q)", base, head)
}
if head != viewHead {
return diffResult{}, fmt.Errorf("evidence: pr head moved during gather: view %s, pulls %s", viewHead, head)
}
diff, err := fetchers.compare(pr, base, head)
if err != nil {
return diffResult{}, err
}
if len(diff) == 0 {
return diffResult{}, fmt.Errorf("evidence: empty diff at head %s", head)
}
return diffResult{Diff: string(diff), Head: head}, nil
Comment on lines +244 to +254

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.

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 👍 / 👎.

}
Comment on lines +251 to +255

// decisiveReviewState reports whether a submission state states a position on
// whether the PR may merge. APPROVED and CHANGES_REQUESTED do; COMMENTED does
// NOT — GitHub is unambiguous that commenting after approving does not withdraw
Expand Down
Loading