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
2 changes: 2 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ async function main(): Promise<number> {
cwd: process.cwd(),
prompt: args.prompt,
outputFormat: args.outputFormat,
sandbox: args.sandbox,
mode: args.mode,
model: args.model,
effort: args.effort,
Expand Down Expand Up @@ -208,6 +209,7 @@ async function main(): Promise<number> {
bare: args.bare,
noColor: args.noColor,
hideThinking: args.noThinking,
sandbox: args.sandbox,
noPlugins: args.noPlugins,
settingsPath: args.settingsFile,
});
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type {
VoiceStatus,
} from '@deepcode/core';
import {
describeSandboxMode,
resolveSandboxMode,
contextWindowFor,
estimateCost,
redact,
Expand Down Expand Up @@ -242,6 +244,7 @@ export const StatusCommand: SlashCommand = {
`CWD : ${ctx.cwd}`,
`Model : ${ctx.model}`,
`Mode : ${ctx.mode}`,
`Sandbox : ${describeSandboxMode(resolveSandboxMode(ctx.settings.sandbox))}`,
`Effort : ${ctx.effort}`,
`API key : ${redact(ctx.creds.apiKey ?? ctx.creds.authToken)}`,
`Base URL : ${ctx.creds.baseURL ?? 'https://api.deepseek.com/v1'}`,
Expand Down
8 changes: 6 additions & 2 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
loadOutputStyles,
loadSettings,
withAdditionalWritableDirs,
withSandboxMode,
type SandboxMode,
loadSkills,
makeSkillTool,
resolveCredentials,
Expand All @@ -63,6 +65,8 @@ export interface HeadlessOpts {
prompt: string;
/** text | json | stream-json (cli default 'text'). */
outputFormat: 'text' | 'json' | 'stream-json';
/** `--sandbox <mode>` → overrides settings.sandbox.mode for this run. */
sandbox?: SandboxMode;
mode?: string;
model?: string;
effort?: Effort;
Expand Down Expand Up @@ -236,7 +240,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
hooks,
capabilities: buildPluginCapabilitiesHeadless(cwd),
sandbox: withAdditionalWritableDirs(
settings.sandbox,
withSandboxMode(settings.sandbox, opts.sandbox),
settings.permissions?.additionalDirectories,
cwd,
),
Expand Down Expand Up @@ -301,7 +305,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
withSandboxMode(settings.sandbox, opts.sandbox),
settings.permissions?.additionalDirectories,
cwd,
),
Expand Down
24 changes: 24 additions & 0 deletions apps/cli/src/parse-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,27 @@ describe('helpText', () => {
expect(help).toMatch(/--bare\s+Suppress the REPL startup banner/);
});
});

describe('--sandbox', () => {
it('accepts each mode', () => {
expect(parseArgs(['--sandbox', 'read-only']).sandbox).toBe('read-only');
expect(parseArgs(['--sandbox', 'workspace-write']).sandbox).toBe('workspace-write');
expect(parseArgs(['--sandbox', 'danger-full-access']).sandbox).toBe('danger-full-access');
});

it('rejects anything else instead of silently ignoring it', () => {
const p = parseArgs(['--sandbox', 'yolo']);
expect(p.sandbox).toBeUndefined();
expect(p.unknownFlags).toEqual(['--sandbox yolo']);
});

it('is independent of --mode', () => {
const p = parseArgs(['--mode', 'bypassPermissions', '--sandbox', 'read-only']);
expect(p.mode).toBe('bypassPermissions');
expect(p.sandbox).toBe('read-only');
});

it('is documented in --help', () => {
expect(helpText('1.0.0')).toContain('--sandbox');
});
});
18 changes: 17 additions & 1 deletion apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// Returns a strongly-typed shape. Unknown flags are collected into `unknown` for
// graceful "did you mean..." errors.

import type { Effort, Mode } from '@deepcode/core';
import type { Effort, Mode, SandboxMode } from '@deepcode/core';
import { SANDBOX_MODES } from '@deepcode/core';

