Skip to content

fix(macos): authenticate git-over-ssh through the sandbox proxy - #516

Open
cblecker wants to merge 2 commits into
anthropics:mainfrom
cblecker:fix/macos-git-ssh-proxy-auth
Open

fix(macos): authenticate git-over-ssh through the sandbox proxy#516
cblecker wants to merge 2 commits into
anthropics:mainfrom
cblecker:fix/macos-git-ssh-proxy-auth

Conversation

@cblecker

@cblecker cblecker commented Sep 4, 2026

Copy link
Copy Markdown

Problem

On macOS generateProxyEnvVars() injects:

GIT_SSH_COMMAND=ssh … -o ProxyCommand='nc -X 5 -x localhost:<socksPort> %h %p'

Apple's /usr/bin/nc implements SOCKS5 but has no username/password
authentication — there is no flag for it. Since the loopback proxy began
minting a proxyAuthToken (sandbox-manager.ts:881, which happens whenever
SRT owns the mux, i.e. by default), that ProxyCommand cannot authenticate, so
sandboxed git-over-SSH ends at the handshake:

$ git ls-remote origin HEAD
Received disconnect from UNKNOWN port 65535:1: This proxy requires
authentication, and this client did not offer an authentication method, so
the connection was refused.

Reported as anthropics/claude-code#70684 and anthropics/claude-code#82255.

What this changes

Two commits that are independent of each other.

1. feat(proxy): explain blocked CONNECT to ssh

A blocked CONNECT to port 22 reached the user as Connection closed by UNKNOWN port 65535, with no reason — an ssh client cannot read an HTTP
status. socks-proxy.ts already solves this for SOCKS by answering in SSH's
own protocol, so this lifts sshDisconnectPacket() and the identification
banner into ssh-refusal.ts and reuses them from the CONNECT denial path.
OpenSSH discards lines preceding the SSH-2.0 identification string
(RFC 4253 §4.2), so the 403 and the in-band refusal can share one response.

This commit is useful on its own: it improves CONNECT denial messages for the
Linux socat path that exists today, independently of anything below.

2. fix(sandbox): authenticate macOS git-over-ssh

When a token is set, macOS emits an HTTP CONNECT ProxyCommand carrying a
Basic header — the same transport the Linux branch has used since #168
spelled with tools macOS ships:

-o ProxyCommand="/bin/sh -c 'exec 3<&0; { printf \"CONNECT %h:%p HTTP/1.1\r\nProxy-Authorization: Basic <b64>\r\n\r\n\"; cat <&3; } | /usr/bin/nc 127.0.0.1 <httpProxyPort> & exec 1>&- 3<&-; wait'"

When no token is set, the existing nc -X 5 spelling is kept unchanged: it is
correct against an externally configured proxy that handles its own auth.

Every token in that string is load-bearing, and each is commented in place:

token why
inner /bin/sh -c ssh runs <shell> -c "exec <ProxyCommand>", so the value has to be a single command — exec { …; } | nc is a syntax error
/bin/sh, /usr/bin/nc absolute ssh runs ProxyCommand under $SHELL, and the child's PATH is the user's own
& exec 1>&- 3<&- drops the shell's own copy of ssh's stdout, so nc exiting propagates EOF. Without it, a refused CONNECT never reaches ssh and ssh hangs
exec 3<&0 / cat <&3 POSIX gives a background command stdin from /dev/null (/bin/sh and dash do); saving fd 0 first makes that irrelevant
127.0.0.1, not localhost the mux binds v4; a client that resolves localhost to ::1 first is refused
only %h/%p unescaped ssh's percent_expand aborts on any other % key

Verification

macOS 15 (Darwin 25.6.0, arm64), OpenSSH 10.3p1, against a live sandbox:

  • git ls-remote over an ssh remote returns the expected SHA through the
    generated string; verified with $SHELL set to bash, /bin/sh and zsh,
    since ssh runs the ProxyCommand under the login shell.
  • A denied destination reports the policy reason rather than
    Connection closed by UNKNOWN port 65535.
  • New tests: 7 unit cases pinning both macOS branches in
    proxy-env-vars.test.ts, and http-proxy-ssh-refusal.test.ts covering the
    CONNECT refusal bytes plus a describe.if(isMacOS) end-to-end that drives
    real OpenSSH with the generated string against a real proxy — both the
    tunnel and the denial. All three behavioural tests were confirmed failing on
    main before the fix.
  • npm run typecheck and npm run lint:check clean. Full suite run on this
    branch and on main in the same environment produces an identical set of
    pre-existing failures (my host cannot nest a sandbox), plus the new passes.

