Repository navigation
Merge, update-branch, workflow runs and review reads, behind opt-in allow-lists - #11
antra-tess wants to merge 2 commits into
Conversation
…llow-lists Two new feature sets and one read tool, so an agent can carry a pull request from review to merge without a human running gh for it: - github.merge: merge_pull_request (merge/squash/rebase at the head sha the caller read; optional delete_branch) and update_branch. Merges only into the bases GITHUB_MERGE_BASE_ALLOW names, empty by default, so granting the feature set alone merges nothing. Draft, closed, merged and moved-head pull requests are refused before GitHub is asked; delete_branch never touches a fork's branch or a base branch. - github.actions: list_workflow_runs, dispatch_workflow, rerun_workflow_run. Dispatch and re-run act only on workflow files GITHUB_WORKFLOW_ALLOW names (empty by default), and a dispatch runs only on a base branch. Built for re-requesting a review from a workflow that skips comments from bots. - list_reviews (github.read): every submitted review, paged to the end. app-manifest.json now asks for contents: write: GitHub refuses resolveReviewThread to an App without it, which is why resolve_thread failed with "Resource not accessible by integration". README's Security section lists every permission by the feature set that needs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change may not belong in the project.
Reviewed commit 40e6402 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 does not fit the project. Most of this PR adds merging, branch updates, branch deletion and workflow dispatch, all behind opt-in flags. The README calls itself a connector with "No merge, ever.
6 reasons with sources
Not in v1, not behind a flag", so a change this size should come only after the maintainers decide to drop that commitment. The read-only parts (list_reviews, list_workflow_runs) would fit as a separate PR.
- The README rules out merging, even behind a flag, and this PR adds a merge tool gated by an env var. The base README says: "No merge, ever. Not in v1, not behind a flag." The PR adds
merge_pull_requestin a newgithub.mergefeature set, enabled byGITHUB_MERGE_BASE_ALLOW. That is a merge behind a flag. The PR also rewrites this bullet to "Merge only where the operator said so", so it changes the project's goal to match itself. (README.md (intro bullets, line 21)) - The Security and Limits sections promise no merge, no branch deletion and no release-branch writes;
merge_pull_request,delete_branchandupdate_branchbreak all three. The base text says "No merge. No tool merges, closes a release branch or deletes a branch" and "No merge, no branch deletion, no release-branch write." The "Feature sets" notes say: "github.prhas no merge tool and no branch deletion. There is nothing to disable; the capability does not exist." The project treats the missing capability as part of its security bound. The review findings back this up:update_branchwrites to a branch with no allow-list check, anddelete_branchcan delete a release branch. (README.md (Security, line 425; Limits in v1, line 450)) - The App's permissions grow beyond the minimum the README promises (
contents: read, "never write", noworkflows). The base README listscontents: read (file lists and refs; never write)and "Noadministration, noworkflows". The PR changesapp-manifest.jsontocontents: writeand documentsactions: writeandworkflows: writefor the new sets. The README also says that what limits a leaked key is "the permissions below, no merge". This PR widens that limit for every deployment, because the manifest change applies even where merging stays off. (README.md (Security, "The permissions are the minimum the tools need")) - Dispatching and re-running workflows lets the agent trigger CI. The README describes its tools as acting on issues, PRs, comments and reviews, and nothing in it plans this. The connector's stated surface is issues, pull requests, comments, reviews and check results as events, plus tools in the read/comments/review/issues/pr sets. The README's roadmap note mentions only RFC-005 references for logs and diffs. Nothing mentions triggering Actions. (README.md (intro and Feature sets table))
list_reviewsandlist_workflow_runsare reads and fit the existinggithub.readscope, but they are bundled with the scope change.github.readalready hasget_check_runs,list_review_threadsand similar read tools. A review-list read belongs there. These reads, and theresolve_threadpermission problem, should be proposed separately from the merge and actions write sets. (README.md (Feature sets table,github.read))- The new modules follow the project's layout:
tools-*.tsper feature set, octokit only in the adapter, allow-lists validated likeGITHUB_PR_BASE_ALLOW, and changelog fragments. Where the code goes isn't the problem. Whether the project should offer these capabilities at all is a maintainer decision, because the README currently says no. (README.md (Development file table); changelog.d/README.md)
Findings
4 findings of medium or high severity, see the inline comments:
- Medium severity. update_branch writes to a branch with no allow-list or state check.
- Medium severity. delete_branch protects only allow-listed bases, so a back-merge can delete a release branch.
- Medium severity. An abbreviated or upper-case sha is refused as 'moved'.
- Medium severity. listReviews drops the newest reviews when the page bound is hit.
4 more findings, less severe
- Low severity, test coverage,
test/toolsMerge.test.ts:124-129. No test for a head that moves between the read and GitHub's merge (409 path). The tool always passespr.head.shatomergePullRequest, and the fake throws 409 only when that sha differs from its stored head. The race that PR text and code comments rely on, where the head moves aftergetPullRequestbut before the merge, is therefore never exercised. No test checks that the 409 comes back as an error result and that no branch is deleted in that case. - Low severity, documentation,
README.md:494-496. 'No push … no tool writes a commit' is untrue with update_branch and merge.update_branchmakes GitHub write a merge commit onto the head branch, andmerge_pull_requestwrites a merge or squash commit onto the base. The Limits bullet says no tool writes a commit. app-manifest.json's description ('never pushes') overstates the same point. Reword both to say that commits are written only through GitHub's PR merge and update-branch operations. - Low severity, documentation,
src/types.ts:1368-1374. GITHUB_WORKFLOW_ALLOW errors talk about branches.readBaseAllowis reused for GITHUB_WORKFLOW_ALLOW, so a malformed scoped entry such asowner/name:is reported as '"…" is neither a branch nor owner/name:branch'. That message is misleading for a list of workflow files. Pass a noun or a message to readBaseAllow, or validate workflow entries separately. The doc comments ofallowedBasesandreadBaseAllowalso describe only base branches, though they now serve workflow files too. - Low severity, test coverage,
test/githubAdapter.test.ts:1029-1065. No test for the listReviews page bound or an exactly-full last page. The paging test covers one full page followed by a short page. Two paths have no test: reaching REVIEW_MAX_PAGES, where the warning is logged and later reviews are silently omitted, and a final page of exactly 100 rows, which costs an extra request. The bound is the case where the tool's 'newest verdict' promise breaks, so it should be pinned by a test.
…ewest reviews - update_branch writes a merge commit, so it now takes a merge's fences: an open pull request into a GITHUB_MERGE_BASE_ALLOW base, and a head that is not itself a base branch. Granting github.merge alone writes nothing. - delete_branch also asks GitHub: a protected branch, the default branch, or the base of another open pull request is kept, so a back-merge release -> dev never deletes a release branch left out of the allow-lists. - The sha guard accepts a 7+ hex-digit prefix in any case (the tools print 12-character shas) and sends the full head sha; a malformed sha is an argument error, not "moved". - list_reviews reads GraphQL reviews(last: 50) with totalCount, so a long history drops its oldest reviews, never the newest verdict. - Test for the 409 race (no branch deletion after it); the workflow allow-list's errors name workflow files, not branches; README and the manifest no longer claim no tool writes a commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change may not belong in the project, and 1 high severity finding should be fixed in any case.
Reviewed commit 9127a61 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 does not fit the project. The README's headline promise is "No merge, ever. Not in v1, not behind a flag." Most of this PR adds exactly that: merge behind a flag, branch deletion, branch updates, workflow dispatch and contents: write, and it rewrites the project's security promises to allow them.
6 reasons with sources
The read-only additions (list_reviews, list_workflow_runs) would fit if proposed on their own.
- The project explicitly rules out merging, even behind a flag, and this PR adds a merge tool behind a flag. Base README line 21: "No merge, ever. Not in v1, not behind a flag." Lines 347-348: "
github.prhas no merge tool and no branch deletion. There is nothing to disable; the capability does not exist." The PR addsmerge_pull_requestin a newgithub.mergeset, gated byGITHUB_MERGE_BASE_ALLOW, and replaces that bullet with "Merge only where the operator said so." (README.md (intro bullets; Feature sets and tools notes)) - Branch deletion and writes to branches are listed as v1 limits, and the PR adds both. Base Security says "No tool merges, closes a release branch or deletes a branch." Limits says "No merge, no branch deletion, no release-branch write." The PR adds
delete_branchon merge andupdate_branch, which writes a merge commit onto a PR head. The code review found thatupdate_branchcan write onto a release branch that no allow-list names, so even the PR's narrower promise is not kept. (README.md (Security; Limits in v1)) - The App's permissions are documented as a deliberate minimum, and the PR widens them. Base text: "
contents: read(file lists and refs; never write)" and "Noadministration, noworkflows". The PR changes the manifest tocontents: writefor every deployment and documentsworkflows: writeandactions: writefor the new sets. The README treats this permission set as what bounds a leaked key ("the repository allow-list, the permissions below, no merge"), so widening it is a policy change the maintainers have to choose, not something a feature PR should decide. (README.md (Security, permissions bullet); app-manifest.json) - The new "opt-in twice" security claim does not hold for plain-MCP mode, which the README documents. The base README says a plain-MCP client "sees every tool, because there is no grant on that connection to narrow it by." So in that mode no host grant gates
github.mergeorgithub.actions; only the environment allow-lists do. The code review flags the same gap at README 454-459. (README.md (Plain-MCP mode; Feature sets and tools)) - The new code is placed where the existing layout puts it: new
tools-*.tsmodules, feature-set declarations, and REST calls in the adapter.tools-merge.tsandtools-actions.tssit beside the othertools-*.tsfiles.feature-sets.tsdeclares the sets, and octokit use stays ingithub-adapter.ts, matching the table. The objection is to scope, not placement. (README.md (Development file table)) - The read additions and the resolve_thread permission fix are separate pieces of work and should be proposed on their own.
list_reviewsandlist_workflow_runsare reads that fit thegithub.readrole (reviews and check results already reach the agent). Theresolve_threadpermission fix makes an existing tool work, but it needscontents: write, which the README says the App never holds. A maintainer should decide that separately, apart from merge and actions. (README.md (Feature sets and tools table,github.read))
Findings
6 findings of medium or high severity, see the inline comments:
- High severity. update_branch can write a merge commit onto a release branch that is in no allow-list.
- Medium severity. update_branch writes to a fork's branch, which delete_branch refuses to touch.
- Medium severity. rerun_workflow_run skips the reviewed-branch fence that dispatch_workflow enforces.
- Medium severity. A head branch GitHub already auto-deleted is reported as 'head branch not deleted'.
- Medium severity, documentation. 'Opt-in twice' is false in plain-MCP mode, where merge and actions tools need no grant.
- Medium severity, test coverage. No test for update_branch on a back-merge head kept out of the allow-lists, or on a fork head.
| 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 ` + | ||
| `(GITHUB_PR_BASE_ALLOW / GITHUB_MERGE_BASE_ALLOW); update_branch never writes to one`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
High severity. update_branch can write a merge commit onto a release branch that is in no allow-list.
update_branch refuses a head branch only when baseBranches (the PR's base plus the GITHUB_PR_BASE_ALLOW / GITHUB_MERGE_BASE_ALLOW entries) contains it. It never asks GitHub, unlike the delete_branch path, which calls branchFacts and deletionBlockedOnGitHub. The file's own comment (lines 153-157) describes the case this misses: a release branch kept out of every allow-list so that nothing here can target it is still a base, and a back-merge PR release → dev must not touch it. With mergeBaseAllow=['dev'], update_branch on that PR passes every fence and asks GitHub to merge dev into release, writing a commit onto the release branch unless branch protection happens to block it. Apply the same GitHub-side checks here (protected, default branch, base of another open PR).
| case TOOL_UPDATE_BRANCH: { | ||
| const pull = resolveThread(args, ctx, 'pull'); | ||
| const expected = optionalString(args, 'sha'); | ||
| // A merge commit onto the head branch is a write, so it takes a merge's | ||
| // fences: an open pull request into an allowed base, and a head that is | ||
| // not itself a base (on `dev → main`, this would merge main into dev). | ||
| const pr = await ctx.api.getPullRequest(pull); | ||
| const blocked = notMergeable(pr, 'updating'); | ||
| if (blocked) refuse(blocked); | ||
| requireMergeBase(pr.base.ref, pull.repo, ctx); | ||
| 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 ` + | ||
| `(GITHUB_PR_BASE_ALLOW / GITHUB_MERGE_BASE_ALLOW); update_branch never writes to one`, | ||
| ); | ||
| } | ||
| if (expected !== undefined) requireHeadSha(expected, pr); | ||
| await ctx.api.updatePullRequestBranch(pull, pr.head.sha); |
There was a problem hiding this comment.
Medium severity. update_branch writes to a fork's branch, which delete_branch refuses to touch.
branchDeletionBlocked refuses a head branch whose headRepo is not the base repository, because "a fork's branch is its owner's". update_branch has no such check. On a fork PR that allows maintainer edits, it asks GitHub to push a merge commit onto the contributor's fork branch. The head-is-a-base name comparison is also meaningless for a fork: a fork branch named main is refused, while any other fork branch is written to. Check sameRepo(pr.headRepo, pr.repo) here the same way the deletion path does, or document that fork branches get written to on purpose.
| case TOOL_RERUN_WORKFLOW_RUN: { | ||
| const repo = resolveRepo(args, ctx); | ||
| const runId = requireNumber(args, 'run_id'); | ||
| const failedOnly = optionalBoolean(args, 'failed_only') ?? false; | ||
| // The run is read first: the allow-list is on workflow files, and only | ||
| // the run knows which file it came from. | ||
| const run = await ctx.api.getWorkflowRun(repo, runId); | ||
| requireAllowedWorkflow(workflowFile(run.path), repo, ctx); | ||
| await ctx.api.rerunWorkflowRun(repo, runId, failedOnly); |
There was a problem hiding this comment.
Medium severity. rerun_workflow_run skips the reviewed-branch fence that dispatch_workflow enforces.
The header says a dispatch may run only on a base branch so that "the copy of the workflow that runs is one that was reviewed and merged, not whatever a feature branch says". rerun_workflow_run checks only that the basename of run.path is in GITHUB_WORKFLOW_ALLOW. It never checks run.headBranch. A run of review.yml triggered by pull_request or push on a feature branch (or a fork PR) runs that branch's own copy of the workflow, so re-running it executes unreviewed workflow code with the repository's secrets. That is the exposure the dispatch fence exists to prevent. Either require the run's head branch to be an allowed base or document the exception. No test covers a re-run of a run on a non-base branch.
| if (deleteBranch) { | ||
| // The merge has happened whatever this does next, so a failed check | ||
| // or deletion is reported beside it rather than turning a successful | ||
| // merge into an error result. A repository that deletes merged | ||
| // branches itself answers 404 or 422 here, which is the same end state. | ||
| const why = branchDeletionBlocked(pr, ctx); | ||
| if (why) { | ||
| out.push(`head branch kept: ${why}`); | ||
| } else { | ||
| try { | ||
| const onGitHub = deletionBlockedOnGitHub(pr.head.ref, await ctx.api.branchFacts(pull.repo, pr.head.ref)); | ||
| if (onGitHub) { | ||
| out.push(`head branch kept: ${onGitHub}`); | ||
| } else { | ||
| await ctx.api.deleteBranch(pull.repo, pr.head.ref); | ||
| out.push(`deleted branch ${field(pr.head.ref)}`); | ||
| } | ||
| } catch (err) { | ||
| out.push(`head branch not deleted: ${describeFailure('deleteBranch', err)}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Medium severity. A head branch GitHub already auto-deleted is reported as 'head branch not deleted'.
When the repository has "Automatically delete head branches" on, GitHub deletes the head branch as soon as the merge lands. branchFacts then gets a 404 from GET /branches/{branch}, or deleteBranch gets a 404/422, and the catch pushes head branch not deleted: deleteBranch failed: …. The comment on lines 249-252 says this is "the same end state", but the agent is told the branch was not deleted when it is gone. It may then retry or report a failure to the user. Treat 404/422 (branch missing) as branch already deleted. The only error-path test (toolsMerge.test.ts lines 240-246) asserts the misleading wording, and nothing tests a failure from branchFacts.
| - **Merge and workflows are opt-in twice.** A host has to grant | ||
| `github.merge` or `github.actions`, *and* the operator has to name the | ||
| bases (`GITHUB_MERGE_BASE_ALLOW`) or the workflow files | ||
| (`GITHUB_WORKFLOW_ALLOW`) they may touch. Either alone does nothing. Keep | ||
| release branches out of both, and protect them on GitHub as well: branch | ||
| protection is the fence that holds even if the key leaks. |
There was a problem hiding this comment.
Medium severity, documentation. 'Opt-in twice' is false in plain-MCP mode, where merge and actions tools need no grant.
The Security section says a host has to grant github.merge or github.actions and the operator has to set the allow-list ("Either alone does nothing"). But GitHubMcplServer.listedTools (server.ts lines 633-640) offers the whole surface to a plain-MCP client, with no grant to narrow it. So once GITHUB_MERGE_BASE_ALLOW or GITHUB_WORKFLOW_ALLOW is set, every plain-MCP client can merge or dispatch without any host grant. Operators relying on the stated second fence would be surprised. Either gate these feature sets in plain MCP or correct the documentation.
| test('update_branch takes a merge’s fences: allowed base, open pull request, head not a base', async () => { | ||
| const cases: Array<[Record<string, unknown>, Partial<ToolContext>, RegExp]> = [ | ||
| // GITHUB_MERGE_BASE_ALLOW unset: granting github.merge alone writes nothing. | ||
| [{}, { mergeBaseAllow: [] }, /base branch "dev" is not in GITHUB_MERGE_BASE_ALLOW/], | ||
| [{ state: 'closed' }, {}, /#8 is closed; reopen it before updating/], | ||
| [{ state: 'closed', merged: true }, {}, /#8 is already merged/], | ||
| // `main → dev` would merge dev into main. | ||
| [{ head: { ref: 'main', sha: HEAD } }, { prBaseAllow: ['dev', 'main'] }, /the head of anima-research\/rayforce#8, "main", is a base branch here/], | ||
| ]; | ||
| for (const [partial, over, expected] of cases) { | ||
| const api = new FakeGitHubApi(); | ||
| api.seedPullRequest({ number: 8, headRepo: TEST_REPO, head: { ref: 'feature-8', sha: HEAD }, ...partial }); | ||
| const out = failure(await mergeToolModule.call('update_branch', { number: 8 }, ctx(api, over))); | ||
| assert.match(out, expected); | ||
| assert.equal(api.callsTo('updatePullRequestBranch').length, 0); | ||
| } | ||
| // A draft may be brought up to date; it just may not be merged. | ||
| const api = new FakeGitHubApi(); | ||
| api.seedPullRequest({ number: 8, draft: true, head: { ref: 'feature-8', sha: HEAD } }); | ||
| ok(await mergeToolModule.call('update_branch', { number: 8 }, ctx(api))); | ||
| }); |
There was a problem hiding this comment.
Medium severity, test coverage. No test for update_branch on a back-merge head kept out of the allow-lists, or on a fork head.
The update_branch fence tests cover only a head named in an allow-list (main with prBaseAllow ['dev','main']). Nothing exercises a head like release that is protected or the default branch on GitHub but in no allow-list. Nothing exercises a head in a fork. Those are exactly the cases where update_branch writes to a branch it should not, so the fences can regress unnoticed.
|
@antra-tess #14 is a pull request into this branch for the high finding and the fork finding in the review of Written by Ogden-2688 (Claude Opus 5.5). — Lottie-2661 |
Retain both ThreadKind from contributor delivery (anima-research#6/anima-research#22) and SubmittedReviewState from review reads in the sole import conflict. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
|
We ran #11 together with #14 under a user token (contributor mode from #6) and it works without code changes: every new tool uses the token path, no App endpoint is called, and permission failures come back as clean errors. Tests and two doc/config notes for that mode are in #23 (stacked). One suggestion for #11 itself, independent of contributor mode: |
Why
An agent driving a pull request through review rounds still needed a human (or
gh) for the last steps: resolving the review threads it had answered, merging once the review was clear, and re-requesting a review from a workflow that skips/reviewcomments posted by bots (theirauthor_associationis not OWNER/MEMBER/COLLABORATOR).resolve_threadalso never worked on a live App: GitHub refusesresolveReviewThreadto an App withoutcontents: write, and the manifest asked forcontents: read.What
github.merge(new feature set)merge_pull_request— merge, squash or rebase; passsha(the head you reviewed) and a moved head is refused before GitHub is asked (GitHub enforces the same sha again with a 409). Drafts, closed and already-merged pull requests are refused by name.delete_branch: truedeletes the merged head branch only when it lives in the base repository and is not a base branch any allow-list names; a failed deletion is reported beside the merge, not as an error.update_branch— GitHub's "Update branch", with an optional expected head sha.GITHUB_MERGE_BASE_ALLOWnames. Empty by default: a deployment that never set it merges nothing, whatever a host grants. Branch protection stays GitHub's to enforce.github.actions(new feature set)list_workflow_runs— by pull request (its head sha), sha, branch, workflow file or event. A read; not fenced.dispatch_workflow— only workflow filesGITHUB_WORKFLOW_ALLOWnames (empty by default), and only on a base branch, so the copy of the workflow that runs is one that was reviewed and merged. E.g.review.ymlwith{"pr-number": "12"}.rerun_workflow_run— all jobs or only the failed ones; the run's workflow file must be in the same allow-list.list_reviews(github.read) — every submitted review, paged to the end (GitHub's first page of 30 is passed quickly by bot review rounds, and the newest verdict is on the last page).Permissions.
app-manifest.jsonnow asks forcontents: write. README's Security section has a table of every permission and the feature set that needs it;actions: writeandworkflows: write(merging a PR that touches.github/workflows/) are added by hand only when the opt-in sets are enabled.docs/deploy.mdnow says plainly what a leaked key can do withcontents: write, and that branch protection is the fence that holds.Both env vars use
GITHUB_PR_BASE_ALLOW's format (branchorowner/name:branch), validated at startup the same way. The example recipes list their feature sets explicitly, so they do not pick up the new ones.Tests
npm test: 640 pass (34 new) — the tool modules againstFakeGitHubApi(every refusal above), the adapter against the local fake GitHub (REST paths, bodies, the sha guard, review paging, run endpoints), config parsing, and the updated feature-set counts. Smoke-tested read paths against a live App installation (28 tools listed;list_reviews,list_workflow_runsand the refusals answer as documented). The write paths (merge, update branch, dispatch, re-run, thread resolution) wait on the App's new permissions and will be exercised live before merge.🤖 Generated with Claude Code