Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/sandbox/attacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/sandbox/bwrap-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<RunResult> {
const wrapped = await wrapBashCommand({ userCommand, cwd, config });
return new Promise<RunResult>((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> = {}): 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);
});
Loading