Scope gh CLI through the git broker instead of disabling it - #5
Merged
Conversation
Previously gh was disabled outright inside the sandbox because its API calls go straight to api.github.com over TLS and can't be repo-scoped by inspecting traffic. That made it impossible to delegate PR/issue workflows to a sandboxed Claude session at all. Now gh is rewired (not disabled): invocations are relayed to the same host-side git-broker used for git HTTPS/SSH, which executes a safelisted subset of subcommands (pr/issue/release/workflow/repo-view) for real, using the host's own gh credentials, after checking the target repo (from --repo/-R or the cwd's origin remote) against the same allow-list already used for git. Everything else -- gh api, gh auth/secret/variable, gh repo clone/fork/create -- stays refused by default. The sandboxed process never sees a token. Found and fixed along the way: net.createServer defaults to allowHalfOpen:false, which was silently discarding gh-exec responses (the async exec-then-respond path lost the race against Node auto-ending the write side once the client's request-then-half-close was seen; the synchronous credential/deny paths never hit this since they respond in the same tick). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX
Found while re-reviewing the gh-broker PR before merge, not yet released: 1. Bundled short flags. gh (pflag/Cobra) bundles short flags into one token: `-wR owner/repo` means `-w -R owner/repo`, same as space- separated. classifyGhInvocation only recognized a bare "-R", so a bundled "-wR" was invisible to it -- it would resolve/check the *cwd's* repo (allowed) while the real `gh` binary, parsing the same argv with its own complete grammar, would actually act on the *bundled flag's* repo (never checked). Fixed by refusing any multi-letter short-dash token that isn't unambiguously "-R" or "-R<value>", rather than trying to reimplement gh's full bundling grammar (the ssh wrapper's argv parser already shows how fragile that path is). 2. Positional URLs. `pr view/checkout/diff/merge/close/edit` etc. accept `<number>|<url>|<branch>`, and `repo view` accepts a bare owner/repo too -- when given, gh resolves the target repo from THAT argument, ignoring --repo/-R and cwd entirely. `gh pr merge <url-to-unrelated- repo>` would previously sail through as "no --repo given, use cwd's (allow-listed) repo" while actually merging a PR in a completely different, unchecked repo -- and this isn't an obscure edge case, it's the ordinary way to act on a pasted PR link. classifyGhInvocation now scans argv for URL-shaped (and, for `repo view`, bare owner/repo- shaped) tokens and requires every one found to be allow-listed too. classifyGhInvocation's return shape changed from a single `repo` to a `repos` array (usually one entry, more when e.g. both --repo and a URL positional are present) since a single invocation can now reference more than one repo that must all check out. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX
The repo had no CI and no unit tests at all -- only a Playwright e2e suite. The broker/allowlist logic added in this PR and #4 is exactly the kind of code that needs regression coverage: it's security-load- bearing, and this review already found real bugs in it by hand (the .gitmodules trust gap in #4, the allowHalfOpen bug and the two argv-shape bypasses in this PR) that tests would have caught for free afterwards. - server/ws/gitAllowlist.test.js: normalizeGitUrl edge cases, and computeGitAllowlist/resolveOriginUrl against real temp git repos (including the checked-out-vs-not submodule trust boundary). - server/ws/ghAllowlist.test.js: classifyGhInvocation's subcommand safelist, --repo/-R resolution, and the two bypasses fixed in the previous commit (bundled short flags, positional URLs). - server/ws/git-broker.test.js: end-to-end over the real Unix socket protocol (real startGitBroker() process, fake `gh` on PATH) -- also the regression test for the allowHalfOpen bug (gh-exec responses are written asynchronously; without allowHalfOpen:true on the broker's net.createServer, Node silently discarded them once the client half-closed, so every gh-exec request returned nothing). Runnable via `npm test` (node's built-in test runner, no new dependency). .github/workflows/ci.yml runs it plus a syntax-check pass over all server/*.js, and a separate job for the existing Playwright e2e suite, on push/PR to master. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX
v4 of both actions still targets Node.js 20, which GitHub Actions now runs under a forced Node 24 shim with a deprecation warning. Silence it by moving to v5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ghentirely inside the sandbox (its API calls hitapi.github.comover TLS and can't be repo-scoped by inspecting traffic), which meant a sandboxed Claude session could never delegate PR/issue workflows -- only plaingit pushto allow-listed repos.ghinstead of disabling it: invocations inside the sandbox are relayed to the same host-side git-broker used for git HTTPS/SSH credentials. The broker executes a safelisted subset of gh subcommands for real, using the host's ownghauth, after checking every repo the invocation could target against the same allow-list already computed for git.pr(create/view/list/edit/comment/merge/close/reopen/ready/review/checks/diff/status/checkout),issue(create/view/list/edit/comment/close/reopen/status),release(create/view/list/edit/delete/upload/download/delete-asset),workflow(run/view/list/enable/disable),repo view.gh api(arbitrary API endpoint, not repo-scoped at all),gh auth/secret/variable/ssh-key/gpg-key(credential/secret management),gh repo clone/fork/create/delete/rename(target repo is a bare positional argument with subcommand-specific parsing -- not implemented).--repo/-R, any URL-shaped positional argument (pr view <pr-url>, etc.), a bare owner/repo positional forrepo view, or -- only when none of those are present -- the session cwd's own origin remote.ghbinary runs on the host, inside the broker, with no TTY attached, so only non-interactive usage (flags/stdin, no editor prompts) works through this bridge.Bugs found and fixed during review (before merge)
allowHalfOpen:net.createServerdefaults toallowHalfOpen: false. The credential-request path responds synchronously (same tick), so this never mattered before. The new gh-exec path is async (spawns and awaits a realghchild process), and withoutallowHalfOpen: truethe socket's write side was silently auto-closed by Node as soon as it saw the client's half-close -- every gh-exec response was discarded before it could be sent. Fixed by passing{ allowHalfOpen: true }tocreateServer.-wR owner/repo, pflag/Cobra shorthand for-w -R owner/repo): the broker's own parsing only recognized a bare-R, so a bundled-wRwas invisible to it and it would validate the (allow-listed) cwd repo while the realghbinary -- parsing the same argv with its complete grammar -- acted on the bundled flag's actual (unchecked) target. Fixed by refusing any short-dash token that could plausibly be bundling multiple flags, rather than trying to reimplement gh's full shorthand grammar.pr view/checkout/diff/merge/close/editetc. accept<number>|<url>|<branch>; when given a URL, gh resolves the repo from it, ignoring--repo/cwd entirely.gh pr merge <url-to-unrelated-repo>previously sailed through as "no --repo given, use cwd's repo" while actually merging a PR in a completely different, unchecked repo -- and this is the ordinary way to act on a pasted PR link, not an obscure edge case. Fixed by scanning argv for URL-shaped (and, forrepo view, bare owner/repo-shaped) tokens and requiring every one found to be allow-listed too.Also in this PR: unit tests + CI
The repo had no CI and no unit tests (only a Playwright e2e suite). Given this code is security-load-bearing and review already found real bugs in it by hand, added:
server/ws/gitAllowlist.test.js,server/ws/ghAllowlist.test.js,server/ws/git-broker.test.js(real end-to-end socket protocol test, including a regression test for theallowHalfOpenbug above) using Node's built-in test runner (node --test, no new dependency). Run vianpm test..github/workflows/ci.yml: one job runsnpm testplus anode --checksyntax pass over all ofserver/; a second job runs the existing Playwright e2e suite. Both on push/PR tomaster.Test plan
npm test-- 63 assertions across the three new test files, all passingpr viewwith no--repo, resolved from the session cwd's origin remote, allowed and executed for a repo in the allow-listgh api /userrefused (subcommand-not-allowed)pr view --repo <unrelated>refused (not-allowlisted) -- an explicit--repodoesn't bypass the checkpr view -wR <unrelated> 5(bundled flag) refused (ambiguous-flags)pr merge <url-to-unrelated-repo>refused (not-allowlisted) even though cwd itself is allow-listedpr view <url-to-the-allow-listed-repo>/pull/Nallowed (URL correctly truncated to owner/repo, ignoring the/pull/Nsuffix)gh repo clone <anything>refused (subcommand-not-allowed)Known limitations (documented in README)
docker: true(default) -- a container started viadocker runinside the sandbox doesn't inherit the gh/ssh wrapper binds and can mount the forwarded ssh-agent socket directly.gh pr createwith no flags) don't work.gh repo clone/fork/create/delete/renameremain unsupported.-wR) are refused outright rather than parsed; use long-form or separate flags instead.🤖 Generated with Claude Code
https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX