From 37cfdec85c7271be27540358cec9ceb586852228 Mon Sep 17 00:00:00 2001 From: oratis Date: Tue, 2 Jun 2026 00:05:45 +0800 Subject: [PATCH] feat(sandbox): wire selective network allowlist into BashTool (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BashTool now routes commands through spawnNetworkSandbox when network.allowedDomains is a non-empty allowlist on Linux. If the slirp sandbox can't be set up (e.g. the DNS proxy can't bind 127.0.0.1:53), it FAILS CLOSED — re-runs under deny-all-net with a clear note — rather than running unrestricted. Background commands always fail closed (the slirp helper can't safely outlive the turn). - netns.ts: add pure helpers needsNetworkSandbox() (linux + enabled + non-empty allowlist) and denyAllNetwork() (fail-closed config). - bash.ts: foreground net path (capture/timeout/abort via the handle), fail-closed fallback, background deny-all; shared summarize() helper. - Tests: netns.test.ts (decision + config helpers, runs everywhere) + bash.test.ts wiring tests using an injected fake spawner (net path, fail-closed fallback, background) — no real bwrap needed. - docs/security-model.md: document the Linux allowlist, its DNS-name threat model, the :53 requirement, and the fail-closed behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/security-model.md | 78 +++++++---- packages/core/src/index.ts | 2 + packages/core/src/sandbox/index.ts | 2 + packages/core/src/sandbox/netns.test.ts | 68 +++++++++ packages/core/src/sandbox/netns.ts | 24 ++++ packages/core/src/tools/bash.test.ts | 64 +++++++++ packages/core/src/tools/bash.ts | 178 ++++++++++++++++++++---- 7 files changed, 363 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/sandbox/netns.test.ts diff --git a/docs/security-model.md b/docs/security-model.md index 66dfd8b..ed4d147 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -1,6 +1,6 @@ # DeepCode Security Model -> Last updated: 2026-05-28 (M3.5 hardening + attack-vector test suite landed) +> Last updated: 2026-06-02 (M3.5-ext: Linux selective per-domain network allowlist landed) This document is the **single source of truth** for what DeepCode protects against, what it doesn't, and how each layer composes. If you're reviewing a @@ -12,15 +12,15 @@ against the threat model here. DeepCode is an LLM-driven coding assistant. The threats we care about, in decreasing order of operator severity: -| # | Threat | Severity | Where mitigated | -| --- | ------------------------------------------------------------------------ | ----------------------- | ----------------------------------------------------------------------- | -| 1 | Model exfiltrates DeepSeek API key (or other env secrets) via tool call | High | M3.5 sandbox + M5.1 env strip | -| 2 | Model writes arbitrary files outside the project (`/usr/bin`, `/etc`) | High | M3.5 sandbox + permissions | -| 3 | Plugin (third-party code) does either #1 or #2 | High | M5.1 subprocess + (M5.1-ext) OS sandbox | -| 4 | Hook script (third-party shell snippet) does either #1 or #2 | Medium | M3.5 sandbox wraps Bash; hooks bypass when invoked via /bin/sh directly | -| 5 | Hostile `settings.json` field (e.g. allowRead path) injects sandbox rule | Medium | escapeSbpl() | -| 6 | Untrusted project's AGENTS.md drives the agent into harmful action | Low | Trust store (`/trust`) | -| 7 | DNS exfiltration of secrets from sandboxed Bash | Acknowledged limitation | M3.5-ext userspace proxy | +| # | Threat | Severity | Where mitigated | +| --- | ------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------- | +| 1 | Model exfiltrates DeepSeek API key (or other env secrets) via tool call | High | M3.5 sandbox + M5.1 env strip | +| 2 | Model writes arbitrary files outside the project (`/usr/bin`, `/etc`) | High | M3.5 sandbox + permissions | +| 3 | Plugin (third-party code) does either #1 or #2 | High | M5.1 subprocess + (M5.1-ext) OS sandbox | +| 4 | Hook script (third-party shell snippet) does either #1 or #2 | Medium | M3.5 sandbox wraps Bash; hooks bypass when invoked via /bin/sh directly | +| 5 | Hostile `settings.json` field (e.g. allowRead path) injects sandbox rule | Medium | escapeSbpl() | +| 6 | Untrusted project's AGENTS.md drives the agent into harmful action | Low | Trust store (`/trust`) | +| 7 | DNS exfiltration of secrets from sandboxed Bash | Partly mitigated | M3.5-ext DNS allowlist (netns.ts) — names only; raw-IP dials still pass | ## Defence layers @@ -72,7 +72,8 @@ written to `$TMPDIR/deepcode-sb-*.sb`. Policy: - `denyRead` / `denyWrite` rules appended LAST so they override allows on overlap. - Network: default-allow unless `network.allowedDomains: []` (empty array) - meaning "no network". Domain whitelist needs M3.5-ext (userspace proxy). + meaning "no network". Per-domain allowlisting is NOT available on macOS (SBPL + has no usable remote-host predicate here) — only on Linux (see below). - Unix sockets: blocked unless `network.allowUnixSockets: true`. **Linux — `bwrap` argv** @@ -84,7 +85,28 @@ Generated by `buildLinuxBwrapArgs`: - `/proc`, `/dev`, `/tmp` (tmpfs). - cwd is the only bare `--bind` (rw). - Always `--unshare-pid`, `--unshare-ipc`, `--unshare-uts`. -- `--unshare-net` when `network.allowedDomains: []`. +- `--unshare-net` when `network.allowedDomains: []` (deny-all-net). + +**Linux — selective per-domain allowlist (`netns.ts`)** + +When `network.allowedDomains` is a NON-EMPTY allowlist, `BashTool` runs the +command via `spawnNetworkSandbox`: + +- `bwrap --unshare-net --uid 0 --gid 0` gives the command its own netns; a + generated `resolv.conf` (→ slirp gateway `10.0.2.2`) is bound in. +- `slirp4netns` provides rootless userspace NAT (`tap0`) and is attached to the + sandbox's netns by PID (the `--uid 0` mapping is what lets it `setns`). +- The allowlisting DNS proxy (`dns-proxy.ts`) on `127.0.0.1:53` answers only + allowlisted names and NXDOMAINs the rest; `slirp4netns --disable-dns` closes + the `10.0.2.3` bypass. + +**Threat model + limits:** this is DNS-NAME allowlisting — a process that dials +a raw IP bypasses it (adequate for the git/npm/pip-over-https workload). It +requires binding `127.0.0.1:53` (CAP_NET_BIND_SERVICE or a relaxed +`net.ipv4.ip_unprivileged_port_start`). When that's unavailable, `BashTool` +**fails closed** to deny-all-net (the command runs with no network, with a note) +rather than running unrestricted. Background commands always fail closed (the +slirp helper can't safely outlive the turn). **Windows — not supported.** Sandbox is a no-op (see plan §0.2). @@ -148,7 +170,8 @@ etc.) as **untrusted**. We: - no implicit network when allowedDomains is empty - no implicit file-write to /usr, /System, /Library - **3 bwrap-arg safety** tests: - - no --share-net even with non-empty allowedDomains (until M3.5-ext) + - no --share-net even with a non-empty allowedDomains (connectivity comes from + slirp4netns externally, never by sharing the host netns) - only cwd is bare --bind - always --unshare-{pid,ipc,uts} - **4 excluded-command spoofing** tests: @@ -159,21 +182,26 @@ etc.) as **untrusted**. We: - **2 sandbox-exec e2e** (macOS, runIf the binary exists): - block write to `/usr/local/bin/*` - profile is syntactically valid (smoke) -- **2 bwrap e2e** (Linux, runIf the binary exists): - - block write outside cwd - - DNS unshared when allowedDomains: [] +- **bwrap e2e** (Linux CI, runIf `bwrap` exists — `bwrap-integration.test.ts`): + - write inside the rw-bound cwd succeeds; write to `/etc` is read-only-denied + - `/usr` readable (sandbox is usable); deny-all-net blocks outbound + - block write outside cwd (host file never created) +- **selective net allowlist e2e** (Linux CI, `netns-integration.test.ts`): + - an allowlisted domain returns HTTP 200; a non-allowlisted domain NXDOMAINs + - the sandbox resolv.conf points at the slirp gateway ## What we do NOT yet protect against -| Gap | Tracking | -| -------------------------------------------------- | ------------------------------------- | -| DNS exfil from sandboxed Bash | M3.5-ext (UDP proxy) | -| OS sandbox wrapping the plugin subprocess | M5.1-ext | -| Pipeline analysis (`git ... && rm -rf /`) | M5.2 | -| Domain whitelist enforcement (allowedDomains) | M3.5-ext | -| Image input prompt injection (model multimodal) | v1.1 | -| Side-channel timing leaks (e.g. via exec duration) | Out of scope | -| Local malicious binaries already on $PATH | Out of scope (assume host is trusted) | +| Gap | Tracking | +| -------------------------------------------------- | ---------------------------------------------- | +| Raw-IP egress bypassing the DNS-name allowlist | by design (name-level); IP-level filtering TBD | +| Per-domain allowlist on macOS | SBPL has no usable remote-host predicate | +| Allowlist where `127.0.0.1:53` can't be bound | fails closed to deny-all-net (documented) | +| OS sandbox wrapping the plugin subprocess | M5.1-ext | +| Pipeline analysis (`git ... && rm -rf /`) | M5.2 | +| Image input prompt injection (model multimodal) | v1.1 | +| Side-channel timing leaks (e.g. via exec duration) | Out of scope | +| Local malicious binaries already on $PATH | Out of scope (assume host is trusted) | ## How to file a security issue diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fc6f36..9c21d5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,6 +190,8 @@ export { buildLinuxBwrapArgs, detectPlatform, spawnNetworkSandbox, + needsNetworkSandbox, + denyAllNetwork, NetworkSandboxUnavailable, startDnsProxy, type SandboxPlatform, diff --git a/packages/core/src/sandbox/index.ts b/packages/core/src/sandbox/index.ts index a02bdb6..ba481b6 100644 --- a/packages/core/src/sandbox/index.ts +++ b/packages/core/src/sandbox/index.ts @@ -29,6 +29,8 @@ export { export { spawnNetworkSandbox, + needsNetworkSandbox, + denyAllNetwork, NetworkSandboxUnavailable, type SpawnNetworkSandboxOpts, type NetworkSandboxHandle, diff --git a/packages/core/src/sandbox/netns.test.ts b/packages/core/src/sandbox/netns.test.ts new file mode 100644 index 0000000..ce327d1 --- /dev/null +++ b/packages/core/src/sandbox/netns.test.ts @@ -0,0 +1,68 @@ +// Pure unit tests for the network-sandbox decision helpers. The end-to-end +// orchestration (spawnNetworkSandbox) is exercised by netns-integration.test.ts +// on the Linux CI runner; here we just cover the branch logic that decides +// WHEN to use it and the fail-closed config derivation. + +import { describe, expect, it } from 'vitest'; +import type { SandboxConfig } from '../config/types.js'; +import { denyAllNetwork, needsNetworkSandbox } from './netns.js'; + +describe('needsNetworkSandbox', () => { + const cfg = (network?: SandboxConfig['network'], enabled = true): SandboxConfig => ({ + enabled, + network, + }); + + it('is true for a non-empty allowlist on Linux', () => { + expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'linux')).toBe(true); + }); + + it('is false for an empty allowlist (deny-all-net, handled by --unshare-net)', () => { + expect(needsNetworkSandbox(cfg({ allowedDomains: [] }), 'linux')).toBe(false); + }); + + it('is false when allowedDomains is undefined (full network)', () => { + expect(needsNetworkSandbox(cfg({}), 'linux')).toBe(false); + expect(needsNetworkSandbox(cfg(undefined), 'linux')).toBe(false); + }); + + it('is false on non-Linux platforms (macOS uses sandbox-exec)', () => { + expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'darwin')).toBe(false); + expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'win32')).toBe(false); + }); + + it('is false when the sandbox is disabled', () => { + expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }, false), 'linux')).toBe( + false, + ); + }); + + it('is false for an undefined config', () => { + expect(needsNetworkSandbox(undefined, 'linux')).toBe(false); + }); +}); + +describe('denyAllNetwork', () => { + it('forces allowedDomains to [] (no network)', () => { + const out = denyAllNetwork({ enabled: true, network: { allowedDomains: ['github.com'] } }); + expect(out.network?.allowedDomains).toEqual([]); + }); + + it('preserves other config + network fields', () => { + const out = denyAllNetwork({ + enabled: true, + excludedCommands: ['git'], + network: { allowedDomains: ['a.com'], allowUnixSockets: true }, + }); + expect(out.enabled).toBe(true); + expect(out.excludedCommands).toEqual(['git']); + expect(out.network?.allowUnixSockets).toBe(true); + expect(out.network?.allowedDomains).toEqual([]); + }); + + it('does not mutate the input', () => { + const input: SandboxConfig = { enabled: true, network: { allowedDomains: ['a.com'] } }; + denyAllNetwork(input); + expect(input.network?.allowedDomains).toEqual(['a.com']); + }); +}); diff --git a/packages/core/src/sandbox/netns.ts b/packages/core/src/sandbox/netns.ts index 6a2137b..2e07aaa 100644 --- a/packages/core/src/sandbox/netns.ts +++ b/packages/core/src/sandbox/netns.ts @@ -40,6 +40,30 @@ export class NetworkSandboxUnavailable extends Error { } } +/** + * True iff a command should run under the selective-allowlist network sandbox: + * Linux + sandbox enabled + `network.allowedDomains` is a NON-EMPTY allowlist. + * (An empty array means deny-all-net — handled by plain bwrap --unshare-net; + * `undefined` means full network — no netns orchestration needed.) + */ +export function needsNetworkSandbox( + config: SandboxConfig | undefined, + platform: NodeJS.Platform = process.platform, +): boolean { + if (!config?.enabled || platform !== 'linux') return false; + const domains = config.network?.allowedDomains; + return Array.isArray(domains) && domains.length > 0; +} + +/** + * Derive a deny-all-network config from `config` (allowedDomains: []). Used as + * the fail-closed fallback when the selective allowlist can't be set up — the + * command runs with NO network rather than unrestricted. + */ +export function denyAllNetwork(config: SandboxConfig): SandboxConfig { + return { ...config, network: { ...config.network, allowedDomains: [] } }; +} + export interface SpawnNetworkSandboxOpts { /** The user shell command to run inside the sandbox. */ userCommand: string; diff --git a/packages/core/src/tools/bash.test.ts b/packages/core/src/tools/bash.test.ts index 427761f..b52ae08 100644 --- a/packages/core/src/tools/bash.test.ts +++ b/packages/core/src/tools/bash.test.ts @@ -1,9 +1,23 @@ +import type { ChildProcess } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { NetworkSandboxUnavailable } from '../sandbox/index.js'; +import type { NetworkSandboxHandle } from '../sandbox/index.js'; import { BashTool } from './bash.js'; +// A fake network-sandbox handle: stdout emits `text` then ends; `exited` +// resolves with `code` once stdout drains (so output is captured first). +function fakeNetHandle(text: string, code: number): NetworkSandboxHandle { + const stdout = Readable.from([Buffer.from(text)]); + const stderr = Readable.from([]); + const child = { stdout, stderr } as unknown as ChildProcess; + const exited = new Promise((res) => stdout.once('end', () => res(code))); + return { child, exited, close: async () => {} }; +} + describe('BashTool', () => { let tmp: string; beforeAll(async () => { @@ -61,4 +75,54 @@ describe('BashTool', () => { const suffix = tmp.replace(/^\/tmp\//, '').replace(/^\/private\/tmp\//, ''); expect(r.content).toContain(suffix); }); + + // ── selective network allowlist wiring (M3.5-ext) ──────────────────────── + // platform + spawner are injected so these run on any OS without real bwrap. + const allowlistCtx = (extra: Record) => ({ + cwd: tmp, + sandboxConfig: { enabled: true, network: { allowedDomains: ['example.com'] } }, + sandboxPlatform: 'linux', + ...extra, + }); + + it('routes a non-empty allowlist through the network sandbox', async () => { + let called = false; + const r = await BashTool.execute( + { command: 'echo ignored-by-fake' }, + allowlistCtx({ + sandboxNetSpawn: async () => { + called = true; + return fakeNetHandle('net-sandbox-ran\n', 0); + }, + }) as never, + ); + expect(called).toBe(true); + expect(r.content).toContain('net-sandbox-ran'); + expect(r.data?.exitCode).toBe(0); + expect(r.isError).toBeFalsy(); + }); + + it('fails closed to deny-all-net when the network sandbox is unavailable', async () => { + const r = await BashTool.execute( + { command: 'echo fallback-ran' }, + allowlistCtx({ + sandboxNetSpawn: async () => { + throw new NetworkSandboxUnavailable('cannot bind DNS proxy on 127.0.0.1:53'); + }, + }) as never, + ); + // The fail-closed note is surfaced, and the command still ran (reached the + // close handler via the deny-all-net fallback wrap). + expect(r.content).toContain('network allowlist unavailable'); + expect(r.content).toMatch(/exit:/); + }, 10000); + + it('disables network for background commands under an allowlist', async () => { + const r = await BashTool.execute( + { command: 'echo bg', run_in_background: true }, + allowlistCtx({ sessionDir: tmp }) as never, + ); + expect(r.isError).toBeFalsy(); + expect(r.content).toContain('not supported for background'); + }, 5000); }); diff --git a/packages/core/src/tools/bash.ts b/packages/core/src/tools/bash.ts index 2476073..36d3313 100644 --- a/packages/core/src/tools/bash.ts +++ b/packages/core/src/tools/bash.ts @@ -1,12 +1,23 @@ // Bash tool — execute a shell command with timeout, capture stdout+stderr+exitCode. // Spec: docs/DEVELOPMENT_PLAN.md §3.2 (P0) + run_in_background param // M3.5: optionally wrapped under platform sandbox via ctx.sandboxConfig +// M3.5-ext: when network.allowedDomains is a non-empty allowlist on Linux, run +// under the slirp4netns selective-network sandbox (spawnNetworkSandbox). If +// that can't be set up (e.g. can't bind the DNS proxy on :53), fail CLOSED to +// deny-all-net rather than running unrestricted. import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { wrapBashCommand } from '../sandbox/index.js'; +import { + denyAllNetwork, + needsNetworkSandbox, + NetworkSandboxUnavailable, + spawnNetworkSandbox, + wrapBashCommand, +} from '../sandbox/index.js'; +import type { NetworkSandboxHandle, SpawnNetworkSandboxOpts } from '../sandbox/index.js'; import type { SandboxConfig } from '../config/types.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; @@ -17,6 +28,15 @@ interface BashInput { run_in_background?: boolean; // detach + stream output to a log file } +// ToolContext carries sandbox config (+ optional test seams) from the loop owner. +type SandboxCtx = ToolContext & { + sandboxConfig?: SandboxConfig; + /** Test seam: override the platform used for the net-sandbox decision. */ + sandboxPlatform?: NodeJS.Platform; + /** Test seam: override the network-sandbox spawner. */ + sandboxNetSpawn?: (opts: SpawnNetworkSandboxOpts) => Promise; +}; + const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes const MAX_OUTPUT_BYTES = 30_000; @@ -24,6 +44,86 @@ const MAX_OUTPUT_BYTES = 30_000; // same pid don't collide on a log filename. let bgSeq = 0; +function capStream(s: string, label: string): string { + return s.length > MAX_OUTPUT_BYTES + ? s.slice(0, MAX_OUTPUT_BYTES) + `\n... [${label} truncated]` + : s; +} + +/** Build the standard Bash ToolResult from captured output + exit info. */ +function summarize( + stdout: string, + stderr: string, + killed: boolean, + code: number | null, + timeoutMs: number, + note?: string, +): ToolResult { + const parts: string[] = []; + if (note) parts.push(note); + if (stdout) parts.push(`\n${stdout}\n`); + if (stderr) parts.push(`\n${stderr}\n`); + if (killed) parts.push(`[killed by timeout after ${timeoutMs}ms]`); + parts.push(`exit: ${code ?? 'unknown'}`); + return { + content: parts.join('\n'), + data: { exitCode: code, killed, stdoutBytes: stdout.length, stderrBytes: stderr.length }, + isError: killed || (code !== null && code !== 0), + }; +} + +/** + * Foreground run under the slirp4netns selective-network sandbox. Rejects with + * NetworkSandboxUnavailable if setup fails (caller falls back to deny-all-net). + */ +async function runForegroundNet( + command: string, + ctx: SandboxCtx, + config: SandboxConfig, + timeoutMs: number, + spawnFn: (opts: SpawnNetworkSandboxOpts) => Promise, +): Promise { + const handle = await spawnFn({ userCommand: command, cwd: ctx.cwd, config }); + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let killed = false; + let settled = false; + const finish = (r: ToolResult): void => { + if (!settled) { + settled = true; + resolve(r); + } + }; + const timer = setTimeout(() => { + killed = true; + void handle.close(); + }, timeoutMs); + const onAbort = (): void => { + killed = true; + void handle.close(); + }; + ctx.signal?.addEventListener('abort', onAbort, { once: true }); + handle.child.stdout?.on('data', (c: Buffer) => { + stdout = capStream(stdout + c.toString('utf8'), 'stdout'); + }); + handle.child.stderr?.on('data', (c: Buffer) => { + stderr = capStream(stderr + c.toString('utf8'), 'stderr'); + }); + handle.exited + .then((code) => { + clearTimeout(timer); + ctx.signal?.removeEventListener('abort', onAbort); + finish(summarize(stdout, stderr, killed, code, timeoutMs)); + }) + .catch((err: unknown) => { + clearTimeout(timer); + ctx.signal?.removeEventListener('abort', onAbort); + finish({ content: `Error running sandboxed command: ${String(err)}`, isError: true }); + }); + }); +} + export const BashTool: ToolHandler = { name: 'Bash', definition: { @@ -57,23 +157,37 @@ export const BashTool: ToolHandler = { // M3.5: wrap under platform sandbox if configured. ctx.sandboxConfig is // populated by the agent loop owner (CLI REPL passes settings.sandbox). - const sandboxCfg = (ctx as ToolContext & { sandboxConfig?: SandboxConfig }).sandboxConfig; - const wrapped = await wrapBashCommand({ - userCommand: input.command, - cwd: ctx.cwd, - config: sandboxCfg, - }); + const sctx = ctx as SandboxCtx; + const sandboxCfg = sctx.sandboxConfig; + const platform = sctx.sandboxPlatform ?? process.platform; + // M3.5-ext: does this command want the selective-allowlist network sandbox? + const useNet = needsNetworkSandbox(sandboxCfg, platform); // Background: spawn detached, stream stdout+stderr into a log file, and // return immediately. The agent reads the log path later (via Read) to see // progress/output. The process survives this turn (own process group). if (input.run_in_background) { + // The selective allowlist needs a slirp4netns helper that must outlive the + // turn — not supported for detached background commands. Fail CLOSED to + // deny-all-net so a background command can't escape the allowlist. + let bgCfg = sandboxCfg; + let bgNote = ''; + if (useNet && sandboxCfg) { + bgCfg = denyAllNetwork(sandboxCfg); + bgNote = + '[sandbox] selective network allowlist is not supported for background commands; running with NO network.\n'; + } + const wrapped = await wrapBashCommand({ + userCommand: input.command, + cwd: ctx.cwd, + config: bgCfg, + }); const dir = join(ctx.sessionDir ?? tmpdir(), 'bg'); const id = `bg-${Date.now().toString(36)}-${process.pid}-${bgSeq++}`; const logPath = join(dir, `${id}.log`); try { await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(logPath, `$ ${input.command}\n`, 'utf8'); + await fs.writeFile(logPath, `${bgNote}$ ${input.command}\n`, 'utf8'); const fh = await fs.open(logPath, 'a'); try { const child = spawn(wrapped.command, wrapped.args, { @@ -84,7 +198,7 @@ export const BashTool: ToolHandler = { const pid = child.pid; child.unref(); return { - content: `Started in background (pid ${pid ?? 'unknown'}). Output streams to:\n${logPath}\nRead that file to check progress or results.`, + content: `${bgNote}Started in background (pid ${pid ?? 'unknown'}). Output streams to:\n${logPath}\nRead that file to check progress or results.`, data: { background: true, pid, logPath, id }, }; } finally { @@ -98,6 +212,30 @@ export const BashTool: ToolHandler = { } } + // Foreground. Effective config + an optional note (set on fail-closed). + let effectiveCfg = sandboxCfg; + let failNote: string | undefined; + + if (useNet && sandboxCfg) { + const spawnFn = sctx.sandboxNetSpawn ?? spawnNetworkSandbox; + try { + return await runForegroundNet(input.command, sctx, sandboxCfg, timeoutMs, spawnFn); + } catch (err) { + if (!(err instanceof NetworkSandboxUnavailable)) { + return { content: `Error spawning sandboxed command: ${String(err)}`, isError: true }; + } + // Fail CLOSED: run with no network rather than unrestricted. + effectiveCfg = denyAllNetwork(sandboxCfg); + failNote = `[sandbox] selective network allowlist unavailable (${err.message}); ran with NO network. See docs/security-model.md.`; + } + } + + const wrapped = await wrapBashCommand({ + userCommand: input.command, + cwd: ctx.cwd, + config: effectiveCfg, + }); + return new Promise((resolvePromise) => { const child = spawn(wrapped.command, wrapped.args, { cwd: ctx.cwd, @@ -117,16 +255,10 @@ export const BashTool: ToolHandler = { }, timeoutMs); child.stdout.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf8'); - if (stdout.length > MAX_OUTPUT_BYTES) { - stdout = stdout.slice(0, MAX_OUTPUT_BYTES) + '\n... [stdout truncated]'; - } + stdout = capStream(stdout + chunk.toString('utf8'), 'stdout'); }); child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString('utf8'); - if (stderr.length > MAX_OUTPUT_BYTES) { - stderr = stderr.slice(0, MAX_OUTPUT_BYTES) + '\n... [stderr truncated]'; - } + stderr = capStream(stderr + chunk.toString('utf8'), 'stderr'); }); child.on('error', (err) => { @@ -139,17 +271,7 @@ export const BashTool: ToolHandler = { child.on('close', (code) => { clearTimeout(timer); - const summaryParts: string[] = []; - if (stdout) summaryParts.push(`\n${stdout}\n`); - if (stderr) summaryParts.push(`\n${stderr}\n`); - if (killed) summaryParts.push(`[killed by timeout after ${timeoutMs}ms]`); - summaryParts.push(`exit: ${code ?? 'unknown'}`); - const isError = killed || (code !== null && code !== 0); - resolvePromise({ - content: summaryParts.join('\n'), - data: { exitCode: code, killed, stdoutBytes: stdout.length, stderrBytes: stderr.length }, - isError, - }); + resolvePromise(summarize(stdout, stderr, killed, code, timeoutMs, failNote)); }); }); },