From 629edf8c4c967f0f37d8f9b21490277c9b296b23 Mon Sep 17 00:00:00 2001 From: Christoph Blecker Date: Thu, 3 Sep 2026 19:13:15 -0700 Subject: [PATCH 1/2] 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, 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 --- README.md | 2 +- src/sandbox/http-proxy.ts | 20 ++++- src/sandbox/socks-proxy.ts | 47 +---------- src/sandbox/ssh-refusal.ts | 68 ++++++++++++++++ test/sandbox/http-proxy-ssh-refusal.test.ts | 86 +++++++++++++++++++++ 5 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 src/sandbox/ssh-refusal.ts create mode 100644 test/sandbox/http-proxy-ssh-refusal.test.ts diff --git a/README.md b/README.md index 14d896861..77728812e 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ Uses an **allow-only pattern** - all network access is denied by default. - `network.allowedDomains` - Array of allowed domains (supports wildcards like `*.example.com`). Empty array = no network access. An optional `:port` suffix (`api.example.com:443`, `*.example.com:8443`) restricts an entry to that destination port; entries without a port match any port. - IPv6 literals must be bracketed, RFC 3986-style: `[::1]`, `[2001:db8::1]:443`. An unbracketed multi-colon entry is rejected as ambiguous (`2001:db8::1:443` is itself a valid address). - `network.deniedDomains` - Array of denied domains (checked first, takes precedence over allowedDomains). Same `:port` suffix, and a bare `*` (or `*:22`) is accepted for deny-all. -- `network.deniedDomainReasons` - Optional map from a `deniedDomains` entry (matched by exact string) to a model-facing reason that appears in the `` line when that entry denies a connection — say what is blocked and the sanctioned alternative (e.g. `{"github.com:22": "SSH pushes to GitHub are blocked; use an https:// remote"}`). Entries without a reason report a generic one. For SSH destinations (port 22), the reason is also delivered in-band: an SSH client tunneled through a no-auth SOCKS ProxyCommand (e.g. BSD `nc -X 5`) receives a pre-key-exchange SSH disconnect whose description is the reason, which OpenSSH prints verbatim — keep such reasons under ~400 ASCII characters, imperative first, since OpenSSH truncates and escapes non-ASCII. +- `network.deniedDomainReasons` - Optional map from a `deniedDomains` entry (matched by exact string) to a model-facing reason that appears in the `` line when that entry denies a connection — say what is blocked and the sanctioned alternative (e.g. `{"github.com:22": "SSH pushes to GitHub are blocked; use an https:// remote"}`). Entries without a reason report a generic one. For SSH destinations (port 22), the reason is also delivered in-band: an SSH client tunneled through the proxy — over SOCKS or HTTP CONNECT, authenticated or not — receives a pre-key-exchange SSH disconnect whose description is the reason, which OpenSSH prints verbatim — keep such reasons under ~400 ASCII characters, imperative first, since OpenSSH truncates and escapes non-ASCII. - `network.allowLocalBinding` - Allow binding to local ports (boolean, default: false) **TLS termination** (`network.tlsTerminate`, experimental): when set, HTTPS CONNECTs are terminated in-process so SRT can see (and filter, via `network.filterRequest`) the decrypted requests. The sandboxed process is pointed at a trust bundle containing the MITM CA (`caCertPath`/`caKeyPath`, or an ephemeral CA if omitted) plus the host's regular roots, so proxy-minted certificates and real upstream certificates both verify. diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index 5a6e5ea8a..47f7f82d8 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -14,6 +14,7 @@ import { type FilterRequestCallback, type MutateForwardedHeaders, } from './request-filter.js' +import { SSH_PORT, sshRefusalBytes } from './ssh-refusal.js' import { peekForClientHello, terminateAndForward, @@ -367,7 +368,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { // Decision-phase status writes go through this guard: a verdict for // a dead client is dropped, never written. The filter's work is not // wasted — host-side allow/deny caches serve the client's retry. - const endWithStatus = (payload: string) => { + const endWithStatus = (payload: string, body?: Buffer) => { if ( clientGone || socket.destroyed || @@ -384,7 +385,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { // still queued (backpressured or slow client) would otherwise // fire the armed 'end' handler and destroy() the unflushed write. disarmDecisionWindowEof() - socket.end(payload) + socket.end(body ? Buffer.concat([Buffer.from(payload), body]) : payload) } // A client that sent CONNECT and FIN together (or closed before the // handler ran) was destroyed at arm time: return before spending a @@ -427,12 +428,23 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { logForDebugging(`Connection blocked to ${requestedHost}:${port}`, { level: 'error', }) + // An SSH client cannot read an HTTP status, so answer in its own + // protocol as well. macOS routes git-over-ssh through CONNECT (see + // the GIT_SSH_COMMAND branch in sandbox-utils), and without this a + // denial reaches the user as "Connection closed by UNKNOWN port + // 65535". OpenSSH discards everything before the SSH-2.0 banner, so + // the 403 is skipped and the disconnect reason is what it prints. + // The banner replaces the body: an HTTP client would only ever see + // it on a port it was not talking HTTP on anyway. + const blocked = `Connection to ${requestedHost}:${port} blocked by the sandbox network allowlist` endWithStatus( 'HTTP/1.1 403 Forbidden\r\n' + 'Content-Type: text/plain\r\n' + 'X-Proxy-Error: blocked-by-allowlist\r\n' + - '\r\n' + - 'Connection blocked by network allowlist', + '\r\n', + port === SSH_PORT + ? sshRefusalBytes(blocked) + : Buffer.from('Connection blocked by network allowlist'), ) return } diff --git a/src/sandbox/socks-proxy.ts b/src/sandbox/socks-proxy.ts index 71bfe48fe..41ac86e91 100644 --- a/src/sandbox/socks-proxy.ts +++ b/src/sandbox/socks-proxy.ts @@ -14,6 +14,7 @@ import { encodedCommandFromProxyUser, PROXY_AUTH_USER, } from './sandbox-utils.js' +import { SSH_PORT, sshRefusalBytes } from './ssh-refusal.js' export interface SocksProxyServerOptions { /** @@ -313,7 +314,7 @@ async function refuseUnauthenticated( `SOCKS unauthenticated client refused for ${host}:${port}` + (probe.deniedReason !== undefined ? ' (denied by policy)' : ''), ) - if (port !== 22) { + if (port !== SSH_PORT) { socket.end(socksReply(0x02)) return } @@ -327,8 +328,7 @@ async function refuseUnauthenticated( 'authentication method, so the connection was refused.' // SOCKS success (bind address 0.0.0.0:0), then speak SSH. socket.write(socksReply(0x00)) - socket.write(Buffer.from('SSH-2.0-policy_refusal\r\n')) - socket.end(sshDisconnectPacket(reason)) + socket.end(sshRefusalBytes(reason)) } /** SOCKS5 reply with the given status and a zero bind address. */ @@ -389,44 +389,3 @@ function readSocksConnect( socket.once('close', () => finish(undefined)) }) } - -/** - * A plaintext SSH_MSG_DISCONNECT, legal before key exchange (RFC 4253: - * SSH_MSG_DISCONNECT may be sent at any time; pre-NEWKEYS packets carry no - * MAC and no encryption). reason code 1 = HOST_NOT_ALLOWED_TO_CONNECT. - * The description is what OpenSSH prints; collapse control characters so a - * configured reason can't fabricate extra log lines, and cap the length. - */ -function sshDisconnectPacket(description: string): Buffer { - const text = description - // eslint-disable-next-line no-control-regex -- stripping control chars is the point - .replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ') - .slice(0, 1000) - const desc = Buffer.from(text, 'utf8') - const lang = Buffer.alloc(0) - const payload = Buffer.concat([ - Buffer.from([0x01]), // SSH_MSG_DISCONNECT - uint32(1), // SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT - uint32(desc.length), - desc, - uint32(lang.length), - lang, - ]) - // packet_length = padding_length byte + payload + padding; - // (4 + packet_length) must be a multiple of 8, padding >= 4. - let padding = 8 - ((4 + 1 + payload.length) % 8) - if (padding < 4) padding += 8 - const packet = Buffer.concat([ - uint32(1 + payload.length + padding), - Buffer.from([padding]), - payload, - Buffer.alloc(padding), - ]) - return packet -} - -function uint32(n: number): Buffer { - const b = Buffer.alloc(4) - b.writeUInt32BE(n, 0) - return b -} diff --git a/src/sandbox/ssh-refusal.ts b/src/sandbox/ssh-refusal.ts new file mode 100644 index 000000000..c685686e5 --- /dev/null +++ b/src/sandbox/ssh-refusal.ts @@ -0,0 +1,68 @@ +/** + * In-band SSH refusal, shared by the SOCKS and HTTP CONNECT front-ends. + * + * When the sandbox blocks a connection to an SSH destination, the client is + * ssh — it speaks no HTTP and no SOCKS, and a bare transport-level refusal + * reaches the user as "Connection closed by UNKNOWN port 65535" with no + * explanation. Answering in SSH's own protocol instead makes OpenSSH print + * the policy reason verbatim ("Received disconnect from ...: "), so + * a git-over-ssh user or agent learns why the connection was refused. + * + * Both front-ends may emit their own protocol's refusal first: OpenSSH + * discards any lines preceding the SSH identification string (RFC 4253 + * §4.2), so an HTTP status line and headers are skipped by the client. + */ + +/** Minimal SSH server identification. Must end with CRLF (RFC 4253 §4.2). */ +export const SSH_REFUSAL_BANNER = 'SSH-2.0-policy_refusal\r\n' + +/** The port an SSH destination is recognised by. */ +export const SSH_PORT = 22 + +/** + * A plaintext SSH_MSG_DISCONNECT, legal before key exchange (RFC 4253: + * SSH_MSG_DISCONNECT may be sent at any time; pre-NEWKEYS packets carry no + * MAC and no encryption). reason code 1 = HOST_NOT_ALLOWED_TO_CONNECT. + * The description is what OpenSSH prints; collapse control characters so a + * configured reason can't fabricate extra log lines, and cap the length. + */ +export function sshDisconnectPacket(description: string): Buffer { + const text = description + // eslint-disable-next-line no-control-regex -- stripping control chars is the point + .replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ') + .slice(0, 1000) + const desc = Buffer.from(text, 'utf8') + const lang = Buffer.alloc(0) + const payload = Buffer.concat([ + Buffer.from([0x01]), // SSH_MSG_DISCONNECT + uint32(1), // SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT + uint32(desc.length), + desc, + uint32(lang.length), + lang, + ]) + // packet_length = padding_length byte + payload + padding; + // (4 + packet_length) must be a multiple of 8, padding >= 4. + let padding = 8 - ((4 + 1 + payload.length) % 8) + if (padding < 4) padding += 8 + return Buffer.concat([ + uint32(1 + payload.length + padding), + Buffer.from([padding]), + payload, + Buffer.alloc(padding), + ]) +} + +/** Identification string plus disconnect packet, ready to write and close. */ +export function sshRefusalBytes(description: string): Buffer { + return Buffer.concat([ + Buffer.from(SSH_REFUSAL_BANNER), + sshDisconnectPacket(description), + ]) +} + +function uint32(n: number): Buffer { + const b = Buffer.alloc(4) + b.writeUInt32BE(n, 0) + return b +} diff --git a/test/sandbox/http-proxy-ssh-refusal.test.ts b/test/sandbox/http-proxy-ssh-refusal.test.ts new file mode 100644 index 000000000..836bb3234 --- /dev/null +++ b/test/sandbox/http-proxy-ssh-refusal.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import type { Server } from 'node:http' +import { connect, type Socket } from 'node:net' +import { once } from 'node:events' +import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' + +/** + * A blocked CONNECT to an SSH destination must say why. + * + * The SOCKS side already does this: an unauthenticated client asking for + * port 22 gets an in-band SSH identification plus a plaintext + * SSH_MSG_DISCONNECT carrying the policy reason, which OpenSSH prints + * verbatim. The CONNECT side is now the transport macOS git-over-ssh uses + * (see the GIT_SSH_COMMAND branch in sandbox-utils), so without the same + * treatment a denied host degrades to "Connection closed by UNKNOWN port + * 65535" with no reason. The HTTP status lines precede the SSH banner and + * OpenSSH discards them (RFC 4253 §4.2), so both can share one response. + */ +describe('HTTP CONNECT refusal for SSH destinations', () => { + const HOST = '127.0.0.1' + let server: Server | undefined + + afterEach(async () => { + if (server) { + const s = server + server = undefined + await new Promise(r => s.close(() => r())) + } + }) + + async function startProxy(): Promise { + server = createHttpProxyServer({ filter: () => false }) + const s = server + s.listen(0, HOST) + await once(s, 'listening') + return (s.address() as { port: number }).port + } + + async function connectTo( + proxyPort: number, + target: string, + ): Promise { + const client: Socket = connect(proxyPort, HOST) + client.on('error', () => {}) + await once(client, 'connect') + client.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`) + const chunks: Buffer[] = [] + client.on('data', c => chunks.push(c)) + await once(client, 'close') + return Buffer.concat(chunks) + } + + /** Description field of a pre-key-exchange SSH_MSG_DISCONNECT. */ + function disconnectReason(packet: Buffer): string { + // uint32 packet_length, byte padding_length, byte msg (1), uint32 reason, + // then a uint32-prefixed description. + expect(packet[5]).toBe(0x01) + const len = packet.readUInt32BE(10) + return packet.subarray(14, 14 + len).toString('utf8') + } + + it('follows the 403 with an SSH disconnect naming the reason', async () => { + const port = await startProxy() + + const response = await connectTo(port, 'blocked.example:22') + + const text = response.toString('latin1') + expect(text).toStartWith('HTTP/1.1 403 Forbidden\r\n') + const bannerAt = text.indexOf('SSH-2.0-policy_refusal\r\n') + expect(bannerAt).toBeGreaterThan(0) + const packet = response.subarray( + bannerAt + 'SSH-2.0-policy_refusal\r\n'.length, + ) + expect(disconnectReason(packet)).toContain('allowlist') + }) + + it('leaves non-SSH destinations as a plain 403', async () => { + const port = await startProxy() + + const response = await connectTo(port, 'blocked.example:443') + + const text = response.toString('latin1') + expect(text).toStartWith('HTTP/1.1 403 Forbidden\r\n') + expect(text).not.toContain('SSH-2.0') + }) +}) From fb8bac8a68e98e8396b9b8cdd5f90b29bf29d8ef Mon Sep 17 00:00:00 2001 From: Christoph Blecker Date: Thu, 3 Sep 2026 19:13:15 -0700 Subject: [PATCH 2/2] fix(sandbox): authenticate macOS git-over-ssh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/sandbox/sandbox-utils.ts | 42 +++++++- test/sandbox/http-proxy-ssh-refusal.test.ts | 107 +++++++++++++++++++- test/sandbox/proxy-env-vars.test.ts | 85 +++++++++++++++- 3 files changed, 228 insertions(+), 6 deletions(-) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index f5d9bd8f1..6e7323535 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -594,10 +594,44 @@ export function generateProxyEnvVars( // configured ControlPath. const sshMuxOverride = '-o ControlMaster=no -o ControlPath=none' const platform = getPlatform() - if (platform === 'macos') { - // macOS: use BSD nc SOCKS5 proxy support (-X 5 -x). nc has no SOCKS5 - // auth, so when proxyAuthToken is set, git-over-ssh fails at the SOCKS - // handshake — use git-over-https (HTTP_PROXY carries the credential). + if (platform === 'macos' && proxyAuthToken && httpProxyPort) { + // BSD nc speaks SOCKS5 (-X 5 -x) but has no authentication of any + // kind, so it cannot reach an authenticated proxy at all — every + // sandboxed git-over-ssh operation died at the handshake + // (anthropics/claude-code#70684). Do what Linux does instead — HTTP + // CONNECT carrying a Basic header — spelled with tools stock macOS + // ships, since socat is not one of them. + // + // ssh runs the ProxyCommand as `<$SHELL> -c "exec "`, so the + // value must be a single command; hence the inner shell. Within it: + // + // exec 3<&0 Save ssh's stdin. POSIX hands a background + // command /dev/null on fd 0 — /bin/sh and dash do + // this — so the pipeline reads from fd 3 instead. + // printf … | nc Send the CONNECT preamble ahead of ssh's own + // traffic. OpenSSH discards lines preceding the + // SSH-2.0 banner (RFC 4253 §4.2), so the proxy's + // HTTP response is skipped by the client. + // & exec 1>&- 3<&- Drop this shell's copy of ssh's stdout, leaving + // nc the only holder. Without it a refused CONNECT + // never reaches ssh as EOF and ssh hangs forever. + // + // /bin/sh and /usr/bin/nc are absolute because the child's PATH is the + // user's own and may shadow either. 127.0.0.1 rather than localhost: + // the mux binds v4, and a client that tries ::1 first is refused. + // Only %h and %p may appear unescaped — ssh's percent_expand aborts on + // any other % key. + const basic = Buffer.from(`${userRaw}:${proxyAuthToken}`).toString( + 'base64', + ) + const preamble = `printf \\"CONNECT %h:%p HTTP/1.1\\r\\nProxy-Authorization: Basic ${basic}\\r\\n\\r\\n\\"` + const proxyCommand = `/bin/sh -c 'exec 3<&0; { ${preamble}; cat <&3; } | /usr/bin/nc 127.0.0.1 ${httpProxyPort} & exec 1>&- 3<&-; wait'` + envVars.push( + `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand="${proxyCommand}"`, + ) + } else if (platform === 'macos') { + // No token: an externally-configured proxy handles its own auth, so + // BSD nc's unauthenticated SOCKS5 support is all that is needed. envVars.push( `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`, ) diff --git a/test/sandbox/http-proxy-ssh-refusal.test.ts b/test/sandbox/http-proxy-ssh-refusal.test.ts index 836bb3234..f82ddbadc 100644 --- a/test/sandbox/http-proxy-ssh-refusal.test.ts +++ b/test/sandbox/http-proxy-ssh-refusal.test.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it } from 'bun:test' import type { Server } from 'node:http' -import { connect, type Socket } from 'node:net' +import { + connect, + createServer as createNetServer, + type Server as NetServer, + type Socket, +} from 'node:net' import { once } from 'node:events' +import { execFile } from 'node:child_process' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' +import { generateProxyEnvVars } from '../../src/sandbox/sandbox-utils.js' +import { isMacOS } from '../helpers/platform.js' /** * A blocked CONNECT to an SSH destination must say why. @@ -84,3 +92,100 @@ describe('HTTP CONNECT refusal for SSH destinations', () => { expect(text).not.toContain('SSH-2.0') }) }) + +/** + * The end-to-end claim for the macOS GIT_SSH_COMMAND branch: the string + * generateProxyEnvVars emits, handed to a real OpenSSH the way git hands it + * over, authenticates to the proxy and tunnels — and when the destination is + * blocked, ssh prints why. + * + * macOS-gated because that branch is macOS-only and because it needs a real + * OpenSSH. It drives the proxy directly rather than through + * SandboxManager.initialize: the seatbelt wrapper is not what this change + * touches, and skipping it keeps the test runnable outside a sandbox host. + */ +describe.if(isMacOS)('macOS GIT_SSH_COMMAND end to end', () => { + const HOST = '127.0.0.1' + const TOKEN = 'tok-e2e' + let proxy: Server | undefined + let upstream: NetServer | undefined + + afterEach(async () => { + if (proxy) { + const p = proxy + proxy = undefined + await new Promise(r => p.close(() => r())) + } + upstream?.close() + upstream = undefined + }) + + async function startProxy(allow: boolean): Promise { + proxy = createHttpProxyServer({ + filter: () => allow, + proxyAuthToken: TOKEN, + }) + const p = proxy + p.listen(0, HOST) + await once(p, 'listening') + return (p.address() as { port: number }).port + } + + /** Runs ssh exactly as git would: the whole value through a shell. */ + function runSsh(gitSshCommand: string, target: string): Promise { + return new Promise(resolve => { + execFile( + '/bin/sh', + [ + '-c', + `${gitSshCommand} -o StrictHostKeyChecking=no -o ConnectTimeout=10 -T ${target} 2>&1`, + ], + { timeout: 20000 }, + (_err, stdout) => resolve(stdout), + ) + }) + } + + function sshCommandFor(proxyPort: number): string { + const line = generateProxyEnvVars( + proxyPort, + proxyPort, + undefined, + TOKEN, + true, + ).find(v => v.startsWith('GIT_SSH_COMMAND='))! + return line.slice('GIT_SSH_COMMAND='.length) + } + + it('tunnels ssh to the destination', async () => { + // A stand-in for sshd: enough to prove ssh's own bytes crossed the + // proxy. Reaching key exchange is not the claim under test. + const seen: Buffer[] = [] + upstream = createNetServer(sock => { + sock.write('SSH-2.0-srt_test\r\n') + // Hang up once ssh has identified itself, so it fails fast on the + // key exchange we are not here to perform. + sock.once('data', c => { + seen.push(c) + sock.end() + }) + }) + upstream.listen(0, HOST) + await once(upstream, 'listening') + const upstreamPort = (upstream.address() as { port: number }).port + const proxyPort = await startProxy(true) + + await runSsh(sshCommandFor(proxyPort), `-p ${upstreamPort} git@${HOST}`) + + expect(Buffer.concat(seen).toString('latin1')).toContain('SSH-2.0-') + }, 20000) + + it('prints the reason when the destination is blocked', async () => { + const proxyPort = await startProxy(false) + + const output = await runSsh(sshCommandFor(proxyPort), 'git@blocked.example') + + expect(output).toContain('blocked by the sandbox network allowlist') + expect(output).not.toContain('Connection closed by UNKNOWN') + }, 20000) +}) diff --git a/test/sandbox/proxy-env-vars.test.ts b/test/sandbox/proxy-env-vars.test.ts index 5c99c65a8..cdbe85b36 100644 --- a/test/sandbox/proxy-env-vars.test.ts +++ b/test/sandbox/proxy-env-vars.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import { createServer } from 'node:http' import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' @@ -10,6 +10,7 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' import { spawnAsync } from '../helpers/spawn.js' import { isLinux } from '../helpers/platform.js' +import * as platform from '../../src/utils/platform.js' describe('generateProxyEnvVars', () => { it('sets CLOUDSDK_PROXY_TYPE to http (gcloud rejects "https")', () => { @@ -98,6 +99,88 @@ describe('generateProxyEnvVars', () => { }) }) + describe('GIT_SSH_COMMAND on macOS', () => { + // Regression for anthropics/claude-code#70684. BSD nc speaks SOCKS5 but + // has no authentication of any kind, so `nc -X 5` cannot reach a proxy + // that requires a credential — every sandboxed git-over-ssh operation + // died at the handshake. When a token is set, macOS uses HTTP CONNECT + // with a Basic header instead, the same transport Linux already uses. + let platformSpy: ReturnType + + beforeEach(() => { + platformSpy = spyOn(platform, 'getPlatform') + platformSpy.mockReturnValue('macos') + }) + afterEach(() => platformSpy.mockRestore()) + + const sshCommand = (...args: Parameters) => + generateProxyEnvVars(...args).find(v => + v.startsWith('GIT_SSH_COMMAND='), + )! + + it('authenticates via HTTP CONNECT when a proxy auth token is set', () => { + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef') + const cred = Buffer.from('srt:deadbeef').toString('base64') + + expect(cmd).toContain('CONNECT %h:%p HTTP/1.1') + expect(cmd).toContain(`Proxy-Authorization: Basic ${cred}`) + // CONNECT is the HTTP proxy's port, not the SOCKS one. + expect(cmd).toContain('/usr/bin/nc 127.0.0.1 3128') + expect(cmd).not.toContain('nc -X 5') + }) + + it('keeps the SOCKS form when no token is set', () => { + // An externally-configured proxy handles its own auth, so unadorned + // `nc -X 5` is correct there and needs no shell wrapper. + const cmd = sshCommand(3128, 1080) + + expect(cmd).toContain('nc -X 5 -x localhost:1080') + expect(cmd).not.toContain('CONNECT') + }) + + it('carries the encoded command in the Basic username', () => { + // The proxy decodes this suffix to attribute a denial to the specific + // invocation that caused it (see encodedCommandFromProxyUser). + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef', undefined, 'YWJj') + const cred = Buffer.from('srt.YWJj:deadbeef').toString('base64') + + expect(cmd).toContain(`Proxy-Authorization: Basic ${cred}`) + }) + + it('dials 127.0.0.1 rather than localhost', () => { + // The mux binds 127.0.0.1; a client that resolves localhost to ::1 + // first gets ECONNREFUSED. + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef') + + expect(cmd).not.toContain('localhost') + }) + + it('pins absolute paths for the shell and netcat', () => { + // PATH inside the sandbox is the user's own and may shadow either. + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef') + + expect(cmd).toContain('/bin/sh -c') + expect(cmd).toContain('/usr/bin/nc') + }) + + it('closes its copy of stdout so a refused CONNECT reaches ssh as EOF', () => { + // Without this the shell keeps ssh's stdout pipe open after nc exits, + // and ssh hangs forever instead of reporting the failure. + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef') + + expect(cmd).toContain('& exec 1>&- 3<&-; wait') + }) + + it('reads ssh stdin from a saved descriptor', () => { + // POSIX gives a background command stdin from /dev/null (dash and + // /bin/sh do this); saving fd 0 first makes the rule irrelevant. + const cmd = sshCommand(3128, 1080, undefined, 'deadbeef') + + expect(cmd).toContain('exec 3<&0;') + expect(cmd).toContain('cat <&3') + }) + }) + describe('NO_PROXY', () => { it('does not exclude .local hostnames from the proxy', () => { // Under network restriction the child has no usable resolver, so a