diff --git a/README.md b/README.md index 14d89686..8d4d0ef6 100644 --- a/README.md +++ b/README.md @@ -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 `` 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.exposeLoopbackPorts` - **Linux only.** Array of specific ports (e.g. `[3000]`) that the sandboxed process binds on `127.0.0.1` which the host should be able to reach through the bwrap network-namespace boundary (default: none). Each listed port gets its own scoped Unix-socket bridge — no general network access is granted, and any port not listed stays unreachable from outside the sandbox exactly as before. Use this when an unsandboxed parent process needs to poll/drive an HTTP (or other TCP) server that only exists inside the sandbox — e.g. spawning `myserver --port 4000` under `srt` and then health-checking `http://127.0.0.1:4000` from the launcher itself. Has no effect on macOS, where `--unshare-net` is never applied and `network.allowLocalBinding` already covers the equivalent case. **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. @@ -627,6 +628,8 @@ The sandbox runs HTTP and SOCKS5 proxy servers on the host machine that filter a - **Windows**: A WFP `ALE_AUTH_CONNECT` filter blocks every outbound connect from the `srt-sandbox` account except loopback to the configured proxy port range. The proxies bind inside that range. Environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, …) point tools at the proxies, but the WFP filter is the boundary — a process that ignores or unsets them is still fenced. +**Reaching a server inside the sandbox (Linux, opt-in):** `--unshare-net` is otherwise all-or-nothing — there is no way for a process outside the sandbox to connect to a port a sandboxed process binds, since it lives in a different, fully isolated network namespace. `network.exposeLoopbackPorts` adds a narrowly-scoped exception for this one case, reusing the same Unix-socket-bridge mechanism in reverse: for each listed port, a host-side `socat` listens on `127.0.0.1:` and forwards connections into a Unix socket; that socket is bind-mounted into the sandbox, where a matching in-sandbox `socat` forwards it to the sandboxed process's own `127.0.0.1:` server. Only the exact ports listed are bridged — every other port, and all other outbound/inbound traffic, is governed by the domain allowlist and proxies exactly as described above. + **JVM tools (macOS/Linux):** the JVM ignores `HTTPS_PROXY`/`NO_PROXY` and has no environment variable for proxy credentials — proxy selection comes from the `https.proxyHost` system properties and the credential can only be supplied through `java.net.Authenticator`. So JVM-based tools (Bazel's gRPC remote cache, Gradle, Maven, …) would otherwise dial the target directly and fail, or reach the proxy without its token and get a 407. To close that gap srt injects a small `-javaagent` via `JAVA_TOOL_OPTIONS` (the env var carries only the jar path, the credential stays in `HTTPS_PROXY`). At JVM start the agent sets `http[s].proxyHost`/`Port` and `http.nonProxyHosts` from the proxy env vars, re-enables Basic auth for CONNECT tunnels, and installs an Authenticator for the proxy endpoint. Explicit `-D` proxy properties on the JVM command line still win, and any inherited `JAVA_TOOL_OPTIONS` is preserved (unless it is a denied credential env var). Every JVM prints a `Picked up JAVA_TOOL_OPTIONS: …` line to stderr as a result; a jlink'd runtime built without the `java.instrument` module cannot load agents and will refuse to start under the sandbox — unset `JAVA_TOOL_OPTIONS` in the command for such a tool. The jar ships in the npm package as `vendor/java-proxy-agent/srt-proxy-agent.jar` (source: `vendor/java-proxy-agent-src/`; built by the release workflow, or locally with `npm run build:java-agent` — needs a JDK ≥ 17). If it is not found, `JAVA_TOOL_OPTIONS` is left alone and JVMs behave as before; bundlers can point at their own copy with `javaAgentJarPath`. ### Filesystem Isolation diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e31685cf..8360c372 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -5,6 +5,7 @@ import { randomBytes } from 'node:crypto' import * as fs from 'fs' import { spawn } from 'node:child_process' import type { ChildProcess } from 'node:child_process' +import * as net from 'node:net' import { tmpdir } from 'node:os' import path, { join } from 'node:path' import { ripGrep } from '../utils/ripgrep.js' @@ -95,6 +96,14 @@ export interface LinuxSandboxParams { observeSocketPath?: string /** Abort signal to cancel the ripgrep scan */ abortSignal?: AbortSignal + /** + * Linux-only: reverse loopback port-forward bridges to bind-mount into + * the sandbox and wire up so the host can reach a TCP server the + * sandboxed process binds on 127.0.0.1:. Each entry's socketPath + * must already exist (spawned by initializeLinuxPortForwardBridges on + * the host side) before this is used. + */ + exposeLoopbackPorts?: Array<{ port: number; socketPath: string }> } /** Default max depth for searching dangerous files */ @@ -755,6 +764,249 @@ export async function initializeLinuxNetworkBridge( } } +/** + * A single host-side reverse port-forward bridge, forwarding + * 127.0.0.1: on the host into a Unix socket that the sandbox side + * (set up in a later step) will listen on. + */ +export interface LinuxPortForwardBridge { + port: number + socketPath: string + bridgeProcess: ChildProcess +} + +/** + * Probe whether something is actively listening on 127.0.0.1: by + * attempting a real TCP connect. A successful 'connect' event proves a + * listener has bound and is accepting connections on that port; a + * connection-refused error or timeout means nothing is listening yet (the + * caller should retry). This is used to verify the host-side socat bridge + * actually bound the requested TCP port, rather than merely existing as a + * process. + */ +function probeTcpListening(port: number, timeoutMs: number): Promise { + return new Promise(resolve => { + const socket = net.connect({ host: '127.0.0.1', port }) + const done = (result: boolean): void => { + socket.removeAllListeners() + socket.destroy() + resolve(result) + } + socket.setTimeout(timeoutMs) + socket.once('connect', () => done(true)) + socket.once('timeout', () => done(false)) + socket.once('error', () => done(false)) + }) +} + +/** + * Initialize host-side reverse port-forward bridges for Linux sandbox networking. + * + * ARCHITECTURE NOTE: + * This is the REVERSE direction of initializeLinuxNetworkBridge above. That + * function lets processes INSIDE the sandbox's isolated network namespace + * reach OUT to host proxy servers. This function does the opposite: it lets + * the HOST reach a TCP server that a SANDBOXED process binds on + * 127.0.0.1: inside its own isolated network namespace. + * + * Since bwrap --unshare-net gives the sandbox a fully isolated network + * namespace, the host cannot directly connect to a port the sandboxed + * process listens on. To bridge this without opening up the network + * namespace generally, we: + * + * 1. Host side (this function): For each configured port, spawn a socat + * process that listens on 127.0.0.1: on the HOST and forwards each + * connection to a unique Unix socket in tmpdir. + * 2. Sandbox side (a later step): Bind that Unix socket into the isolated + * namespace and run a socat listener there that connects the Unix socket + * to the sandboxed process's own TCP server on 127.0.0.1:. + * + * Only the ports explicitly listed in `ports` are bridged — no other network + * access is granted by this mechanism. + * + * DEPENDENCIES: Requires socat. + */ +export async function initializeLinuxPortForwardBridges( + ports: number[], + socatPath?: string, +): Promise { + if (ports.length === 0) { + return [] + } + + const socat = socatPath ?? 'socat' + const bridges: LinuxPortForwardBridge[] = [] + + const cleanupAll = () => { + for (const bridge of bridges) { + if (bridge.bridgeProcess.pid) { + try { + process.kill(bridge.bridgeProcess.pid, 'SIGTERM') + } catch { + // Ignore errors + } + } + try { + fs.unlinkSync(bridge.socketPath) + } catch { + // Ignore errors (placeholder file may not have been created, or + // already replaced/removed) + } + } + } + + for (const port of ports) { + const socketPath = join( + tmpdir(), + `claude-portfwd-${port}-${randomBytes(8).toString('hex')}.sock`, + ) + + // PRE-EXISTING BUG FIX (minimal, flagged): wrapCommandWithSandboxLinux + // requires exposeLoopbackPorts[].socketPath to already exist on the host + // before bwrap can --bind it into the sandbox. The host-side bridge + // spawned below is a UNIX-CONNECT *client* (it connects to socketPath, + // it does not create it), so nothing ever created this placeholder file + // — bwrap's precheck would always throw. Create an empty regular file + // now as a bind-mount target; the in-sandbox UNIX-LISTEN socat (see + // buildSandboxCommand's exposeLoopbackPorts branch, which passes + // `unlink-early`) replaces it with the real socket once the sandbox + // starts. + fs.closeSync(fs.openSync(socketPath, 'w')) + + const socatArgs = [ + `TCP-LISTEN:${port},fork,reuseaddr,bind=127.0.0.1`, + `UNIX-CONNECT:${socketPath}`, + ] + + logForDebugging( + `Starting port-forward bridge for port ${port}: ${socat} ${socatArgs.join(' ')}`, + ) + + const bridgeProcess = spawn(socat, socatArgs, { + stdio: 'ignore', + }) + + // Tracks real process exit (as opposed to ChildProcess#killed, which is + // only true when *we* called process.kill() on it — it stays false when + // socat exits on its own, e.g. because the requested TCP port is already + // in use and it gets EADDRINUSE). The readiness loop below reads this + // flag instead of `.killed` so a dead-on-arrival bridge is detected. + let bridgeExited = false + let bridgeExitInfo: + | { code: number | null; signal: NodeJS.Signals | null } + | undefined + + // Add error and exit handlers to monitor bridge health. These must be + // registered before the !pid check: when spawn fails (e.g. socat is + // missing or not executable), the ChildProcess emits an asynchronous + // 'error' event, and throwing first would leave that event without a + // listener — surfacing as an uncaughtException instead of the rejection + // below. + bridgeProcess.on('error', err => { + logForDebugging( + `Port-forward bridge process error (port ${port}): ${err}`, + { level: 'error' }, + ) + }) + bridgeProcess.on('exit', (code, signal) => { + bridgeExited = true + bridgeExitInfo = { code, signal } + logForDebugging( + `Port-forward bridge process exited (port ${port}) with code ${code}, signal ${signal}`, + { level: code === 0 ? 'info' : 'error' }, + ) + }) + + if (!bridgeProcess.pid) { + cleanupAll() + try { + fs.unlinkSync(socketPath) + } catch { + // Ignore errors + } + throw new Error( + `Failed to start port-forward bridge process for port ${port}`, + ) + } + + bridges.push({ port, socketPath, bridgeProcess }) + + // Wait for the bridge to actually be listening on the host TCP port. + // + // Unlike initializeLinuxNetworkBridge above (where the host side is the + // UNIX-LISTEN/socket-creating side, so fs.existsSync genuinely proves + // that socat has bound its socket), this bridge's socketPath is an + // empty placeholder file created unconditionally *before* socat even + // starts (see the PRE-EXISTING BUG FIX comment above), purely so bwrap's + // --bind precondition can pass later. Its existence says nothing about + // whether socat has bound 127.0.0.1:. Instead, probe the actual + // TCP port: a successful connect proves socat is listening and + // accepting, regardless of what happens to the UNIX-CONNECT side after. + const maxAttempts = 5 + let ready = false + for (let i = 0; i < maxAttempts; i++) { + if (!bridgeProcess.pid || bridgeExited) { + cleanupAll() + throw new Error( + `Port-forward bridge process for port ${port} exited unexpectedly ` + + `(code=${bridgeExitInfo?.code}, signal=${bridgeExitInfo?.signal}) before becoming ready`, + ) + } + + let probedListening = false + try { + probedListening = await probeTcpListening(port, 250) + } catch (err) { + logForDebugging( + `Error probing port-forward bridge for port ${port} (attempt ${i + 1}): ${err}`, + { level: 'error' }, + ) + } + + if (probedListening) { + // A successful connect proves *some* process is listening on the + // port, but socat's own bind failure (e.g. EADDRINUSE because + // another process already held the port) can race with this + // check: the connect may succeed against that other process before + // socat's near-instant exit event has fired. Give the exit event a + // brief grace period and re-check bridgeExited before trusting the + // probe. + await new Promise(resolve => setTimeout(resolve, 50)) + if (bridgeExited) { + cleanupAll() + throw new Error( + `Port-forward bridge process for port ${port} exited unexpectedly ` + + `(code=${bridgeExitInfo?.code}, signal=${bridgeExitInfo?.signal}) before becoming ready`, + ) + } + logForDebugging( + `Port-forward bridge for port ${port} ready after ${i + 1} attempts`, + ) + ready = true + break + } + + if (i === maxAttempts - 1) { + break + } + + await new Promise(resolve => setTimeout(resolve, i * 100)) + } + + if (!ready) { + cleanupAll() + throw new Error( + `Failed to create port-forward bridge socket for port ${port} after ${maxAttempts} attempts` + + (bridgeExited + ? ` (bridge process exited: code=${bridgeExitInfo?.code}, signal=${bridgeExitInfo?.signal})` + : ''), + ) + } + } + + return bridges +} + /** * Resolve how to invoke apply-seccomp: either a standalone binary path, or a * multicall-binary prefix that dispatches on the ARGV0 env var. @@ -785,12 +1037,13 @@ function resolveApplySeccompPrefix( * Sets up HTTP proxy on port 3128 and SOCKS proxy on port 1080 */ function buildSandboxCommand( - httpSocketPath: string, - socksSocketPath: string, + httpSocketPath: string | undefined, + socksSocketPath: string | undefined, userCommand: string, applySeccompPrefix: string | undefined, shell?: string, socatPath?: string, + exposeLoopbackPorts?: Array<{ port: number; socketPath: string }>, ): string { // Default to bash for backward compatibility const shellPath = shell || 'bash' @@ -798,9 +1051,34 @@ function buildSandboxCommand( // socatPath resolves to the same binary inside bwrap. const socat = quote([socatPath ?? 'socat']) const socatCommands = [ - `${socat} TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`, - `${socat} TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`, - 'trap "kill %1 %2 2>/dev/null; exit" EXIT', + // Proxy listeners only start when proxy sockets are actually provided — + // a fully network-blocked sandbox (empty allowedDomains, no proxy) can + // still request exposeLoopbackPorts on their own (see call site). + ...(httpSocketPath + ? [ + `${socat} TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`, + ] + : []), + ...(socksSocketPath + ? [ + `${socat} TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`, + ] + : []), + ...(exposeLoopbackPorts ?? []).map( + // PRE-EXISTING BUG FIX (minimal, flagged): the bind-mounted socketPath + // already exists on the host as an empty placeholder regular file (see + // initializeLinuxPortForwardBridges), needed so bwrap's --bind precheck + // passes. socat's UNIX-LISTEN refuses to bind when the target path + // already exists ("File exists"), even with `reuseaddr` — only + // `unlink-early` makes it remove the placeholder and create the real + // socket in its place. + ({ port, socketPath }) => + `${socat} UNIX-LISTEN:${socketPath},fork,reuseaddr,unlink-early TCP:127.0.0.1:${port} >/dev/null 2>&1 &`, + ), + // Single-quoted so `$(jobs -p)` is deferred to trap-fire time (after + // all background socat commands above have started), not expanded + // immediately when this string is constructed/registered. + "trap 'kill $(jobs -p) 2>/dev/null; exit' EXIT", ] // apply-seccomp runs after socat so socat can still create Unix sockets. @@ -1715,6 +1993,7 @@ export async function wrapCommandWithSandboxLinux( socatPath, observeSocketPath, abortSignal, + exposeLoopbackPorts, } = params // Determine if we have restrictions to apply @@ -1839,6 +2118,22 @@ export async function wrapCommandWithSandboxLinux( // This removes all network interfaces, effectively blocking all network bwrapArgs.push('--unshare-net') + // Bind-mount each reverse loopback port-forward socket so the + // sandboxed process's own socat listener (started in + // buildSandboxCommand) can bind that path inside the namespace. This + // is independent of whether a proxy is configured: a fully + // network-blocked sandbox (empty allowedDomains, no proxy sockets) + // can still expose specific loopback ports. + for (const { port, socketPath } of exposeLoopbackPorts ?? []) { + if (!fs.existsSync(socketPath)) { + throw new Error( + `Linux loopback port-forward bridge socket for port ${port} does not exist: ${socketPath}. ` + + 'The bridge process may have died. Try reinitializing the sandbox.', + ) + } + bwrapArgs.push('--bind', socketPath, socketPath) + } + // If proxy sockets are provided, bind them into the sandbox to allow // filtered network access through the proxy. If not provided, network // is completely blocked (empty allowedDomains = block all) @@ -1983,8 +2278,14 @@ export async function wrapCommandWithSandboxLinux( // With network restrictions, route the command through buildSandboxCommand // so socat starts before seccomp is applied. Otherwise invoke apply-seccomp - // directly if we have a binary. - if (needsNetworkRestriction && httpSocketPath && socksSocketPath) { + // directly if we have a binary. Triggers whenever there's a proxy bridge + // OR loopback ports to expose — a fully network-blocked sandbox can still + // request exposeLoopbackPorts on its own. + const hasExposeLoopbackPorts = (exposeLoopbackPorts?.length ?? 0) > 0 + if ( + needsNetworkRestriction && + ((httpSocketPath && socksSocketPath) || hasExposeLoopbackPorts) + ) { const sandboxCommand = buildSandboxCommand( httpSocketPath, socksSocketPath, @@ -1992,6 +2293,7 @@ export async function wrapCommandWithSandboxLinux( applySeccompPrefix, shell, socatPath, + exposeLoopbackPorts, ) bwrapArgs.push(sandboxCommand) } else if (applySeccompPrefix) { diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index baa6052d..c1be6fd1 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -749,6 +749,12 @@ export const NetworkConfigSchema = z.object({ .boolean() .optional() .describe('Whether to allow binding to local ports (default: false)'), + exposeLoopbackPorts: z + .array(z.number().int().min(1).max(65535)) + .optional() + .describe( + 'Linux only: specific 127.0.0.1 ports the sandboxed process binds that the host should be able to reach through the bwrap network-namespace boundary. Each listed port gets its own scoped Unix-socket bridge; no general network access is granted, and ports not listed remain unreachable from outside the sandbox. Has no effect on macOS (no network namespace is created there).', + ), allowMachLookup: z .array( z.string().refine( diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index a7d57319..73560a26 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -43,6 +43,8 @@ import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, type LinuxNetworkBridgeContext, + initializeLinuxPortForwardBridges, + type LinuxPortForwardBridge, checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, @@ -111,6 +113,7 @@ interface HostNetworkManagerContext { httpProxyPort: number socksProxyPort: number linuxBridge: LinuxNetworkBridgeContext | undefined + portForwardBridges: LinuxPortForwardBridge[] | undefined } // ============================================================================ @@ -920,18 +923,28 @@ async function initialize( // Initialize platform-specific infrastructure let linuxBridge: LinuxNetworkBridgeContext | undefined + let portForwardBridges: LinuxPortForwardBridge[] | undefined if (getPlatform() === 'linux') { linuxBridge = await initializeLinuxNetworkBridge( httpProxyPort, socksProxyPort, config.socatPath, ) + + const exposeLoopbackPorts = getExposeLoopbackPorts() + if (exposeLoopbackPorts && exposeLoopbackPorts.length > 0) { + portForwardBridges = await initializeLinuxPortForwardBridges( + exposeLoopbackPorts, + config.socatPath, + ) + } } const context: HostNetworkManagerContext = { httpProxyPort, socksProxyPort, linuxBridge, + portForwardBridges, } managerContext = context logForDebugging('Network infrastructure initialized') @@ -1399,6 +1412,10 @@ function getAllowLocalBinding(): boolean | undefined { return config?.network?.allowLocalBinding } +function getExposeLoopbackPorts(): number[] | undefined { + return config?.network?.exposeLoopbackPorts +} + function getAllowMachLookup(): string[] | undefined { return config?.network?.allowMachLookup } @@ -1470,6 +1487,10 @@ function getLinuxSocksSocketPath(): string | undefined { return managerContext?.linuxBridge?.socksSocketPath } +function getLinuxPortForwardBridges(): LinuxPortForwardBridge[] | undefined { + return managerContext?.portForwardBridges +} + /** * Wait for network initialization to complete if already in progress * Returns true if initialized successfully, false otherwise @@ -1726,6 +1747,16 @@ async function wrapWithSandbox( socatPath: config?.socatPath, observeSocketPath: linuxMonitor?.observeSocketPath, abortSignal, + // Bind-mounting these sockets is only meaningful inside a + // network-namespaced sandbox; without needsNetworkRestriction + // there is no --unshare-net and the sockets would just be + // superfluous host-path binds. + exposeLoopbackPorts: needsNetworkRestriction + ? getLinuxPortForwardBridges()?.map(b => ({ + port: b.port, + socketPath: b.socketPath, + })) + : undefined, }) case 'windows': @@ -2167,6 +2198,31 @@ async function reset(): Promise { } } + if (managerContext?.portForwardBridges) { + const portForwardBridges = managerContext.portForwardBridges + + // Kill all port-forward bridges and wait for them to exit + await Promise.all( + portForwardBridges.map(bridge => + killBridgeProcess(bridge.bridgeProcess, `port-forward:${bridge.port}`), + ), + ) + + // Clean up sockets + for (const bridge of portForwardBridges) { + try { + fs.rmSync(bridge.socketPath, { force: true }) + logForDebugging( + `Cleaned up port-forward socket for port ${bridge.port}`, + ) + } catch (err) { + logForDebugging(`Port-forward socket cleanup error: ${err}`, { + level: 'error', + }) + } + } + } + // Close servers in parallel (only if they exist, i.e., were started by us) const closePromises: Promise[] = [] @@ -2314,6 +2370,7 @@ export interface ISandboxManager { getSocksProxyPort(): number | undefined getLinuxHttpSocketPath(): string | undefined getLinuxSocksSocketPath(): string | undefined + getLinuxPortForwardBridges(): LinuxPortForwardBridge[] | undefined waitForNetworkInitialization(): Promise wrapWithSandbox( command: string, @@ -2370,6 +2427,7 @@ export const SandboxManager: ISandboxManager = { getSocksProxyPort, getLinuxHttpSocketPath, getLinuxSocksSocketPath, + getLinuxPortForwardBridges, waitForNetworkInitialization, wrapWithSandbox, wrapWithSandboxArgv, diff --git a/test/sandbox/linux-loopback-port-forward.test.ts b/test/sandbox/linux-loopback-port-forward.test.ts new file mode 100644 index 00000000..e96741bc --- /dev/null +++ b/test/sandbox/linux-loopback-port-forward.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'bun:test' +import * as net from 'node:net' +import { spawn } from 'node:child_process' +import { unlinkSync } from 'node:fs' +import { isLinux } from '../helpers/platform.js' +import { + initializeLinuxPortForwardBridges, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' + +/** + * Real, no-mock E2E coverage for the reverse loopback port-forward feature: + * a sandboxed process that binds a TCP server on 127.0.0.1: inside an + * `--unshare-net` namespace can be reached from the (unsandboxed) host when + * `exposeLoopbackPorts` is configured, and remains unreachable (isolation + * intact) when it isn't. Uses real bwrap and socat binaries. + */ + +/** Pick a random ephemeral port, then verify it's actually free before use. */ +async function pickFreePort(): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const candidate = 20000 + Math.floor(Math.random() * 20000) + const free = await new Promise(resolve => { + const server = net.createServer() + server.once('error', () => resolve(false)) + server.listen(candidate, '127.0.0.1', () => { + server.close(() => resolve(true)) + }) + }) + if (free) return candidate + } + throw new Error('Could not find a free ephemeral port after 10 attempts') +} + +/** Poll until a TCP connect to 127.0.0.1: succeeds or the deadline passes. */ +async function connectWithRetry( + port: number, + deadlineMs: number, +): Promise { + const start = Date.now() + let lastError: unknown + while (Date.now() - start < deadlineMs) { + try { + const data = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port }, () => {}) + let received = '' + socket.setTimeout(500) + socket.on('data', d => (received += d.toString())) + socket.on('close', () => resolve(received)) + socket.on('timeout', () => { + socket.destroy() + resolve(received) + }) + socket.on('error', reject) + }) + // The host-side bridge accepts the TCP connection immediately, but + // its UNIX-CONNECT to the sandbox side can fail (empty response, + // connection closed) until the in-sandbox socat listener has + // finished its `unlink-early` bind — retry until real data arrives. + if (data.length > 0) return data + } catch (err) { + lastError = err + } + await new Promise(r => setTimeout(r, 100)) + } + throw lastError ?? new Error(`Timed out connecting to port ${port}`) +} + +/** Attempt a single connect, expecting it to fail or time out quickly. */ +async function expectConnectionRefusedOrTimeout( + port: number, + timeoutMs: number, +): Promise { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port }) + const timer = setTimeout(() => { + socket.destroy() + resolve() // timed out without connecting: isolation intact + }, timeoutMs) + socket.on('connect', () => { + clearTimeout(timer) + socket.destroy() + reject(new Error(`Unexpectedly connected to port ${port}`)) + }) + socket.on('error', () => { + clearTimeout(timer) + resolve() // connection refused: isolation intact + }) + }) +} + +const d = isLinux ? describe : describe.skip + +d('Linux loopback port-forward (real bwrap + socat)', () => { + it('allows the host to reach a TCP server bound inside the network-isolated sandbox when exposeLoopbackPorts is configured', async () => { + const port = await pickFreePort() + const bridge = ( + await initializeLinuxPortForwardBridges([port], undefined) + )[0]! + + let sandboxChild: ReturnType | undefined + try { + const command = await wrapCommandWithSandboxLinux({ + command: `socat TCP-LISTEN:${port},fork,reuseaddr,bind=127.0.0.1 SYSTEM:'echo ok'`, + needsNetworkRestriction: true, + exposeLoopbackPorts: [{ port, socketPath: bridge.socketPath }], + }) + + sandboxChild = spawn(command, { + shell: true, + stdio: 'ignore', + detached: true, + }) + + const response = await connectWithRetry(port, 5000) + expect(response.trim()).toBe('ok') + } finally { + if (sandboxChild?.pid) { + try { + process.kill(-sandboxChild.pid, 'SIGKILL') + } catch { + // Ignore errors (process may already be gone) + } + } + if (bridge.bridgeProcess.pid) { + try { + process.kill(bridge.bridgeProcess.pid, 'SIGKILL') + } catch { + // Ignore errors + } + } + try { + unlinkSync(bridge.socketPath) + } catch { + // Ignore errors (may already be removed) + } + } + }, 15000) + + it('keeps network isolation intact (host cannot reach the sandboxed port) when exposeLoopbackPorts is not configured', async () => { + const port = await pickFreePort() + + let sandboxChild: ReturnType | undefined + try { + const command = await wrapCommandWithSandboxLinux({ + command: `socat TCP-LISTEN:${port},fork,reuseaddr,bind=127.0.0.1 SYSTEM:'echo ok'`, + needsNetworkRestriction: true, + }) + + sandboxChild = spawn(command, { + shell: true, + stdio: 'ignore', + detached: true, + }) + + // Give the sandboxed socat listener a moment to actually start + // (inside its own isolated namespace) before probing from the host. + await new Promise(r => setTimeout(r, 500)) + + await expectConnectionRefusedOrTimeout(port, 2000) + } finally { + if (sandboxChild?.pid) { + try { + process.kill(-sandboxChild.pid, 'SIGKILL') + } catch { + // Ignore errors (process may already be gone) + } + } + } + }, 10000) +}) diff --git a/test/sandbox/linux-port-forward-bridge-bind-conflict.test.ts b/test/sandbox/linux-port-forward-bridge-bind-conflict.test.ts new file mode 100644 index 00000000..01fe7e08 --- /dev/null +++ b/test/sandbox/linux-port-forward-bridge-bind-conflict.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'bun:test' +import * as net from 'node:net' +import { execSync } from 'node:child_process' +import { isLinux } from '../helpers/platform.js' +import { initializeLinuxPortForwardBridges } from '../../src/sandbox/linux-sandbox-utils.js' + +/** + * Regression test for the vacuous-readiness bug: previously, + * initializeLinuxPortForwardBridges' readiness loop only checked + * fs.existsSync(socketPath), a placeholder file created unconditionally + * *before* socat even starts. This meant that if socat failed to bind the + * requested TCP port (e.g. EADDRINUSE because something else already holds + * it) and exited quickly, the placeholder file's existence still reported + * "ready", silently swallowing a dead-on-arrival bridge. + * + * This test occupies a real port with a plain net.createServer() first (no + * mocks), then asserts initializeLinuxPortForwardBridges rejects instead of + * falsely reporting success — proving readiness is now tied to an actual + * TCP connect probe against the bridged port. + */ + +/** Pick a random ephemeral port, then verify it's actually free before use. */ +async function pickFreePort(): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const candidate = 20000 + Math.floor(Math.random() * 20000) + const free = await new Promise(resolve => { + const server = net.createServer() + server.once('error', () => resolve(false)) + server.listen(candidate, '127.0.0.1', () => { + server.close(() => resolve(true)) + }) + }) + if (free) return candidate + } + throw new Error('Could not find a free ephemeral port after 10 attempts') +} + +function occupyPort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(port, '127.0.0.1', () => resolve(server)) + }) +} + +function closeServer(server: net.Server): Promise { + return new Promise(resolve => server.close(() => resolve())) +} + +const d = isLinux ? describe : describe.skip + +d('initializeLinuxPortForwardBridges bind conflict', () => { + it('rejects instead of falsely reporting readiness when the port is already occupied', async () => { + const port = await pickFreePort() + const occupyingServer = await occupyPort(port) + + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(initializeLinuxPortForwardBridges([port])).rejects.toThrow() + } finally { + await closeServer(occupyingServer) + + // Confirm no leaked socat process remains bound to this port. + let psOutput = '' + try { + psOutput = execSync('ps aux | grep socat | grep -v grep', { + encoding: 'utf8', + }) + } catch { + // grep exits non-zero when there are no matches at all — that's the + // expected clean state. + psOutput = '' + } + expect(psOutput.includes(`TCP-LISTEN:${port},`)).toBe(false) + } + }, 15000) +}) diff --git a/test/sandbox/linux-port-forward-bridge-spawn-error.test.ts b/test/sandbox/linux-port-forward-bridge-spawn-error.test.ts new file mode 100644 index 00000000..1ce57555 --- /dev/null +++ b/test/sandbox/linux-port-forward-bridge-spawn-error.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { initializeLinuxPortForwardBridges } from '../../src/sandbox/linux-sandbox-utils.js' + +// Mirrors linux-bridge-spawn-error.test.ts: when spawn() cannot start socat +// (e.g. the binary is missing or not executable), the ChildProcess gets no +// pid and emits an asynchronous 'error' event. initializeLinuxPortForwardBridges +// must have an 'error' listener attached before it throws on the missing pid — +// otherwise the queued event fires with no listener and escalates to an +// uncaughtException, crashing the host process even though the caller +// handled the rejection. +describe('initializeLinuxPortForwardBridges spawn failure', () => { + const uncaught: Error[] = [] + const onUncaught = (err: Error): void => { + uncaught.push(err) + } + + beforeEach(() => { + uncaught.length = 0 + process.on('uncaughtException', onUncaught) + }) + + afterEach(() => { + process.off('uncaughtException', onUncaught) + }) + + test('rejects without an unhandled error event for a single port when socat cannot be spawned', async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect( + initializeLinuxPortForwardBridges([0], '/nonexistent-for-test/socat'), + ).rejects.toThrow('Failed to start port-forward bridge process') + + // Give the queued 'error' event a tick to fire so we can assert it was + // absorbed by the bridge's own listener. + await new Promise(r => setTimeout(r, 50)) + + expect(uncaught).toEqual([]) + }) + + test('rejects cleanly for multiple ports with no leaked bridge processes when the first spawn fails', async () => { + // The bogus socat path fails deterministically on the very first port, + // exercising the same partial-cleanup path that would run if a later + // port in a multi-port list failed after earlier bridges started: the + // cleanupAll() helper in initializeLinuxPortForwardBridges must + // terminate every bridge process spawned so far and not leave any + // running processes behind. + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect( + initializeLinuxPortForwardBridges( + [0, 1, 2], + '/nonexistent-for-test/socat', + ), + ).rejects.toThrow('Failed to start port-forward bridge process') + + await new Promise(r => setTimeout(r, 50)) + + expect(uncaught).toEqual([]) + }) +})