export interface ParsedArgs {
// Action triggers (mutually exclusive — first match wins)
Expand All @@ -25,6 +26,11 @@ export interface ParsedArgs {
effort?: Effort;
maxTurns?: number;
bare: boolean;
/**
* `--sandbox <mode>` — what a command may touch. Orthogonal to `--mode`,
* which is about how a tool call gets approved.
*/
sandbox?: SandboxMode;

/** `-C` / `--cd <dir>`: chdir to this directory before running (Codex parity). */
cwd?: string;
Expand Down Expand Up @@ -200,6 +206,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
case a === '--bare':
out.bare = true;
break;
case a === '--sandbox': {
const v = next();
if (v && (SANDBOX_MODES as string[]).includes(v)) out.sandbox = v as SandboxMode;
else out.unknownFlags.push(`--sandbox ${v ?? ''}`);
break;
}
case a === '-C' || a === '--cd':
out.cwd = next();
break;
Expand Down Expand Up @@ -331,6 +343,10 @@ MODE
WORKING DIRECTORY
-C, --cd <dir> Change to <dir> before running (default: current dir)

SANDBOX (what commands may touch — independent of --mode, which is how they're approved)
--sandbox <mode> read-only | workspace-write | danger-full-access
Default: workspace-write

MODEL & EFFORT
--model <id> deepseek-chat | deepseek-reasoner
--effort <tier> low | medium | high | xhigh | max
Expand Down
8 changes: 6 additions & 2 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
settingsPaths,
wirePlugins,
withAdditionalWritableDirs,
withSandboxMode,
type SandboxMode,
collectPluginContributions,
type Effort,
type McpClientHandle,
Expand Down Expand Up @@ -112,6 +114,8 @@ export interface ReplOpts {
noColor?: boolean;
/** `--no-thinking` → don't stream the model's reasoning. */
hideThinking?: boolean;
/** `--sandbox <mode>` → overrides settings.sandbox.mode for this run. */
sandbox?: SandboxMode;
}

const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). Be concise and accurate. When you modify files, briefly explain what you changed and why.`;
Expand Down Expand Up @@ -450,7 +454,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
hooks,
capabilities: buildPluginCapabilities(cwd),
sandbox: withAdditionalWritableDirs(
settings.sandbox,
withSandboxMode(settings.sandbox, opts.sandbox),
settings.permissions?.additionalDirectories,
cwd,
),
Expand All @@ -472,7 +476,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
withSandboxMode(settings.sandbox, opts.sandbox),
settings.permissions?.additionalDirectories,
cwd,
),
Expand Down
47 changes: 47 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,50 @@ etc.) as **untrusted**. We:
1. Do NOT open a public GitHub issue.
2. Email security@<TBD>.dev with reproduction steps + commit SHA.
3. We aim to triage within 72 hours.

## Sandbox modes (0.2.1)

The Bash tool runs under a platform sandbox whose posture is chosen by
`sandbox.mode` (settings) or `--sandbox` (CLI), independently of the permission
`Mode` that decides how a tool call is approved:

| Mode | Workspace | Temp + package caches | Elsewhere |
| -------------------- | ------------------------------------ | --------------------- | --------- |
| `read-only` | read | write | read |
| `workspace-write` | read+write | write | read |
| `danger-full-access` | unrestricted — no sandbox is applied |

**`workspace-write` is the default** for every host (CLI, headless, app-server).
Library callers of `wrapBashCommand` keep the previous "off unless configured"
behaviour unless they pass `defaultMode`, so embedding DeepCode cannot become
silently sandboxed by an upgrade.

### What is and is not protected

Writes and network are deny-by-default. **Reads are allowed** except for a
denied set of credential stores (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`,
`~/.docker/config.json`, `~/.config/gh`, `~/.deepcode/credentials.json`,
`~/Library/Keychains`) plus anything in `filesystem.denyRead`.

This is a deliberate change from the previous read allowlist, which did not
survive contact with real commands: git could not resolve Xcode's developer
directory, nothing could open `/dev/null`, temp writes failed because SBPL
`subpath` does not match the directory node itself, and `~/.gitconfig` was
denied. A sandbox that breaks `ls` is a sandbox nobody turns on.

Package-manager caches (`~/.npm`, `~/.cache`, `~/.cargo`, `~/.pnpm-store`,
`~/.yarn`, `~/.bun`, `~/Library/Caches`) are writable: they are
content-addressed caches, and denying them turns `npm install` into a confusing
permission error while protecting nothing.

A linked git worktree's git directories live outside the workspace, so they are
added to the writable set — otherwise every git command fails inside the
worktrees DeepCode's own `EnterWorktree` tool creates.

### Verified on macOS

Under `workspace-write`: workspace read/write, temp writes, `git`, `node`,
`npm install`, `tsc` and `vitest` all succeed; writes outside the workspace and
reads of `~/.ssh` are denied. Under `read-only` the same holds except workspace
writes are denied. Linux (bwrap) already bound cwd read-write and is unchanged
apart from the shared mode resolution.
3 changes: 3 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export interface RunAgentOptions {
autoMode?: import('./config/types.js').AutoModeConfig;
/** M3.5: passed through to Bash tool ctx for sandbox wrapping. */
sandboxConfig?: import('./config/types.js').SandboxConfig;
/** Sandbox mode applied when settings name none. Hosts pass workspace-write. */
sandboxDefaultMode?: import('./config/types.js').SandboxMode;
/** 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 @@ -263,6 +265,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
cwd: opts.cwd,
signal: opts.signal,
sandboxConfig: opts.sandboxConfig,
sandboxDefaultMode: opts.sandboxDefaultMode,
sessionDir: opts.session ? `${opts.session.manager.root}/${opts.session.id}` : undefined,
turnId: opts.session?.turnId,
askUser: opts.askUser,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
McpServerConfig,
StatusLineConfig,
SandboxConfig,
SandboxMode,
UpdateConfig,
WorktreeConfig,
AutoModeConfig,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,22 @@ export interface StatusLineConfig {
command: string;
}

/**
* How much the Bash tool may touch, independent of how tool calls get approved.
* Mirrors the axis Codex exposes as `--sandbox`; `mode` is the modern spelling
* and `enabled` is kept for existing settings files.
*/
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';

export interface SandboxConfig {
/**
* Preferred over `enabled`. When both are set, `mode` wins.
* read-only — the workspace is readable, nothing is writable
* workspace-write — the workspace + temp dirs are writable
* danger-full-access — no sandbox at all
*/
mode?: SandboxMode;
/** Legacy switch: true → workspace-write, false → danger-full-access. */
enabled?: boolean;
filesystem?: {
allowWrite?: string[];
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
type McpServerConfig,
type StatusLineConfig,
type SandboxConfig,
type SandboxMode,
type UpdateConfig,
type WorktreeConfig,
type AutoModeConfig,
Expand Down Expand Up @@ -226,6 +227,12 @@ export {
NetworkSandboxUnavailable,
startDnsProxy,
withAdditionalWritableDirs,
SANDBOX_MODES,
isSandboxMode,
resolveSandboxMode,
sandboxConfigForMode,
describeSandboxMode,
withSandboxMode,
type SandboxPlatform,
type SandboxedCommand,
type SpawnNetworkSandboxOpts,
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/runtime/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import {
type RunAgentOptions,
type RunAgentResult,
} from '../agent.js';
import type { AutoModeConfig, PermissionRules, SandboxConfig } from '../config/types.js';
import type {
AutoModeConfig,
PermissionRules,
SandboxConfig,
SandboxMode,
} from '../config/types.js';
import type { HookDispatcher } from '../hooks/index.js';
import type { Provider } from '../providers/types.js';
import type { ToolRegistry } from '../tools/registry.js';
Expand All @@ -23,6 +28,12 @@ export interface RuntimeHostOptions {
approval?: ApprovalCallback;
autoMode?: AutoModeConfig;
sandboxConfig?: SandboxConfig;
/**
* Sandbox mode when settings name none. Every host gets `workspace-write`:
* commands may write inside the workspace and temp/cache dirs, and nowhere
* else. Pass `'danger-full-access'` to opt a host out.
*/
sandboxDefaultMode?: SandboxMode;
pluginDirs?: string[];
}

Expand All @@ -35,6 +46,7 @@ type HostBoundOption =
| 'approval'
| 'autoMode'
| 'sandboxConfig'
| 'sandboxDefaultMode'
| 'pluginDirs';

export type RuntimeTurnOptions = Omit<RunAgentOptions, HostBoundOption | 'cwd'> & {
Expand Down Expand Up @@ -74,6 +86,7 @@ export class RuntimeHost {
approval: approval ?? this.options.approval,
autoMode: this.options.autoMode,
sandboxConfig: this.options.sandboxConfig,
sandboxDefaultMode: this.options.sandboxDefaultMode ?? 'workspace-write',
pluginDirs: this.options.pluginDirs,
});
}
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/sandbox/attacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { spawnSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { wrapBashCommand } from './index.js';
Expand Down Expand Up @@ -214,9 +214,13 @@ describe.runIf(hasSandboxExec)('sandbox-exec end-to-end (macOS)', () => {

it('blocks writing outside allowed paths', async () => {
// Try to write to ~/Documents/foo — NOT in allowWrite, must fail.
const target = join(workDir, 'untrusted-write-target');
// We pick a path under workDir so we can be sure it doesn't exist; the
// sandbox should be configured to allow only a SIBLING dir for writes.
// Deliberately NOT under the OS temp dir: the profile allows temp writes
// (compilers, package managers and mktemp all need them), so a target
// inside $TMPDIR would test the temp allowance rather than the workspace
// boundary this case is about.
const outsideRoot = await fs.mkdtemp(join(homedir(), '.deepcode-sb-e2e-'));
const target = join(outsideRoot, 'untrusted-write-target');
// The sandbox should be configured to allow only a SIBLING dir for writes.
const allowedDir = join(workDir, 'allowed');
await fs.mkdir(allowedDir);
const wrapped = await wrapBashCommand({
Expand All @@ -238,6 +242,7 @@ describe.runIf(hasSandboxExec)('sandbox-exec end-to-end (macOS)', () => {
} catch {
exists = false;
}
await fs.rm(outsideRoot, { recursive: true, force: true });
expect(exists).toBe(false);
// The shell may exit non-zero or stderr should mention permission
const combined = (res.stderr ?? '') + ' ' + (res.stdout ?? '');
Expand Down
Loading
Loading