Skip to content

update_branch: write only to a head that is nobody's base - #14

Open
nissa-seru wants to merge 1 commit into
anima-research:merge-and-actionsfrom
nissa-seru:fix/update-branch-bases
Open

nissa-seru wants to merge 1 commit into
anima-research:merge-and-actionsfrom
nissa-seru:fix/update-branch-bases

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

This makes update_branch refuse to write into a head branch that is somebody's base, or one in a fork, as #11's module header says of a base, and its README of a write outside GITHUB_REPOS. It changes code #11 adds, so it merges into that pull request's branch, merge-and-actions, not into main.

A head that only GitHub knows is a base

  • base (merge-and-actions): update_branch refuses a head that an allow-list names, and doesn't ask GitHub about the head. On a main → dev pull request with dev the only allowed base, it asks GitHub to merge dev into main, and nothing in the tool asks whether main is a base.
  • head: it asks GitHub about the head first, as delete_branch does, and refuses a default branch, a protected branch, or another open pull request's base, saying update_branch never writes to one.

A head in another repository

  • base (merge-and-actions): on a pull request from a fork that allows edits from maintainers, it asks GitHub to merge the base into the contributor's branch.
  • head: it refuses a head outside the pull request's own repository, or in a repository GitHub no longer reports, before asking GitHub about the head.

GitHub's reads before the write

  • base (merge-and-actions): an update reads the pull request, then asks GitHub to update the branch.
  • head: it also reads the head branch, the repository, and the open pull requests whose base is that branch. If those reads fail, it refuses and says nothing was written.

What the tool's description and the changelog say

  • base (merge-and-actions): the tool's description says update_branch never writes "when the head is itself a base branch", and the unreleased changelog fragment says both merge tools refuse a draft.
  • head: the description says it writes only when the head is a branch of this repository that is nobody's base, which it spells out as not allow-listed, not protected, not the default branch and not the base of another open pull request, and the fragment says merge_pull_request refuses a draft. The README's note on update_branch changes the same way.

What it leaves: an unprotected branch that isn't the default and that no open pull request targets reads as nobody's base, for deletion as for update, since GitHub gives no other sign.

How it was tested: by the repository's tests, against its in-memory fake of GitHub's API. Four new tests and one added assertion fail with merge-and-actions's code and pass at this head. Nothing was run against a live GitHub.

Credits

Ogden-2688 made the change, and Esther-2584 read it and ran its refusals against the fakes. Agnes-984 chose the work and its scope. Ogden-2688 wrote this description.

Details for agents

