Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 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.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.

Expand Down Expand Up @@ -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:<port>` 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:<port>` 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
Expand Down
247 changes: 240 additions & 7 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,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:<port>. 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 */
Expand Down Expand Up @@ -755,6 +763,181 @@ export async function initializeLinuxNetworkBridge(
}
}

/**
* A single host-side reverse port-forward bridge, forwarding
* 127.0.0.1:<port> 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
}

/**
* 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:<port> 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:<port> 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:<port>.
*
* 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<LinuxPortForwardBridge[]> {
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',
})

// 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) => {
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 socket to be ready
const maxAttempts = 5
let ready = false
for (let i = 0; i < maxAttempts; i++) {
if (!bridgeProcess.pid || bridgeProcess.killed) {
cleanupAll()
throw new Error(
`Port-forward bridge process died unexpectedly (port ${port})`,
)
}

try {
if (fs.existsSync(socketPath)) {
logForDebugging(
`Port-forward bridge for port ${port} ready after ${i + 1} attempts`,
)
ready = true
break
}
} catch (err) {
logForDebugging(
`Error checking port-forward socket for port ${port} (attempt ${i + 1}): ${err}`,
{ level: 'error' },
)
}

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`,
)
}
}

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.
Expand Down Expand Up @@ -785,22 +968,48 @@ 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'
// Host filesystem is bind-mounted into the sandbox, so an explicit
// 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.
Expand Down Expand Up @@ -1715,6 +1924,7 @@ export async function wrapCommandWithSandboxLinux(
socatPath,
observeSocketPath,
abortSignal,
exposeLoopbackPorts,
} = params

// Determine if we have restrictions to apply
Expand Down Expand Up @@ -1839,6 +2049,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)
Expand Down Expand Up @@ -1983,15 +2209,22 @@ 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,
command,
applySeccompPrefix,
shell,
socatPath,
exposeLoopbackPorts,
)
bwrapArgs.push(sandboxCommand)
} else if (applySeccompPrefix) {
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 @@ -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(
Expand Down
Loading