Not covered

  • Plain ssh. No environment variable injects an ssh_config, and a
    command-line -o ProxyCommand beats ~/.ssh/config, so only git's
    GIT_SSH_COMMAND is reachable from here. ssh user@host outside git stays
    broken.
  • git-lfs parses GIT_SSH_COMMAND with its own tokenizer. I could not
    exercise it: git-lfs derives an https:// LFS endpoint for GitHub ssh
    remotes, so it never invoked ssh in my testing. An LFS endpoint that does
    use git-lfs-authenticate over ssh is untested.
  • mitmCA enabled is not hand-tested — my config has no CA configured.
    The client's wire bytes are identical either way (CONNECT, then
    SSH-2.0-…), so it should take the same non-TLS fallback that
    http-proxy.ts already documents for the Linux socat path, but I have not
    observed it.

Relationship to the other open PRs

#385, #452 and #482 all address this same issue with different designs
(respectively: a destination-hostname token prefix with a server-side strip;
detecting socat on PATH; and a bundled Node SOCKS5 client). I found them
only after writing this, and I'm not proposing this as a replacement for any
of them — maintainers will have context I don't about which direction fits the
project. Offering it as another option, with what is specific to it stated
plainly: it adds no runtime dependency, no bundled artifact, and no change to
how the proxy authenticates. Happy to close this if one of the others is
preferred, or to rework it in whatever direction is useful.

One environment observation that may be relevant to the general question of
where an ssh ProxyCommand can find a helper: on my machine the released
Claude Code is a single ~191 MB Mach-O binary with no dist/, no
node_modules/@anthropic-ai/sandbox-runtime and no vendor/ beside it, and
nothing extracted at runtime — JAVA_TOOL_OPTIONS inside the sandbox is
-Djava.net.preferIPv4Stack=true with no -javaagent: entry, so the
java-proxy-agent.ts locator finds no jar there either. That is one install
on one machine and I may well be missing how assets are meant to resolve in
that build, but it is why this change leans only on what the host already
ships. It may also be worth a separate look for the Java agent's sake.

This PR was written in part with the assistance of generative AI.

@cblecker
cblecker force-pushed the fix/macos-git-ssh-proxy-auth branch from 60a395f to 1892539 Compare September 4, 2026 02:11
A blocked CONNECT to port 22 reached the user as "Connection closed by
UNKNOWN port 65535" with no reason, because an ssh client cannot read an
HTTP status. The SOCKS front-end already solves this by answering in SSH's
own protocol; move those helpers into ssh-refusal.ts and reuse them, so a
denied SSH destination gets the policy reason either way. OpenSSH discards
lines preceding the SSH-2.0 banner (RFC 4253 4.2), so the 403 and the
refusal can share one response.

Also drops the README's claim that the in-band SSH reason is specific to
no-auth SOCKS clients; it now covers CONNECT denials too.

Assisted-by: LLM
BSD nc speaks SOCKS5 but has no authentication of any kind, so the
GIT_SSH_COMMAND injected on macOS could never reach the proxy once it
started requiring a credential: every sandboxed git fetch/pull/push died
at the handshake with SSH-2.0-policy_refusal.

Use HTTP CONNECT with a Basic header instead — the transport the Linux
branch already uses — spelled with /bin/sh and /usr/bin/nc, which stock
macOS ships. The unauthenticated branch keeps `nc -X 5`, which is correct
when an external proxy handles its own auth.

The end-to-end test drives real OpenSSH with the string generateProxyEnvVars
emits, the way git hands it over: it must authenticate and tunnel, and a
blocked destination must print the reason. It runs the proxy directly rather
than through SandboxManager.initialize — the seatbelt wrapper is not what
this touches, and skipping it keeps the test runnable outside a sandbox host.

Fixes anthropics/claude-code#70684

Assisted-by: LLM
@cblecker
cblecker force-pushed the fix/macos-git-ssh-proxy-auth branch from 1892539 to fb8bac8 Compare September 4, 2026 02:13
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