Skip to content

Scope gh CLI through the git broker instead of disabling it - #5

Merged
nananek merged 4 commits into
masterfrom
gh-broker-scoped-passthrough
Jul 28, 2026
Merged

Scope gh CLI through the git broker instead of disabling it#5
nananek merged 4 commits into
masterfrom
gh-broker-scoped-passthrough

Conversation

@nananek

@nananek nananek commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • PR Add repo-scoped git credential broker for the sandbox #4 disabled gh entirely inside the sandbox (its API calls hit api.github.com over TLS and can't be repo-scoped by inspecting traffic), which meant a sandboxed Claude session could never delegate PR/issue workflows -- only plain git push to allow-listed repos.
  • This rewires gh instead 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 own gh auth, after checking every repo the invocation could target against the same allow-list already computed for git.
  • Allowed: 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.
  • Refused (default-deny, not on the safelist): 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).
  • Target repo resolution now checks every repo an invocation could resolve to, not just one: explicit --repo/-R, any URL-shaped positional argument (pr view <pr-url>, etc.), a bare owner/repo positional for repo view, or -- only when none of those are present -- the session cwd's own origin remote.
  • The sandboxed process never sees a gh token: the real gh binary 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)

  1. allowHalfOpen: net.createServer defaults to allowHalfOpen: 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 real gh child process), and without allowHalfOpen: true the 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 } to createServer.
  2. Bundled short flags (e.g. -wR owner/repo, pflag/Cobra shorthand for -w -R owner/repo): the broker's own parsing only recognized a bare -R, so a bundled -wR was invisible to it and it would validate the (allow-listed) cwd repo while the real gh binary -- 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.
  3. Positional URLs: pr view/checkout/diff/merge/close/edit etc. 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, for repo 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 the allowHalfOpen bug above) using Node's built-in test runner (node --test, no new dependency). Run via npm test.
  • .github/workflows/ci.yml: one job runs npm test plus a node --check syntax pass over all of server/; a second job runs the existing Playwright e2e suite. Both on push/PR to master.

Test plan

  • npm test -- 63 assertions across the three new test files, all passing
  • pr view with no --repo, resolved from the session cwd's origin remote, allowed and executed for a repo in the allow-list
  • gh api /user refused (subcommand-not-allowed)
  • pr view --repo <unrelated> refused (not-allowlisted) -- an explicit --repo doesn't bypass the check
  • pr view -wR <unrelated> 5 (bundled flag) refused (ambiguous-flags)
  • pr merge <url-to-unrelated-repo> refused (not-allowlisted) even though cwd itself is allow-listed
  • pr view <url-to-the-allow-listed-repo>/pull/N allowed (URL correctly truncated to owner/repo, ignoring the /pull/N suffix)
  • gh repo clone <anything> refused (subcommand-not-allowed)

Known limitations (documented in README)

  • Same as the git broker generally: not a hard boundary against a deliberately-bypassing process, and not a hard boundary at all when docker: true (default) -- a container started via docker run inside the sandbox doesn't inherit the gh/ssh wrapper binds and can mount the forwarded ssh-agent socket directly.
  • gh commands relayed through the broker must be non-interactive; editor/prompt-based flows (e.g. gh pr create with no flags) don't work.
  • gh repo clone/fork/create/delete/rename remain unsupported.
  • Bundled short flags (e.g. -wR) are refused outright rather than parsed; use long-form or separate flags instead.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YNFA5BLgmig7RswZypScjX

nananek and others added 4 commits July 28, 2026 11:16
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
@nananek
nananek merged commit df00999 into master Jul 28, 2026
2 checks passed
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