fix(macos): authenticate git-over-ssh through the sandbox proxy - #516
Open
cblecker wants to merge 2 commits into
Open
fix(macos): authenticate git-over-ssh through the sandbox proxy#516cblecker wants to merge 2 commits into
cblecker wants to merge 2 commits into
Conversation
cblecker
force-pushed
the
fix/macos-git-ssh-proxy-auth
branch
from
September 4, 2026 02:11
60a395f to
1892539
Compare
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
force-pushed
the
fix/macos-git-ssh-proxy-auth
branch
from
September 4, 2026 02:13
1892539 to
fb8bac8
Compare
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.
Problem
On macOS
generateProxyEnvVars()injects:Apple's
/usr/bin/ncimplements SOCKS5 but has no username/passwordauthentication — there is no flag for it. Since the loopback proxy began
minting a
proxyAuthToken(sandbox-manager.ts:881, which happens wheneverSRT owns the mux, i.e. by default), that ProxyCommand cannot authenticate, so
sandboxed git-over-SSH ends at the handshake:
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 sshA blocked CONNECT to port 22 reached the user as
Connection closed by UNKNOWN port 65535, with no reason — an ssh client cannot read an HTTPstatus.
socks-proxy.tsalready solves this for SOCKS by answering in SSH'sown protocol, so this lifts
sshDisconnectPacket()and the identificationbanner into
ssh-refusal.tsand 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
socatpath that exists today, independently of anything below.2.
fix(sandbox): authenticate macOS git-over-sshWhen 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:
When no token is set, the existing
nc -X 5spelling is kept unchanged: it iscorrect against an externally configured proxy that handles its own auth.
Every token in that string is load-bearing, and each is commented in place:
/bin/sh -c<shell> -c "exec <ProxyCommand>", so the value has to be a single command —exec { …; } | ncis a syntax error/bin/sh,/usr/bin/ncabsolute$SHELL, and the child'sPATHis the user's own& exec 1>&- 3<&-ncexiting propagates EOF. Without it, a refused CONNECT never reaches ssh and ssh hangsexec 3<&0/cat <&3/dev/null(/bin/shand dash do); saving fd 0 first makes that irrelevant127.0.0.1, notlocalhostlocalhostto::1first is refused%h/%punescapedpercent_expandaborts on any other%keyVerification
macOS 15 (Darwin 25.6.0, arm64), OpenSSH 10.3p1, against a live sandbox:
git ls-remoteover an ssh remote returns the expected SHA through thegenerated string; verified with
$SHELLset to bash,/bin/shand zsh,since ssh runs the ProxyCommand under the login shell.
Connection closed by UNKNOWN port 65535.proxy-env-vars.test.ts, andhttp-proxy-ssh-refusal.test.tscovering theCONNECT refusal bytes plus a
describe.if(isMacOS)end-to-end that drivesreal OpenSSH with the generated string against a real proxy — both the
tunnel and the denial. All three behavioural tests were confirmed failing on
mainbefore the fix.npm run typecheckandnpm run lint:checkclean. Full suite run on thisbranch and on
mainin the same environment produces an identical set ofpre-existing failures (my host cannot nest a sandbox), plus the new passes.
Not covered
ssh. No environment variable injects anssh_config, and acommand-line
-o ProxyCommandbeats~/.ssh/config, so only git'sGIT_SSH_COMMANDis reachable from here.ssh user@hostoutside git staysbroken.
GIT_SSH_COMMANDwith its own tokenizer. I could notexercise it: git-lfs derives an
https://LFS endpoint for GitHub sshremotes, so it never invoked ssh in my testing. An LFS endpoint that does
use
git-lfs-authenticateover ssh is untested.mitmCAenabled 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 thathttp-proxy.tsalready documents for the Linux socat path, but I have notobserved 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
socatonPATH; and a bundled Node SOCKS5 client). I found themonly 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/, nonode_modules/@anthropic-ai/sandbox-runtimeand novendor/beside it, andnothing extracted at runtime —
JAVA_TOOL_OPTIONSinside the sandbox is-Djava.net.preferIPv4Stack=truewith no-javaagent:entry, so thejava-proxy-agent.tslocator finds no jar there either. That is one installon 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.