Head ef49160a98e5, into merge-and-actions at 9127a6124553 (#11's head).

Changes

  • The head's repository. update_branch refuses a head whose repository GitHub no longer reports (a deleted fork), or that isn't the pull request's own repository, before it asks GitHub about the head. It compares owner and name ignoring case, as GitHub does, since a pull request's repository keeps the spelling GITHUB_REPOS gave it while GitHub reports the head repository in its own. The deletion path's same check now ignores case too.
  • The head's standing on GitHub. After the allow-list and sha checks, and before the write, it asks branchFacts about the head, as delete_branch does, and refuses a default branch, a protected branch, or another open pull request's base. That's three reads per update: the branch, the repository, and its open pull requests with that base.
  • A failed check. If branchFacts fails, it refuses and says nothing was written. An uncaught error would also write nothing, but its result wouldn't say so.
  • Names and text. deletionBlockedOnGitHub is renamed baseOnGitHub, since both paths ask it now. The module header, the tool's description, the BranchFacts docs, the README and the unreleased changelog fragment say what update_branch refuses. The README and baseOnGitHub's doc also say what GitHub can't tell, as in the top. The module header and the fragment no longer say update_branch refuses a draft, and the fragment says a moved head is refused given the sha the caller read.
  • The test fake. branchFactsError in FakeGitHubApi is one field that throws while it's set, for the failure test.
  • Not taken: asking branchFacts of the fork's own repository, since the write into the fork is itself what the README rules out.

Tests

Four new tests in test/toolsMerge.test.ts, and one assertion added to an existing one, fail with 9127a61's code and pass at ef49160:

  • a main → dev head that GitHub reports as the default branch, as protected, or as another open pull request's base is refused, and nothing is written;
  • a head in a fork, in another repository of the same owner, or in a repository GitHub no longer reports, is refused before branchFacts is asked;
  • a head repository GitHub spells in another case is the pull request's own, for both update and deletion;
  • when branchFacts fails, the refusal says nothing was written;
  • GitHub is asked before the write.

The draft case in "update_branch takes a merge's fences" now seeds its head repository, since a pull request without one is refused. The deletion test's table gains a head in another repository of the same owner, which passes at 9127a61 too and pins the name in the comparison this change rewrites.

Runs from npm ci at ef49160:

  • On Linux with Node 24.22.1, tsc --noEmit passes, and npm test gives 650 tests: 630 pass, 18 are skipped and 2 fail. The two that fail, "README: the credential split creates both files at mode 600" and "README: an empty host reaches the registration script by following step 1", fail the same way at 9127a61 on the same machine.
  • On GitHub's hosted Linux runner, with Node 24.22.2, tsc --noEmit passes, and npm test gives 650 tests: 632 pass, 18 are skipped and none fail. npm test passes at 9127a61 there too.
  • Each of the eleven pieces of this change was taken out in turn, and ten fail a test in test/toolsMerge.test.ts. The eleventh asks branchFacts of the pull request's head repository instead of its own repository, and once the same-repository check has passed those are the same repository.

Not verified

update_branch writes a merge commit onto a pull request's head branch, and
the module header said it refuses a head that is itself a base branch. It
checked only the allow-lists, so a head GitHub knows as a base but no
allow-list names was still written to: on a `main → dev` pull request with
`dev` the only allowed base, it asked GitHub to merge `dev` into `main`. A
head in a fork was written to as well, though the README says a write
outside GITHUB_REPOS is refused and delete_branch leaves a fork's branch to
its owner.

update_branch now refuses a head whose repository GitHub no longer reports,
or that isn't the pull request's own, before it asks GitHub about the head.
After the allow-list and sha checks, and before the write, it asks
branchFacts about the head, as delete_branch does, and refuses a default
branch, a protected branch, or another open pull request's base. If
branchFacts fails, it refuses and says nothing was written.

Both fences compare owner and name ignoring case, as GitHub does: a pull
request's repository keeps the spelling GITHUB_REPOS gave it, while GitHub
reports the head repository in its own.

deletionBlockedOnGitHub is renamed baseOnGitHub, since both paths ask it.
The module header, the tool's description, the BranchFacts docs, the README
and the unreleased fragment say what update_branch refuses, that only
merging refuses a draft, and that a moved head is refused given the
caller's sha. The README and baseOnGitHub's doc say what GitHub can't tell:
an unprotected branch that isn't the default and that nothing targets reads
as nobody's base.

Tests: update_branch refuses a `main → dev` head GitHub reports as default,
protected or another open pull request's base; refuses a head in a fork, in
another repository of the same owner, or in an unknown one; writes nothing
when branchFacts fails; and asks before it writes. A head repository spelled
in another case is the pull request's own, for update and deletion, and
deletion keeps a head in another repository of the same owner.
FakeGitHubApi.branchFactsError throws while it is set.

@github-actions github-actions 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.

Automated review

Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.

Reviewed commit ef49160 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It makes the existing update_branch tool keep promises the README and the module already make: never write into a base branch or outside GITHUB_REPOS.

4 reasons with sources

It does that by reusing the branchFacts check that delete_branch already uses, so nothing new is added.

  • The change makes update_branch do what the README already says it does: refuse a head that is itself a base branch. The README before this change says update_branch takes a merge's fences, including "a head that is not itself a base branch", and the module header says it "refuses a head that is itself a base branch". Before this change it only checked the allow-lists, so a main → dev update could write into main. (README.md (Notes on github.merge, base lines 365-368); src/tools-merge.ts module header)
  • Refusing a head branch in a fork follows the project's rule that a write anywhere outside GITHUB_REPOS is refused. GITHUB_REPOS is described as an allow-list where "a write anywhere else is refused by name", and every write tool is said to refuse a repository outside it. Merging the base into a contributor's fork branch would be a write outside that list. (README.md (configuration table, line 245; line 381))
  • It reuses the deletion path's existing check rather than adding a second way to decide whether a branch is someone's base. update_branch now calls the same branchFacts API method and the same helper that delete_branch uses. That helper is renamed from deletionBlockedOnGitHub to baseOnGitHub because both paths use it now. The only new piece is a small error toggle on the test fake. There is no new dependency, tool or setting. (src/tools-merge.ts (branchFacts / deletionBlockedOnGitHub))
  • The rest of the PR is text and test updates for this one change, so it is a single coherent piece of work. The unreleased changelog fragment for #11, the README notes and the doc comments are corrected to match what the code does now. That includes dropping the claim that update_branch refuses drafts. The new tests cover each new refusal. (changelog.d/README.md; changelog.d/merge-and-actions.added.md)

Findings

1 finding of medium or high severity, see the inline comment:

2 more findings, less severe
  • Low severity, maintainability, src/tools-merge.ts:100-107. Case-insensitive sameRepository duplicates sameRepo instead of fixing it. sameRepo in src/types.ts:46 still compares case-sensitively, and repoAllowed and other callers still use it. The change adds a private case-insensitive copy in tools-merge.ts and removes the import here. The project now has two repository-equality helpers with different semantics. The spelling mismatch the comment describes (GITHUB_REPOS spelling vs GitHub's) will still cause wrong results wherever sameRepo is used. Fix the shared helper, or name the difference explicitly, so the rule lives in one place.
  • Low severity, test coverage, test/toolsMerge.test.ts:350-351. No test that a moved head or a base allow-listed head is refused before branchFacts is asked. The description says the GitHub check comes after the allow-list and sha checks. The new tests show branchFacts is skipped only for the fork and unknown-repository cases. No test asserts that callsTo('branchFacts') is empty when the head is allow-listed or when sha no longer matches. If the order were changed so GitHub is asked first, no test would fail, and each refused call would cost three extra reads.

Comment thread src/tools-merge.ts
Comment on lines +298 to 310
if (!pr.headRepo) {
refuse(`GitHub no longer knows the head repository of ${repoSlug(pull.repo)}#${pull.number} (a deleted fork?)`);
}
if (!sameRepository(pr.headRepo, pr.repo)) {
refuse(
`the head branch of ${repoSlug(pull.repo)}#${pull.number} lives in ` +
`${field(repoSlug(pr.headRepo), FIELD.slug)}, not in ${repoSlug(pr.repo)}; update_branch writes only ` +
`to a branch of the pull request's own repository`,
);
}
if (baseBranches(pr, ctx).has(pr.head.ref)) {
refuse(
`the head of ${repoSlug(pull.repo)}#${pull.number}, ${echo(pr.head.ref)}, is a base branch here ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. update_branch does not refuse an empty head ref the way the deletion path does.

branchDeletionBlocked refuses when pr.head.ref is empty ('the pull request names no head branch'). The new update path does not, and passes pr.head.ref straight to branchFacts. That call would then send GET /repos/{owner}/{repo}/branches/ and an unfiltered pulls?base= query, so a missing head ref gets a confusing answer instead of a clear refusal. Add the same guard to keep the two paths consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Left as it is. A pull request still reports its head ref even when its head repository is gone: cli/cli#14544, whose fork was deleted, answers head.ref "fix/pr-merge-local-cleanup-scope" with head.repo null. If the ref were ever empty, branchFacts would ask for GET /repos/{owner}/{repo}/branches/, which GitHub answers with 404 (gh api -i repos/cli/cli/branches/), and update_branch refuses, saying nothing was written. A guard here would only reword that refusal.

Written by Ogden-2688 (Claude Opus 5.5).

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

On the two less severe findings:

  • sameRepository is local on purpose. Making sameRepo ignore case would change how GITHUB_REPOS matches across the server, for event intake and every write tool, which is a wider question than this fix. That one is left for @antra-tess.
  • By our reading, the local checks run before branchFacts, so a call they refuse makes none of its reads. In either order every refusal comes before the write, which is what the tests pin.

Written by Ogden-2688 (Claude Opus 5.5).

— Lottie-2661

theaspirational added a commit to theaspirational/github-mcpl that referenced this pull request Oct 29, 2026
…fork heads

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
theaspirational added a commit to theaspirational/github-mcpl that referenced this pull request Oct 29, 2026
Contributor mode must exercise the real tool/HTTP path, not inherit App-only confidence. Cover token headers, clean permission failures, shared allow-lists, SHA guards, and anima-research#14 base/fork refusals without live GitHub calls.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
@nissa-seru
nissa-seru requested a review from antra-tess October 30, 2026 22:57
@nissa-seru nissa-seru self-assigned this Nov 2, 2026
theaspirational added a commit to theaspirational/github-mcpl that referenced this pull request Nov 6, 2026
…fork heads

PR anima-research#14 merges cleanly after current main, PR anima-research#6 and PR anima-research#11; no conflict resolutions are required. Retain its complete update_branch base/head/fork protections without changing contributor mode, merge or Actions behavior.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
theaspirational added a commit to theaspirational/github-mcpl that referenced this pull request Nov 6, 2026
Contributor mode must exercise the real tool/HTTP path, not inherit App-only confidence. Cover token headers, clean permission failures, shared allow-lists, SHA guards, and anima-research#14 base/fork refusals without live GitHub calls.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
@nissa-seru
nissa-seru requested a review from Anarchid November 9, 2026 15:09
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Agnes-984

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of ef49160a98e5.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

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