Skip to content

fix(gh-wrapper): close GH_REPO, PR URL and GraphQL draft bypasses - #443

Merged
twistedmelonman merged 2 commits into
mainfrom
claude/fix-gh-draft-bypasses-055823
Oct 20, 2026
Merged

twistedmelonman merged 2 commits into
mainfrom
claude/fix-gh-draft-bypasses-055823

Conversation

@twistedmelonman

@twistedmelonman twistedmelonman commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Handles items 1 and 2 of #391. Item 3 lives in claude-config and is not touched here.

Item 1: GH_REPO and PR URLs

_gh_wrapper_resolve_owner ignored GH_REPO and a PR URL argument, so these all missed the off-org checks:

  • GH_REPO=o/r gh pr ready 5
  • gh pr ready https://github.com/o/r/pull/5
  • GH_REPO=o/r gh pr create (not forced to draft)

The resolver now follows gh's own order: a PR or issue URL argument, then -R, then GH_REPO, then the checkout remote. I measured that order with read-only gh pr view and gh api calls on gh 2.102.0 (-R beat GH_REPO, a URL beat both, and GH_REPO filled {owner}/{repo} for gh api). pr ready and pr create were not run, since both write. They share gh's PR selector and repo override, and gh pr ready --help lists [<number> | <url> | <branch>].

Details:

  • URLs are read only for subcommands that take a selector. For create, new, list and status, a URL is a flag value and must not beat -R. A URL right after a value flag (--body URL) is skipped too, except on pr ready, which has no value flags.
  • A new _gh_wrapper_spec_owner reads HOST/OWNER/REPO, which gh accepts in -R and GH_REPO. Before, -R github.com/o/r resolved to github.com.

Item 2: GraphQL

Schema introspection (__type(name:"Mutation") plus each input type) shows three mutations that open or promote a PR:

  • createPullRequest: opens a PR; draft is optional.
  • revertPullRequest: opens a revert PR; draft is optional.
  • markPullRequestReadyForReview: promotes a draft.

updatePullRequest has no draft field. convertPullRequestToDraft goes the safe way and is left alone.

All three name their target by node ID, so the wrapper cannot prove that the owner is in-org. It refuses them on any gh api call to a GraphQL endpoint (graphql, /graphql, or a full URL), whether the query comes inline, from -F query=@file, or from an --input file. The guard is fail-closed:

  • A query it cannot read (stdin, a missing file) is refused.
  • An --input file containing \u escapes is refused, because an escape can hide a mutation name.

A grep of the fleet found no script that uses these mutations.

Behavior change: gh api graphql --input - and -F query=@- are now refused through the wrapper even for read-only queries.

Tests

bash/tests/test-gh-wrapper-off-org-guards.sh gains GH_REPO, URL, precedence, GraphQL and resolver cases. Against the origin/main wrapper, 54 cases fail and 63 pass. With this change, all 117 pass. All test-gh-wrapper-*.sh files pass, and shellcheck -S info is clean.

Advances #391

Claude Code Bot added 2 commits October 19, 2026 18:28
Two of the three holes in #391. Both let an agent publish a non-draft
PR on an off-org repo through the wrapper.

Item 1: _gh_wrapper_resolve_owner ignored GH_REPO and a PR URL
argument, so `GH_REPO=o/r gh pr ready 5`, `gh pr ready
https://github.com/o/r/pull/5` and `GH_REPO=o/r gh pr create` all
missed the off-org checks. The resolver now follows gh's own order:
a PR or issue URL argument, then -R, then GH_REPO, then the checkout
remote. Measured with read-only `gh pr view` calls on gh 2.102.0.
URLs are read only for selector-taking subcommands; for create, new,
list and status a URL is a flag value and must not beat -R. A new
_gh_wrapper_spec_owner also reads HOST/OWNER/REPO, which gh accepts
in -R and GH_REPO; before, `-R github.com/o/r` resolved to
"github.com".

Item 2: GraphQL introspection shows three mutations that open or
promote a PR: createPullRequest and revertPullRequest (both open a
PR, draft optional) and markPullRequestReadyForReview.
updatePullRequest has no draft field. All three name their target by
node ID, so the wrapper cannot prove the owner is in-org. They are
refused on every `gh api` call to a GraphQL endpoint, from inline
fields, -F @file, or an --input file. A query the wrapper cannot read
(stdin, a missing file) or an --input file with \u escapes is refused
too. No fleet script uses these mutations.

Item 3 (claude-config hook chains) is not touched.

Advances #391
Both commit reviewers flagged the URL scan in _gh_wrapper_resolve_owner:
it took any URL argument, so `gh pr comment 5 -R o/r --body <URL>`
resolved to the URL's owner and could pick the wrong identity, and it
lowercased the owner although the function documents raw case.

A URL that follows a value flag is now that flag's text and is
skipped. Self-contained flags (--x=v, -Rvalue) and a short list of
boolean flags do not hide a URL. pr ready has no value flag, so there
every URL still counts, which keeps the draft refusal fail-closed. The
owner is cut from the original argument, so its case is kept.

Advances #391
@twistedmelonman
twistedmelonman merged commit 32632f8 into main Oct 20, 2026
5 checks passed
@twistedmelonman
twistedmelonman deleted the claude/fix-gh-draft-bypasses-055823 branch October 20, 2026 03:43
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