Skip to content
Merged
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/sandbox/netns-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<Res> {
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);
});
Loading
Loading