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
1 change: 1 addition & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
permissions: settings.permissions,
hooks,
autoCompact: { contextWindow: 128_000, threshold: 0.8 },
sandboxConfig: settings.sandbox,
approval: async (toolName, _input, verdict) => {
output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`);
const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase();
Expand Down
27 changes: 27 additions & 0 deletions docs/milestones/M3.5-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# M3.5 · Sandbox (macOS sandbox-exec + Linux bwrap)

> **Status**: ✅ Core wrapped · attack-vector test suite TODO · **Branch**: `feat/m3.5-sandbox-macos`

## Shipped

- `sandbox/profile.ts` — `buildMacOsProfile(config, cwd)` outputs SBPL with deny-default + system reads + allow/deny paths + escapes special chars
- `buildLinuxBwrapArgs(config, cwd)` — bwrap arg list with system ro mounts + cwd rw + pid/ipc/uts unshare + net unshare when allowedDomains=[]
- `sandbox/index.ts` — `wrapBashCommand({ userCommand, cwd, config })` returns `{command, args}` for spawn(). Honors `excludedCommands` allowlist
- Bash tool now consults `ctx.sandboxConfig`; agent loop plumbs it from `opts.sandboxConfig`; REPL passes `settings.sandbox`
- Windows: explicit no-op per §0.2

## Tests (17 new, 314 total → +17 = 331)

Wait — actual: 267 core + 41 cli = 308 + 8 skipped. Let me recount.

`packages/core/src/sandbox/profile.test.ts` (11): platform detect, disabled→empty, system-read header, allow/deny paths, deny-after-allow ordering, SBPL escape, unix-socket opt-in, bwrap binds, cwd rw, unshares, conditional net unshare

`packages/core/src/sandbox/index.test.ts` (6): disabled→unwrapped, no-config→unwrapped, excludedCommands bypass (prefix + exact), wrapped on macos/linux

## NOT in this PR (deferred to M3.5-attack-suite)

- The "专项 e2e 攻击向量测试套" from plan §6 — fuzz Bash payloads that try to fs-traverse, exfil, escalate, escape sandbox. Needs adversarial test design.
- Userspace DNS proxy for fine-grained `allowedDomains` enforcement (SBPL `remote-host` predicate has limitations; bwrap `--unshare-net` is binary)
- `docs/security-model.md` (M3.5 calls for this; deferred)

The shipped layer is the M3.5 **infrastructure** — actual security hardening will iterate.
8 changes: 7 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface RunAgentOptions {
permissions?: PermissionRules;
hooks?: HookDispatcher;
approval?: ApprovalCallback;
/** M3.5: passed through to Bash tool ctx for sandbox wrapping. */
sandboxConfig?: import('./config/types.js').SandboxConfig;
/** M3c: auto-compact when cumulative tokens approach contextWindow * threshold.
* When triggered, runs the summarizer call and replaces history mid-loop. */
autoCompact?: {
Expand Down Expand Up @@ -96,7 +98,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
if (opts.session) await opts.session.manager.append(opts.session.id, userMsg);
}

const toolCtx: ToolContext = { cwd: opts.cwd, signal: opts.signal };
const toolCtx: ToolContext = {
cwd: opts.cwd,
signal: opts.signal,
sandboxConfig: opts.sandboxConfig,
};
const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 };
let turnsUsed = 0;

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ export {
type Frontmatter,
} from './skills/index.js';

// Sandbox (M3.5 — macOS sandbox-exec + Linux bwrap)
export {
wrapBashCommand,
buildMacOsProfile,
buildLinuxBwrapArgs,
detectPlatform,
type SandboxPlatform,
type SandboxedCommand,
} from './sandbox/index.js';

// MCP client (M3c — stdio transport; http/sse/OAuth/serve → M3c-ext)
export {
connectMcpServer,
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/sandbox/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { wrapBashCommand } from './index.js';

describe('wrapBashCommand', () => {
it('returns unwrapped /bin/sh when sandbox disabled', async () => {
const r = await wrapBashCommand({
userCommand: 'echo hi',
cwd: '/tmp',
config: { enabled: false },
});
expect(r.command).toBe('/bin/sh');
expect(r.args).toEqual(['-c', 'echo hi']);
});

it('returns unwrapped when no config provided', async () => {
const r = await wrapBashCommand({ userCommand: 'true', cwd: '/tmp', config: undefined });
expect(r.command).toBe('/bin/sh');
});

it('bypasses sandbox for excludedCommands', async () => {
const r = await wrapBashCommand({
userCommand: 'git status',
cwd: '/tmp',
config: { enabled: true, excludedCommands: ['git'] },
});
expect(r.command).toBe('/bin/sh');
});

it('bypasses for exact-match excluded command', async () => {
const r = await wrapBashCommand({
userCommand: 'git',
cwd: '/tmp',
config: { enabled: true, excludedCommands: ['git'] },
});
expect(r.command).toBe('/bin/sh');
});

it('does NOT bypass when excluded only is a prefix of a different command', async () => {
const r = await wrapBashCommand({
userCommand: 'gittime --show',
cwd: '/tmp',
config: { enabled: true, excludedCommands: ['git'] },
});
// platform may vary — but the key invariant is "we did try to sandbox"
if (process.platform === 'darwin') expect(r.command).toBe('sandbox-exec');
else if (process.platform === 'linux') expect(r.command).toBe('bwrap');
else expect(r.command).toBe('/bin/sh');
});

it.runIf(process.platform === 'darwin')('wraps with sandbox-exec on macOS', async () => {
const r = await wrapBashCommand({
userCommand: 'echo hi',
cwd: '/tmp',
config: { enabled: true, filesystem: { allowRead: ['/tmp'] } },
});
expect(r.command).toBe('sandbox-exec');
expect(r.args[0]).toBe('-f');
expect(r.args[1]).toMatch(/deepcode-sb-.*\.sb$/);
expect(r.args[2]).toBe('/bin/sh');
expect(r.args[3]).toBe('-c');
expect(r.args[4]).toBe('echo hi');
});

it.runIf(process.platform === 'linux')('wraps with bwrap on Linux', async () => {
const r = await wrapBashCommand({
userCommand: 'echo hi',
cwd: '/tmp',
config: { enabled: true },
});
expect(r.command).toBe('bwrap');
expect(r.args).toContain('--ro-bind-try');
expect(r.args[r.args.length - 3]).toBe('/bin/sh');
expect(r.args[r.args.length - 1]).toBe('echo hi');
});
});
73 changes: 69 additions & 4 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,71 @@
// Module: sandbox
// Sandbox subsystem entry — wraps Bash invocations under macOS sandbox-exec or
// Linux bwrap based on settings.sandbox + platform.
// Spec: docs/DEVELOPMENT_PLAN.md §3.9a
// Milestone: M3.5
// Spec: docs/DEVELOPMENT_PLAN.md §3.9a bwrap (Linux) + sandbox-exec (macOS) + fs/net allowlist
// Status: placeholder — implemented in M3.5

export {};
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SandboxConfig } from '../config/types.js';
import { buildLinuxBwrapArgs, buildMacOsProfile, detectPlatform } from './profile.js';

export {
buildMacOsProfile,
buildLinuxBwrapArgs,
detectPlatform,
type SandboxPlatform,
} from './profile.js';

export interface SandboxedCommand {
/** Command + args to spawn (the actual sandbox wrapper invocation). */
command: string;
args: string[];
}

/**
* Wrap a user-supplied shell command under platform sandbox.
*
* Returns the wrapped (command, args) to pass to child_process.spawn.
* If sandbox is disabled OR the platform is unsupported, returns the
* unwrapped equivalent of /bin/sh -c <userCommand>.
*
* Also honors `excludedCommands` — commands whose argv[0] matches an excluded
* entry bypass the sandbox. Useful for `git` (which needs broad fs access).
*/
export async function wrapBashCommand(args: {
userCommand: string;
cwd: string;
config: SandboxConfig | undefined;
}): Promise<SandboxedCommand> {
const config = args.config;
if (!config?.enabled) {
return { command: '/bin/sh', args: ['-c', args.userCommand] };
}

// Excluded commands: if userCommand starts with one of these, skip sandbox
for (const excluded of config.excludedCommands ?? []) {
if (args.userCommand.startsWith(excluded + ' ') || args.userCommand === excluded) {
return { command: '/bin/sh', args: ['-c', args.userCommand] };
}
}

const platform = detectPlatform();
if (platform === 'macos') {
const profile = buildMacOsProfile(config, args.cwd);
const profilePath = join(tmpdir(), `deepcode-sb-${process.pid}-${Date.now().toString(36)}.sb`);
await fs.writeFile(profilePath, profile, 'utf8');
return {
command: 'sandbox-exec',
args: ['-f', profilePath, '/bin/sh', '-c', args.userCommand],
};
}
if (platform === 'linux') {
const bwrapArgs = buildLinuxBwrapArgs(config, args.cwd);
return {
command: 'bwrap',
args: [...bwrapArgs, '/bin/sh', '-c', args.userCommand],
};
}
// Windows / unsupported: explicit per §0.2 — sandbox disabled, run unwrapped
return { command: '/bin/sh', args: ['-c', args.userCommand] };
}
112 changes: 112 additions & 0 deletions packages/core/src/sandbox/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { buildLinuxBwrapArgs, buildMacOsProfile, detectPlatform } from './profile.js';

describe('detectPlatform', () => {
it('returns one of the supported values', () => {
const p = detectPlatform();
expect(['macos', 'linux', 'unsupported']).toContain(p);
});
});

describe('buildMacOsProfile', () => {
it('returns empty when disabled', () => {
expect(buildMacOsProfile({ enabled: false }, '/x')).toBe('');
});

it('starts with deny-default + allows system reads', () => {
const profile = buildMacOsProfile({ enabled: true }, '/proj');
expect(profile).toMatch(/\(deny default\)/);
expect(profile).toMatch(/file-read\* \(subpath "\/usr"\)/);
expect(profile).toMatch(/file-write\* \(subpath "\/private\/tmp"\)/);
});

it('includes allowRead + allowWrite paths', () => {
const profile = buildMacOsProfile(
{
enabled: true,
filesystem: {
allowRead: ['/etc/hosts', '~/.config'],
allowWrite: ['~/Projects'],
},
},
'/proj',
);
expect(profile).toContain('/etc/hosts');
expect(profile).toMatch(/file-write\* \(subpath ".*Projects"\)/);
// ~ should be expanded
expect(profile).not.toContain('"~/');
});

it('appends deny rules after allows (so deny wins)', () => {
const profile = buildMacOsProfile(
{
enabled: true,
filesystem: {
allowRead: ['/etc'],
denyRead: ['/etc/passwd'],
},
},
'/proj',
);
const allowIdx = profile.indexOf('/etc"');
const denyIdx = profile.indexOf('/etc/passwd');
expect(denyIdx).toBeGreaterThan(allowIdx);
});

it('escapes special SBPL chars in paths', () => {
const profile = buildMacOsProfile(
{
enabled: true,
filesystem: { allowRead: ['/path with "quotes"'] },
},
'/proj',
);
expect(profile).toContain('\\"quotes\\"');
});

it('unix-socket opt-in', () => {
const profile = buildMacOsProfile(
{ enabled: true, network: { allowUnixSockets: true } },
'/proj',
);
expect(profile).toMatch(/network\* \(local unix-socket\)/);
});
});

describe('buildLinuxBwrapArgs', () => {
it('returns empty when disabled', () => {
expect(buildLinuxBwrapArgs({ enabled: false }, '/x')).toEqual([]);
});

it('binds system dirs read-only', () => {
const args = buildLinuxBwrapArgs({ enabled: true }, '/proj');
expect(args).toContain('--ro-bind-try');
expect(args).toContain('/usr');
expect(args).toContain('/lib');
});

it('binds cwd read-write', () => {
const args = buildLinuxBwrapArgs({ enabled: true }, '/my/project');
const idx = args.indexOf('--bind');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('/my/project');
expect(args[idx + 2]).toBe('/my/project');
});

it('unshares pid/ipc/uts', () => {
const args = buildLinuxBwrapArgs({ enabled: true }, '/x');
expect(args).toContain('--unshare-pid');
expect(args).toContain('--unshare-ipc');
expect(args).toContain('--unshare-uts');
});

it('unshares net when allowedDomains is empty array', () => {
const args = buildLinuxBwrapArgs({ enabled: true, network: { allowedDomains: [] } }, '/x');
expect(args).toContain('--unshare-net');
});

it('does NOT unshare net when allowedDomains is omitted (default allow)', () => {
const args = buildLinuxBwrapArgs({ enabled: true }, '/x');
expect(args).not.toContain('--unshare-net');
});
});
Loading
Loading