From e95b8878c1f9056f2e6244e0c9e270a033cccb29 Mon Sep 17 00:00:00 2001 From: oratis Date: Mon, 1 Jun 2026 23:01:24 +0800 Subject: [PATCH 1/2] test(sandbox): real-kernel bwrap integration tests + Linux CI setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the §3.9a network-allowlist work: prove the Linux sandbox actually sandboxes on a real kernel (so far only ARG GENERATION was tested), and stand up the CI Linux harness the slirp4netns selective-allowlist work (step 2) will build on. - bwrap-integration.test.ts: spawns the real bwrap-wrapped command and asserts rw-cwd writes succeed, /etc writes fail (ro), /usr is readable, and deny-all network (allowedDomains: []) blocks outbound. Gated on `bwrap` present → runs on the Linux CI runner, skips on macOS/dev. - ci.yml: on Linux, apt-install bubblewrap + slirp4netns + curl and relax Ubuntu 24.04's unprivileged-userns AppArmor restriction so bwrap can unshare. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 +++ .../src/sandbox/bwrap-integration.test.ts | 90 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 packages/core/src/sandbox/bwrap-integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a933627..5048392 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,17 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # 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. + - 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 + - name: Typecheck run: pnpm typecheck diff --git a/packages/core/src/sandbox/bwrap-integration.test.ts b/packages/core/src/sandbox/bwrap-integration.test.ts new file mode 100644 index 0000000..0505773 --- /dev/null +++ b/packages/core/src/sandbox/bwrap-integration.test.ts @@ -0,0 +1,90 @@ +// Real-kernel integration tests for the Linux bwrap sandbox. The rest of the +// sandbox suite only checks ARG GENERATION; these actually spawn bwrap and +// assert behavior. Gated on `bwrap` being present, so they run on the Linux CI +// runner (which installs bubblewrap + relaxes the userns restriction) and skip +// on macOS / dev machines without bwrap. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9a + +import { execSync, spawn } from 'node:child_process'; +import { mkdtemp, readFile, 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 { wrapBashCommand } from './index.js'; + +function hasBwrap(): boolean { + try { + execSync('command -v bwrap', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +async function runSandboxed( + userCommand: string, + cwd: string, + config: SandboxConfig, +): Promise { + const wrapped = await wrapBashCommand({ userCommand, cwd, config }); + return new Promise((resolve) => { + const child = spawn(wrapped.command, wrapped.args, { cwd }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr })); + child.on('error', (e) => resolve({ code: -1, stdout, stderr: `${stderr}${String(e)}` })); + }); +} + +const RUN = hasBwrap(); + +describe.skipIf(!RUN)('bwrap sandbox (real-kernel integration)', () => { + let cwd: string; + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-bwrap-int-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + const base = (extra: Partial = {}): SandboxConfig => ({ enabled: true, ...extra }); + + it('permits writes inside the rw-bound cwd', async () => { + const r = await runSandboxed(`echo hi > ${cwd}/out.txt && cat ${cwd}/out.txt`, cwd, base()); + expect(r.code).toBe(0); + expect(r.stdout).toContain('hi'); + expect(await readFile(join(cwd, 'out.txt'), 'utf8')).toContain('hi'); + }); + + it('blocks writes to a read-only system path (/etc)', async () => { + const r = await runSandboxed('echo x > /etc/dc-should-not-exist', cwd, base()); + expect(r.code).not.toBe(0); + expect(r.stderr.toLowerCase()).toMatch(/read-only|permission|denied/); + }); + + it('can read system libraries (ro-bound /usr) — sandbox is usable', async () => { + const r = await runSandboxed('ls /usr/bin >/dev/null && echo ok', cwd, base()); + expect(r.code).toBe(0); + expect(r.stdout).toContain('ok'); + }); + + it('deny-all network (allowedDomains: []) → outbound fails (own netns)', async () => { + // curl can't resolve/connect inside an empty network namespace; fails fast + // regardless of the runner's own connectivity. + const r = await runSandboxed( + 'curl -sS --max-time 8 https://example.com -o /dev/null; echo "exit=$?"', + cwd, + base({ network: { allowedDomains: [] } }), + ); + expect(r.stdout).not.toContain('exit=0'); + }, 20_000); +}); From 97f9b3ca917b9c1fe491c43c394de5d9d5f5ef37 Mon Sep 17 00:00:00 2001 From: oratis Date: Mon, 1 Jun 2026 23:07:26 +0800 Subject: [PATCH 2/2] fix(sandbox): correct contradictory assertion in bwrap e2e write test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing "blocks writing outside the bound cwd" test was dormant in CI (no bwrap installed) until the new Linux sandbox-tools step activated it. Its own comment notes that /tmp inside the sandbox is a fresh tmpfs, so a write there *succeeds* (exit 0) into that ephemeral, isolated filesystem — yet the test also asserted a non-zero exit. The real security property is that the write never reaches the HOST, which the `exists === false` check already verifies. Drop the contradictory exit-code assertion; a genuine read-only-bind denial (non-zero exit) is covered by bwrap-integration.test.ts (/etc write). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/sandbox/attacks.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/sandbox/attacks.test.ts b/packages/core/src/sandbox/attacks.test.ts index 9d246c2..265dff6 100644 --- a/packages/core/src/sandbox/attacks.test.ts +++ b/packages/core/src/sandbox/attacks.test.ts @@ -330,9 +330,13 @@ describe.runIf(hasBwrap)('bwrap end-to-end (Linux)', () => { } catch { exists = false; } - // The file should not exist outside the bound cwd because tmpfs covers /tmp. + // The security property: the write does NOT reach the host. /tmp inside the + // sandbox is a fresh tmpfs, so the write may "succeed" (exit 0) into that + // ephemeral, isolated filesystem — what matters is the HOST file is never + // created. (A write to a read-only bind like /etc *does* fail with a + // non-zero exit; that's covered by bwrap-integration.test.ts.) expect(exists).toBe(false); - expect(res.stdout ?? '').toMatch(/exit=[1-9]|read-only|Permission/i); + void res; }, 15000); it('network unshared when allowedDomains is empty', async () => {