Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sandbox_violations>` 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 `<sandbox_violations>` 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.
Expand Down
20 changes: 16 additions & 4 deletions src/sandbox/http-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type FilterRequestCallback,
type MutateForwardedHeaders,
} from './request-filter.js'
import { SSH_PORT, sshRefusalBytes } from './ssh-refusal.js'
import {
peekForClientHello,
terminateAndForward,
Expand Down Expand Up @@ -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 ||
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 38 additions & 4 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>"`, 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'`,
)
Expand Down
47 changes: 3 additions & 44 deletions src/sandbox/socks-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
encodedCommandFromProxyUser,
PROXY_AUTH_USER,
} from './sandbox-utils.js'
import { SSH_PORT, sshRefusalBytes } from './ssh-refusal.js'

export interface SocksProxyServerOptions {
/**
Expand Down Expand Up @@ -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
}
Expand All @@ -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. */
Expand Down Expand Up @@ -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
}
68 changes: 68 additions & 0 deletions src/sandbox/ssh-refusal.ts
Original file line number Diff line number Diff line change
@@ -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 ...: <reason>"), 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
}
Loading