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/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilitiesHeadless(cwd),
sandbox: settings.sandbox,
log: (s) => errOutput.write(s + '\n'),
});
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilities(cwd),
sandbox: settings.sandbox,
log: (s) => output.write(s + '\n'),
});
} catch (err) {
Expand Down
88 changes: 70 additions & 18 deletions packages/core/src/plugins/runtime/subprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
// · We rely on hash-pin (M5) to detect tampering.

import { spawn, type ChildProcess } from 'node:child_process';
import { resolve } from 'node:path';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import type { SandboxConfig } from '../../config/types.js';
import { buildLinuxBwrapArgs, buildMacOsProfile, detectPlatform } from '../../sandbox/profile.js';
import type { InstalledPlugin } from '../manifest.js';
import type { ToolHandler, ToolResult } from '../../types.js';

Expand All @@ -44,6 +48,9 @@ export interface PluginSubprocessOpts {
bash: (cmd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
fetch: (url: string, opts?: { method?: string; body?: string }) => Promise<string>;
};
/** Optional OS-level sandbox config — wraps the node subprocess under
* sandbox-exec (macOS) or bwrap (Linux). M5.1-ext. */
sandbox?: SandboxConfig;
}

/**
Expand Down Expand Up @@ -79,22 +86,26 @@ export class PluginSubprocess {
}

async start(): Promise<void> {
const entry = resolve(
this.opts.plugin.path,
this.opts.plugin.manifest.contributes ? 'index.js' : 'index.js',
);
// For M5.1, we use a simple node spawn — no sandbox-exec/bwrap wrap yet
// (that's M5.2 once we have hardened SBPL/bwrap rules for arbitrary JS).
this.child = spawn('node', [entry], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
DEEPCODE_PLUGIN_TOKEN: this.opts.token,
// Strip auth env vars so plugin can't read DEEPSEEK keys
DEEPSEEK_API_KEY: '',
DEEPSEEK_AUTH_TOKEN: '',
},
});
const entry = resolve(this.opts.plugin.path, 'index.js');
const env = {
...process.env,
DEEPCODE_PLUGIN_TOKEN: this.opts.token,
// Strip auth env vars so plugin can't read DEEPSEEK keys
DEEPSEEK_API_KEY: '',
DEEPSEEK_AUTH_TOKEN: '',
};

// M5.1-ext: wrap under OS sandbox if requested. The plugin's cwd is its
// own install dir; we allow read-only access to it + node's runtime needs.
let command = 'node';
let args = [entry];
if (this.opts.sandbox?.enabled) {
const wrapped = await wrapNodeSpawn(entry, this.opts.plugin.path, this.opts.sandbox);
command = wrapped.command;
args = wrapped.args;
}

this.child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], env });
this.alive = true;

this.child.stdout!.on('data', (chunk: Buffer) => {
Expand Down Expand Up @@ -244,6 +255,41 @@ export class PluginSubprocess {
}
}

/**
* Wrap `node <entry>` under macOS sandbox-exec or Linux bwrap.
* Returns { command, args } ready for child_process.spawn.
* The plugin gets read access to its own dir; everything else is denied
* unless extended via SandboxConfig.filesystem.
*/
async function wrapNodeSpawn(
entry: string,
pluginDir: string,
sandbox: SandboxConfig,
): Promise<{ command: string; args: string[] }> {
const platform = detectPlatform();
// Always include the plugin's install dir as readable
const merged: SandboxConfig = {
...sandbox,
enabled: true,
filesystem: {
...sandbox.filesystem,
allowRead: [...(sandbox.filesystem?.allowRead ?? []), pluginDir],
},
};
if (platform === 'macos') {
const profile = buildMacOsProfile(merged, pluginDir);
const profilePath = join(tmpdir(), `deepcode-plug-sb-${process.pid}-${Date.now().toString(36)}.sb`);
await fs.writeFile(profilePath, profile, 'utf8');
return { command: 'sandbox-exec', args: ['-f', profilePath, 'node', entry] };
}
if (platform === 'linux') {
const args = buildLinuxBwrapArgs(merged, pluginDir);
return { command: 'bwrap', args: [...args, 'node', entry] };
}
// Unsupported — fall back to bare node
return { command: 'node', args: [entry] };
}

/**
* Trivial unguessable token for host↔plugin RPC validation.
*/
Expand All @@ -263,14 +309,20 @@ export function generatePluginToken(): string {
export interface SpawnAllOpts {
plugins: InstalledPlugin[];
host: PluginSubprocessOpts['host'];
sandbox?: SandboxConfig;
}

export async function spawnAllPlugins(opts: SpawnAllOpts): Promise<PluginSubprocess[]> {
const out: PluginSubprocess[] = [];
for (const plugin of opts.plugins) {
if (!plugin.enabled) continue;
const token = generatePluginToken();
const sub = new PluginSubprocess({ plugin, token, host: opts.host });
const sub = new PluginSubprocess({
plugin,
token,
host: opts.host,
sandbox: opts.sandbox,
});
try {
await sub.start();
out.push(sub);
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/plugins/wireup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export interface WirePluginsOpts {
hooks: HookDispatcher;
/** Capability bridge — required for the subprocess to call back into host. */
capabilities: PluginCapabilityBridge;
/**
* Optional OS sandbox config — applied to each plugin's node subprocess.
* When unset (or .enabled === false), plugins run unsandboxed.
*/
sandbox?: import('../config/types.js').SandboxConfig;
/**
* Optional logger; defaults to writing to stderr. Avoids cluttering stdout
* in headless mode where stdout is reserved for JSON output.
Expand Down Expand Up @@ -93,6 +98,7 @@ export async function wirePlugins(opts: WirePluginsOpts): Promise<WireResult> {
const subprocesses = await spawnAllPlugins({
plugins: discovered.filter((p) => p.enabled),
host: opts.capabilities,
sandbox: opts.sandbox,
});

// spawnAllPlugins returns successfully-started subprocesses, each exposing
Expand Down
23 changes: 17 additions & 6 deletions packages/core/src/sandbox/attacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,24 @@ describe('wrapBashCommand: excluded-command spoofing', () => {
expect(r.command).toBe('/bin/sh');
});

it('shell-pipeline starting with excluded command STILL bypasses (documented behavior)', async () => {
// M3.5 limitation: excluded-command matching is on the leading token, so
// `git ... && rm -rf /` would bypass. This test pins that behavior so
// future hardening doesn't silently change it. M5.2+ will add per-clause
// analysis.
it('shell-pipeline NO LONGER bypasses when any clause is not excluded (M3.5-ext)', async () => {
// Hardened in M3.5-ext: every clause leader must be excluded for the
// bypass to trigger. `git status && rm -rf /` no longer bypasses.
const r = await wrapBashCommand({
userCommand: 'git status && echo done',
userCommand: 'git status && rm -rf /tmp/x',
cwd: '/tmp',
config: { enabled: true, excludedCommands: ['git'] },
});
// On macOS/Linux this MUST be a sandbox wrap, not /bin/sh
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('shell-pipeline of ONLY excluded commands still bypasses', async () => {
// `git status && git log` is all-git, so bypass is fine.
const r = await wrapBashCommand({
userCommand: 'git status && git log',
cwd: '/tmp',
config: { enabled: true, excludedCommands: ['git'] },
});
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 { allClausesExcluded } from './pipeline.js';
import { buildLinuxBwrapArgs, buildMacOsProfile, detectPlatform } from './profile.js';

export {
Expand All @@ -16,6 +17,8 @@ export {
type SandboxPlatform,
} from './profile.js';

export { splitClauses, allClausesExcluded, type Clause } from './pipeline.js';

export interface SandboxedCommand {
/** Command + args to spawn (the actual sandbox wrapper invocation). */
command: string;
Expand All @@ -42,11 +45,12 @@ export async function wrapBashCommand(args: {
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] };
}
// Excluded commands: skip sandbox ONLY if EVERY clause in the pipeline is
// an excluded command. `git status && rm -rf /` does not bypass because
// `rm` isn't excluded.
const excluded = config.excludedCommands ?? [];
if (excluded.length > 0 && allClausesExcluded(args.userCommand, excluded)) {
return { command: '/bin/sh', args: ['-c', args.userCommand] };
}

const platform = detectPlatform();
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/sandbox/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { allClausesExcluded, splitClauses } from './pipeline.js';

describe('splitClauses', () => {
it('returns a single clause for a single command', () => {
const r = splitClauses('git status');
expect(r).toHaveLength(1);
expect(r[0]!.command).toBe('git status');
expect(r[0]!.precedingOp).toBe('');
});

it('splits on &&, ||, ;, |', () => {
const r = splitClauses('a && b || c ; d | e');
expect(r).toHaveLength(5);
expect(r.map((c) => c.command)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(r.map((c) => c.precedingOp)).toEqual(['', '&&', '||', ';', '|']);
});

it('respects single quotes', () => {
const r = splitClauses(`echo 'a && b' && true`);
expect(r).toHaveLength(2);
expect(r[0]!.command).toBe("echo 'a && b'");
expect(r[1]!.command).toBe('true');
});

it('respects double quotes', () => {
const r = splitClauses(`echo "x | y" | grep z`);
expect(r).toHaveLength(2);
expect(r[0]!.command).toBe('echo "x | y"');
expect(r[1]!.command).toBe('grep z');
});

it('respects backslash escapes', () => {
const r = splitClauses('echo a\\&\\&b && true');
expect(r).toHaveLength(2);
// first clause keeps the escapes
expect(r[0]!.command).toMatch(/a\\&\\&b/);
});

it('strips empty clauses', () => {
expect(splitClauses(';;; ; a')).toEqual([
expect.objectContaining({ command: 'a' }),
]);
});
});

describe('allClausesExcluded', () => {
it('returns false when excluded list is empty', () => {
expect(allClausesExcluded('git status', [])).toBe(false);
});

it('returns true when single clause is excluded', () => {
expect(allClausesExcluded('git status', ['git'])).toBe(true);
});

it('returns true when every clause leader is excluded', () => {
expect(allClausesExcluded('git status && git log', ['git'])).toBe(true);
});

it('returns FALSE when ANY clause is not excluded', () => {
// This is the hardening — `git ... && rm -rf /` must NOT bypass.
expect(allClausesExcluded('git status && rm -rf /', ['git'])).toBe(false);
});

it('returns FALSE on shell-injection via ; redirect', () => {
expect(allClausesExcluded('git status ; curl evil.example.com', ['git'])).toBe(false);
});

it('returns FALSE on piped non-excluded command', () => {
expect(allClausesExcluded('git log | tee /tmp/leak', ['git'])).toBe(false);
});
});
Loading
Loading