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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ Uses an **allow-only pattern** - all network access is denied by default.
- `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.allowLocalBinding` - Allow binding to local ports (boolean, default: false)
- `network.httpProxyDualStack` - On macOS, also listen for HTTP proxy connections on `::1` while continuing to advertise `localhost` (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
28 changes: 24 additions & 4 deletions src/sandbox/mux-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ export interface MuxProxyOptions {
}

export interface MuxProxyServer {
/** The front-end TCP listener. Call `.listen()` on this. */
/** The IPv4 front-end TCP listener. Call `.listen()` on this. */
server: Server
/** Optional IPv6 HTTP front end. */
ipv6Server: Server
/** Bound front-end port, once listening. */
getPort(): number | undefined
/**
Expand All @@ -60,7 +62,7 @@ export interface MuxProxyServer {
listenHttpBackend(): Promise<number | undefined>
/** Tear down front-end, backend, and all open client sockets. */
close(): Promise<void>
/** unref() both listeners so they don't keep the event loop alive. */
/** Prevent listeners from keeping the event loop alive. */
unref(): void
}

Expand Down Expand Up @@ -137,12 +139,16 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer {
upstream.pipe(client)
}

const server = createServer(client => {
function trackClient(client: Socket): void {
openSockets.add(client)
client.once('close', () => openSockets.delete(client))
client.on('error', err =>
logForDebugging(`mux: client socket error: ${err.message}`),
)
}

const server = createServer(client => {
trackClient(client)

const timer = setTimeout(() => {
logForDebugging('mux: first-byte timeout; destroying connection')
Expand All @@ -168,8 +174,14 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer {
})
})

const ipv6Server = createServer(client => {
trackClient(client)
dispatchHttp(client)
})

return {
server,
ipv6Server,
getPort(): number | undefined {
const addr = server.address()
return addr && typeof addr === 'object' ? addr.port : undefined
Expand Down Expand Up @@ -212,7 +224,14 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer {
async close(): Promise<void> {
for (const s of openSockets) s.destroy()
openSockets.clear()
await new Promise<void>(resolve => server.close(() => resolve()))
await Promise.all(
[server, ipv6Server]
.filter(listener => listener.listening)
.map(
listener =>
new Promise<void>(resolve => listener.close(() => resolve())),
),
)
// The mux owns httpServer's listen lifecycle, so it owns close too.
// sandbox-manager.reset() additionally calls forceCloseHttpServer()
// for closeAllConnections() semantics; double-close is a no-op.
Expand All @@ -225,6 +244,7 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer {
},
unref(): void {
server.unref()
if (ipv6Server.listening) ipv6Server.unref()
opts.httpServer.unref()
},
}
Expand Down
6 changes: 6 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,12 @@ export const NetworkConfigSchema = z.object({
.describe(
'macOS only: Additional XPC/Mach service names to allow looking up. Supports trailing-wildcard prefix matching (e.g., "2BUA8C4S2C.com.1password.*"). Needed for tools like 1Password CLI, Playwright, or the iOS Simulator that communicate via XPC.',
),
httpProxyDualStack: z
.boolean()
.optional()
.describe(
'macOS only: Also listen on IPv6 loopback when the runtime owns the HTTP proxy. The proxy URL remains on localhost.',
),
httpProxyPort: z
.number()
.int()
Expand Down
25 changes: 23 additions & 2 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ function shouldTerminateTLSForHost(host: string): boolean {
async function startMuxProxyServer(
sandboxAskCallback: SandboxAskCallback | undefined,
portRange: readonly [number, number] | undefined,
enableIpv6Http: boolean,
): Promise<number> {
const injectCredentials = buildCredentialInjector()
const injectBodyCredentials = buildBodyCredentialInjector()
Expand Down Expand Up @@ -589,8 +590,22 @@ async function startMuxProxyServer(
if (muxPort === undefined) {
throw new Error('Failed to get mux proxy server port')
}
if (enableIpv6Http) {
await new Promise<void>((resolve, reject) => {
mux.ipv6Server.once('error', reject)
mux.ipv6Server.listen(
{ host: '::1', port: muxPort, ipv6Only: true },
() => {
mux.ipv6Server.removeListener('error', reject)
resolve()
},
)
})
}
mux.unref()
logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`)
logForDebugging(
`Mux proxy listening on 127.0.0.1${enableIpv6Http ? ' and ::1' : ''}:${muxPort}`,
)
return muxPort
}

Expand Down Expand Up @@ -890,7 +905,13 @@ async function initialize(
config.network.httpProxyPort === undefined ||
config.network.socksProxyPort === undefined
const muxPort = needLocalProxy
? await startMuxProxyServer(sandboxAskCallback, portRange)
? await startMuxProxyServer(
sandboxAskCallback,
portRange,
getPlatform() === 'macos' &&
config.network.httpProxyPort === undefined &&
config.network.httpProxyDualStack === true,
)
: undefined
const httpProxyPort = config.network.httpProxyPort ?? muxPort!
const socksProxyPort = config.network.socksProxyPort ?? muxPort!
Expand Down
1 change: 1 addition & 0 deletions test/config-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe('Config Validation', () => {
allowUnixSockets: ['/var/run/docker.sock'],
allowAllUnixSockets: false,
allowLocalBinding: true,
httpProxyDualStack: true,
},
filesystem: {
denyRead: ['/etc/shadow'],
Expand Down
40 changes: 38 additions & 2 deletions test/sandbox/proxy-env-vars.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'bun:test'
import { createServer } from 'node:http'
import { createServer, request } from 'node:http'
import type { Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import {
Expand All @@ -9,7 +9,7 @@ import {
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 { isLinux, isMacOS } from '../helpers/platform.js'

describe('generateProxyEnvVars', () => {
it('sets CLOUDSDK_PROXY_TYPE to http (gcloud rejects "https")', () => {
Expand All @@ -30,6 +30,42 @@ describe('generateProxyEnvVars', () => {
expect(env.some(v => v.startsWith('CLOUDSDK_PROXY_'))).toBe(false)
})

it.if(isMacOS)(
'can add an IPv6 listener without changing the proxy URL',
async () => {
try {
await SandboxManager.initialize({
network: {
allowedDomains: [],
deniedDomains: [],
httpProxyDualStack: true,
},
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
})
const port = SandboxManager.getProxyPort()
const wrapped = await SandboxManager.wrapWithSandbox('true')
expect(wrapped).toContain('@localhost:')

const status = await new Promise<number | undefined>(
(resolve, reject) => {
const req = request(
{ host: '::1', port, path: 'http://example.invalid/' },
response => {
response.resume()
resolve(response.statusCode)
},
)
req.once('error', reject)
req.end()
},
)
expect(status).toBe(407)
} finally {
await SandboxManager.reset()
}
},
)

describe('GRPC_PROXY', () => {
const grpcNames = ['GRPC_PROXY', 'grpc_proxy']

Expand Down