diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5048392..bbcafb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,14 +40,17 @@ jobs: # Linux only: install bubblewrap + slirp4netns so the real-kernel sandbox # integration tests run (they skip when `bwrap` is absent, e.g. macOS/dev). - # Ubuntu 24.04 restricts unprivileged user namespaces via AppArmor — relax - # it so bwrap can unshare namespaces on the runner. + # · Ubuntu 24.04 restricts unprivileged user namespaces via AppArmor — + # relax it so bwrap can unshare namespaces on the runner. + # · The selective network-allowlist test runs an allowlisting DNS proxy on + # 127.0.0.1:53; relax ip_unprivileged_port_start so :53 binds rootless. - name: Install sandbox tools (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y bubblewrap slirp4netns curl sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + sudo sysctl -w net.ipv4.ip_unprivileged_port_start=53 || true - name: Typecheck run: pnpm typecheck @@ -59,6 +62,10 @@ jobs: run: pnpm format:check - name: Test + # DC_SANDBOX_NET_TEST opts the selective-allowlist integration test in; + # it self-skips on non-Linux / when bwrap/slirp4netns are absent. + env: + DC_SANDBOX_NET_TEST: '1' run: pnpm test - name: Build diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5078f9..2fc6f36 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -182,14 +182,21 @@ export { type Frontmatter, } from './skills/index.js'; -// Sandbox (M3.5 — macOS sandbox-exec + Linux bwrap) +// Sandbox (M3.5 — macOS sandbox-exec + Linux bwrap; M3.5-ext — slirp4netns +// selective per-domain network allowlist) export { wrapBashCommand, buildMacOsProfile, buildLinuxBwrapArgs, detectPlatform, + spawnNetworkSandbox, + NetworkSandboxUnavailable, + startDnsProxy, type SandboxPlatform, type SandboxedCommand, + type SpawnNetworkSandboxOpts, + type NetworkSandboxHandle, + type DnsProxyHandle, } from './sandbox/index.js'; // MCP client (M3c — stdio transport; http/sse → M3c-ext) + server (`mcp serve`) diff --git a/packages/core/src/sandbox/index.ts b/packages/core/src/sandbox/index.ts index 22c589a..a02bdb6 100644 --- a/packages/core/src/sandbox/index.ts +++ b/packages/core/src/sandbox/index.ts @@ -27,6 +27,13 @@ export { type DnsProxyHandle, } from './dns-proxy.js'; +export { + spawnNetworkSandbox, + NetworkSandboxUnavailable, + type SpawnNetworkSandboxOpts, + type NetworkSandboxHandle, +} from './netns.js'; + export type { BwrapArgsOpts } from './profile.js'; export interface SandboxedCommand { diff --git a/packages/core/src/sandbox/netns-integration.test.ts b/packages/core/src/sandbox/netns-integration.test.ts new file mode 100644 index 0000000..6f79841 --- /dev/null +++ b/packages/core/src/sandbox/netns-integration.test.ts @@ -0,0 +1,78 @@ +// Real-kernel integration test for the selective per-domain network allowlist +// (bwrap + slirp4netns + allowlisting DNS proxy). Proves that a domain on the +// allowlist resolves + connects while everything else is blocked at DNS. +// +// GATED: needs bwrap + slirp4netns + the ability to bind 127.0.0.1:53. The CI +// Linux job installs both tools, relaxes net.ipv4.ip_unprivileged_port_start so +// :53 is bindable rootless, and sets DC_SANDBOX_NET_TEST=1. Skips everywhere +// else (macOS / dev machines). +// Spec: docs/DEVELOPMENT_PLAN.md §3.9a + +import { execSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { SandboxConfig } from '../config/types.js'; +import { spawnNetworkSandbox } from './netns.js'; + +function has(bin: string): boolean { + try { + execSync(`command -v ${bin}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const RUN = + process.env.DC_SANDBOX_NET_TEST === '1' && + process.platform === 'linux' && + has('bwrap') && + has('slirp4netns'); + +interface Res { + code: number | null; + stdout: string; + stderr: string; +} + +async function runNet(userCommand: string, cwd: string, allowedDomains: string[]): Promise { + const config: SandboxConfig = { enabled: true, network: { allowedDomains } }; + const handle = await spawnNetworkSandbox({ userCommand, cwd, config, dnsPort: 53 }); + let stdout = ''; + let stderr = ''; + handle.child.stdout?.on('data', (d: Buffer) => (stdout += d.toString())); + handle.child.stderr?.on('data', (d: Buffer) => (stderr += d.toString())); + const code = await handle.exited; + await handle.close(); + return { code, stdout, stderr }; +} + +describe.skipIf(!RUN)('selective network allowlist (slirp4netns, real-kernel)', () => { + let cwd: string; + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-netns-it-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('allows an allowlisted domain and blocks everything else', async () => { + const cmd = [ + 'curl -sS --max-time 15 -o /dev/null -w "ALLOWED=%{http_code}\\n" https://example.com 2>&1 || echo "ALLOWED_ERR=$?"', + 'curl -sS --max-time 15 -o /dev/null -w "DENIED=%{http_code}\\n" https://github.com 2>&1 || echo "DENIED_ERR=$?"', + ].join('\n'); + const r = await runNet(cmd, cwd, ['example.com', 'www.example.com']); + // The allowlisted domain resolves (via our proxy → upstream) and connects. + expect(r.stdout).toMatch(/ALLOWED=2\d\d/); + // The non-allowlisted domain gets NXDOMAIN from our proxy → can't resolve. + expect(r.stdout).toMatch(/Could not resolve host: github\.com|DENIED_ERR=6/i); + expect(r.stdout).not.toMatch(/DENIED=2\d\d/); + }, 45_000); + + it('resolv.conf inside the sandbox points at the slirp gateway', async () => { + const r = await runNet('cat /etc/resolv.conf', cwd, ['example.com']); + expect(r.stdout).toContain('nameserver 10.0.2.2'); + }, 20_000); +}); diff --git a/packages/core/src/sandbox/netns.ts b/packages/core/src/sandbox/netns.ts new file mode 100644 index 0000000..6a2137b --- /dev/null +++ b/packages/core/src/sandbox/netns.ts @@ -0,0 +1,316 @@ +// Linux selective network allowlist: bwrap (own netns) + slirp4netns (rootless +// userspace NAT for connectivity) + the allowlisting DNS proxy (NXDOMAIN for +// non-allowed domains). The guest's resolv.conf points at the slirp gateway +// (10.0.2.2 → host loopback) where the proxy listens on :53. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9a +// +// THREAT MODEL: DNS-NAME allowlisting. A process that dials a raw IP bypasses +// the allowlist (it never resolves a name). This is adequate for the typical +// agent workload (git / npm / pip over https://host). slirp4netns --disable-dns +// closes the built-in 10.0.2.3 resolver so resolution can ONLY go through our +// allowlisting proxy. +// +// REQUIRES: `bwrap`, `slirp4netns`, and the ability to bind 127.0.0.1:53 (a +// privileged port — needs CAP_NET_BIND_SERVICE or a relaxed +// net.ipv4.ip_unprivileged_port_start). When the proxy can't bind, +// spawnNetworkSandbox throws NetworkSandboxUnavailable so callers fail CLOSED +// (deny-all network) rather than running the command unrestricted. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Readable, Writable } from 'node:stream'; +import type { SandboxConfig } from '../config/types.js'; +import { startDnsProxy, type DnsProxyHandle } from './dns-proxy.js'; +import { buildLinuxBwrapArgs } from './profile.js'; + +/** slirp4netns gateway — maps to the host loopback (no --disable-host-loopback). */ +const SLIRP_GATEWAY = '10.0.2.2'; +const SLIRP_TAP = 'tap0'; +const SLIRP_MTU = 65520; +const DEFAULT_DNS_PORT = 53; +const DEFAULT_READY_TIMEOUT_MS = 10_000; + +/** Thrown when the selective allowlist can't be set up; callers fail closed. */ +export class NetworkSandboxUnavailable extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkSandboxUnavailable'; + } +} + +export interface SpawnNetworkSandboxOpts { + /** The user shell command to run inside the sandbox. */ + userCommand: string; + /** Working directory (rw-bound inside the sandbox). */ + cwd: string; + /** Sandbox config; network.allowedDomains is expected to be a non-empty allowlist. */ + config: SandboxConfig; + /** Override the bwrap binary (tests / non-standard installs). */ + bwrapPath?: string; + /** Override the slirp4netns binary. */ + slirpPath?: string; + /** Upstream resolver for ALLOWED lookups (default 1.1.1.1). */ + dnsUpstream?: string; + /** Host loopback port for the DNS proxy. MUST be 53 for the guest glibc resolver. */ + dnsPort?: number; + /** Milliseconds to wait for child-pid + slirp readiness before failing. */ + readyTimeoutMs?: number; + /** Diagnostic logger. */ + log?: (line: string) => void; +} + +export interface NetworkSandboxHandle { + /** The spawned bwrap process. stdout = stdio[1], stderr = stdio[2]. */ + child: ChildProcess; + /** Resolves with the bwrap exit code once the sandboxed command finishes. */ + exited: Promise; + /** Tear down slirp4netns + DNS proxy + temp dir. Idempotent. */ + close(): Promise; +} + +/** + * Spawn a bwrap sandbox whose network is restricted to `config.network.allowedDomains`. + * + * Orchestration: + * 1. Start the allowlisting DNS proxy on 127.0.0.1:53. + * 2. bwrap --unshare-net (own netns) with our resolv.conf bound + --info-fd + * (to learn the child PID) + --block-fd (to gate the inner command until + * the network is wired up). + * 3. slirp4netns attaches to the child's netns (entering its userns first) and + * provides rootless outbound connectivity via tap0. + * 4. Once slirp signals ready, release --block-fd so the command runs. + * + * On any setup failure this rejects with NetworkSandboxUnavailable after cleaning up. + */ +export async function spawnNetworkSandbox( + opts: SpawnNetworkSandboxOpts, +): Promise { + const log = opts.log ?? (() => {}); + const dnsPort = opts.dnsPort ?? DEFAULT_DNS_PORT; + const readyTimeout = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + const domains = opts.config.network?.allowedDomains ?? []; + + // 1. Allowlisting DNS proxy on the host loopback. Must be :53 because the + // guest's glibc resolver always queries nameservers on port 53. + let dns: DnsProxyHandle; + try { + dns = await startDnsProxy({ + allowedDomains: domains, + upstream: opts.dnsUpstream, + bindAddr: '127.0.0.1', + bindPort: dnsPort, + log, + }); + } catch (err) { + throw new NetworkSandboxUnavailable( + `cannot bind DNS allowlist proxy on 127.0.0.1:${dnsPort} (${errMsg(err)}); selective ` + + `network allowlisting needs CAP_NET_BIND_SERVICE or a relaxed ` + + `net.ipv4.ip_unprivileged_port_start`, + ); + } + + // 2. Temp dir + resolv.conf pointing at the slirp gateway. + const work = await mkdtemp(join(tmpdir(), 'dc-netns-')); + const resolvSrc = join(work, 'resolv.conf'); + await writeFile(resolvSrc, `nameserver ${SLIRP_GATEWAY}\noptions timeout:2 attempts:2\n`, 'utf8'); + // /etc/resolv.conf is usually a dangling symlink (→ /run/systemd/resolve/...) + // that bwrap can't create a bind target for; bind at the resolved real path. + let resolvDest = '/etc/resolv.conf'; + try { + resolvDest = await realpath('/etc/resolv.conf'); + } catch { + /* absent / not a symlink — bind directly */ + } + + // 3. bwrap with its own netns + our resolv.conf + info/block fds. + // --uid 0 --gid 0 maps the host user to root INSIDE bwrap's user namespace. + // This is what lets slirp4netns (running as the host user, which owns that + // userns) gain CAP_SYS_ADMIN on entry and setns() into the netns — without + // it, setns(CLONE_NEWNET) fails with EPERM. + const bwrapArgs = buildLinuxBwrapArgs(opts.config, opts.cwd, { + dnsProxyPort: dnsPort, + resolvConfPath: resolvSrc, + resolvConfDest: resolvDest, + }); + const args = [ + ...bwrapArgs, + '--uid', + '0', + '--gid', + '0', + '--info-fd', + '3', + '--block-fd', + '4', + '/bin/sh', + '-c', + opts.userCommand, + ]; + // stdio: 0 ignore · 1/2 piped (caller captures) · 3 info-fd (we read) · 4 block-fd (we write) + const child = spawn(opts.bwrapPath ?? 'bwrap', args, { + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + cwd: opts.cwd, + }); + // Swallow stream/process errors so a SIGTERM-induced ECONNRESET on the info / + // block / stdio pipes during teardown doesn't surface as an unhandled error. + ignoreErrors(child); + child.stdio.forEach((s) => ignoreErrors(s)); + + let slirp: ChildProcess | undefined; + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + killQuietly(slirp); + killQuietly(child); + await dns.close().catch(() => {}); + await rm(work, { recursive: true, force: true }).catch(() => {}); + }; + + try { + // 4. Read the sandbox child-pid from --info-fd (a host-visible PID). + const childPid = await readChildPid(child.stdio[3] as Readable, child, readyTimeout); + log(`[netns] bwrap child-pid=${childPid}`); + + // 5. Attach slirp4netns to the sandbox's netns by PID. slirp enters the + // target's userns (where the host user is now root, via --uid 0) before + // the netns, so the setns is permitted. --disable-dns closes slirp's + // built-in 10.0.2.3 resolver so ALL resolution must traverse our proxy. + slirp = spawn( + opts.slirpPath ?? 'slirp4netns', + [ + '--configure', + '--disable-dns', + `--mtu=${SLIRP_MTU}`, + '--ready-fd', + '3', + String(childPid), + SLIRP_TAP, + ], + { stdio: ['ignore', 'pipe', 'pipe', 'pipe'] }, + ); + ignoreErrors(slirp); + slirp.stdio.forEach((s) => ignoreErrors(s)); + pipeLog(slirp.stdio[1] as Readable | null, '[slirp]', log); + pipeLog(slirp.stdio[2] as Readable | null, '[slirp!]', log); + + // 6. Wait for slirp to signal the interface is configured. + await waitForReady(slirp.stdio[3] as Readable, slirp, 'slirp4netns ready', readyTimeout); + log('[netns] slirp4netns ready'); + + // 7. Release the inner command — network is now wired up. + const blockFd = child.stdio[4] as Writable; + blockFd.write('go'); + blockFd.end(); + } catch (err) { + await close(); + throw err instanceof NetworkSandboxUnavailable + ? err + : new NetworkSandboxUnavailable(`network sandbox setup failed: ${errMsg(err)}`); + } + + // 8. Auto-teardown slirp + proxy + tmp when the sandboxed command exits. + const exited = new Promise((resolve) => { + child.once('close', (code) => { + void close(); + resolve(code); + }); + }); + + return { child, exited, close }; +} + +/** Parse the `child-pid` out of bwrap's --info-fd JSON (tolerant of chunking). */ +function readChildPid(fd: Readable, child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error('timed out reading bwrap --info-fd')); + }, timeoutMs); + const onData = (d: Buffer): void => { + buf += d.toString('utf8'); + const m = buf.match(/"child-pid"\s*:\s*(\d+)/); + if (m) { + cleanup(); + resolve(Number(m[1])); + } + }; + const onExit = (): void => { + cleanup(); + reject(new Error('bwrap exited before emitting child-pid')); + }; + function cleanup(): void { + clearTimeout(timer); + fd.off('data', onData); + child.off('exit', onExit); + } + fd.on('data', onData); + child.once('exit', onExit); + }); +} + +/** Resolve when the process writes any byte to `fd` (e.g. slirp --ready-fd). */ +function waitForReady( + fd: Readable, + proc: ChildProcess, + label: string, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for ${label}`)); + }, timeoutMs); + const onData = (): void => { + cleanup(); + resolve(); + }; + const onExit = (): void => { + cleanup(); + reject(new Error(`process exited before ${label}`)); + }; + function cleanup(): void { + clearTimeout(timer); + fd.off('data', onData); + proc.off('exit', onExit); + } + fd.on('data', onData); + proc.once('exit', onExit); + }); +} + +function pipeLog(fd: Readable | null, prefix: string, log: (s: string) => void): void { + if (!fd) return; + fd.on('data', (d: Buffer) => { + const s = d.toString('utf8').trimEnd(); + if (s) log(`${prefix} ${s}`); + }); +} + +/** + * Attach a no-op 'error' listener so a stream/process error during teardown + * (e.g. ECONNRESET on the stdio pipes when slirp/bwrap is SIGTERM'd) doesn't + * bubble up as an unhandled error. Accepts ChildProcess, streams, or null. + */ +function ignoreErrors( + emitter: { on(event: 'error', cb: (err: unknown) => void): unknown } | null | undefined, +): void { + emitter?.on('error', () => {}); +} + +function killQuietly(proc: ChildProcess | undefined): void { + if (proc && !proc.killed) { + try { + proc.kill('SIGTERM'); + } catch { + /* already gone */ + } + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/core/src/sandbox/profile.ts b/packages/core/src/sandbox/profile.ts index f82becd..df97aca 100644 --- a/packages/core/src/sandbox/profile.ts +++ b/packages/core/src/sandbox/profile.ts @@ -4,9 +4,11 @@ // // M3.5: macOS sandbox-exec SBPL profile generation + Linux bwrap arg generation // (ro system mounts, rw cwd, read/write allowlists, net unshare, pid/ipc/uts -// unshare, --new-session + --die-with-parent hardening). The one remaining gap -// is the selective-domain net allowlist, which needs a slirp4netns helper to -// bridge UDP into the netns (deny-all-net and full-net modes both work today). +// unshare, --new-session + --die-with-parent hardening). +// M3.5-ext: the selective-domain net allowlist is implemented in netns.ts +// (bwrap own-netns + slirp4netns userspace NAT + allowlisting DNS proxy); this +// module just emits the bwrap args (--unshare-net + the resolv.conf bind) that +// netns.ts orchestrates. deny-all-net and full-net modes work standalone here. // Windows: disabled per §0.2. import { homedir, platform } from 'node:os'; @@ -140,6 +142,14 @@ export interface BwrapArgsOpts { dnsProxyPort?: number; /** Path to a generated resolv.conf to bind into the sandbox. */ resolvConfPath?: string; + /** + * In-sandbox destination for the resolv.conf bind. Defaults to + * `/etc/resolv.conf`, but on systemd hosts that path is a dangling symlink + * (→ /run/systemd/resolve/stub-resolv.conf) which bwrap can't create a bind + * target for. The orchestrator resolves the symlink (realpath) and passes the + * real path here so the preserved /etc/resolv.conf symlink leads to our file. + */ + resolvConfDest?: string; } export function buildLinuxBwrapArgs( @@ -185,7 +195,7 @@ export function buildLinuxBwrapArgs( } else if (whitelisted) { args.push('--unshare-net'); if (opts.resolvConfPath) { - args.push('--ro-bind', opts.resolvConfPath, '/etc/resolv.conf'); + args.push('--ro-bind', opts.resolvConfPath, opts.resolvConfDest ?? '/etc/resolv.conf'); } }