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
78 changes: 53 additions & 25 deletions docs/security-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DeepCode Security Model

> Last updated: 2026-05-28 (M3.5 hardening + attack-vector test suite landed)
> Last updated: 2026-06-02 (M3.5-ext: Linux selective per-domain network allowlist landed)

This document is the **single source of truth** for what DeepCode protects
against, what it doesn't, and how each layer composes. If you're reviewing a
Expand All @@ -12,15 +12,15 @@ against the threat model here.
DeepCode is an LLM-driven coding assistant. The threats we care about, in
decreasing order of operator severity:

| # | Threat | Severity | Where mitigated |
| --- | ------------------------------------------------------------------------ | ----------------------- | ----------------------------------------------------------------------- |
| 1 | Model exfiltrates DeepSeek API key (or other env secrets) via tool call | High | M3.5 sandbox + M5.1 env strip |
| 2 | Model writes arbitrary files outside the project (`/usr/bin`, `/etc`) | High | M3.5 sandbox + permissions |
| 3 | Plugin (third-party code) does either #1 or #2 | High | M5.1 subprocess + (M5.1-ext) OS sandbox |
| 4 | Hook script (third-party shell snippet) does either #1 or #2 | Medium | M3.5 sandbox wraps Bash; hooks bypass when invoked via /bin/sh directly |
| 5 | Hostile `settings.json` field (e.g. allowRead path) injects sandbox rule | Medium | escapeSbpl() |
| 6 | Untrusted project's AGENTS.md drives the agent into harmful action | Low | Trust store (`/trust`) |
| 7 | DNS exfiltration of secrets from sandboxed Bash | Acknowledged limitation | M3.5-ext userspace proxy |
| # | Threat | Severity | Where mitigated |
| --- | ------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------- |
| 1 | Model exfiltrates DeepSeek API key (or other env secrets) via tool call | High | M3.5 sandbox + M5.1 env strip |
| 2 | Model writes arbitrary files outside the project (`/usr/bin`, `/etc`) | High | M3.5 sandbox + permissions |
| 3 | Plugin (third-party code) does either #1 or #2 | High | M5.1 subprocess + (M5.1-ext) OS sandbox |
| 4 | Hook script (third-party shell snippet) does either #1 or #2 | Medium | M3.5 sandbox wraps Bash; hooks bypass when invoked via /bin/sh directly |
| 5 | Hostile `settings.json` field (e.g. allowRead path) injects sandbox rule | Medium | escapeSbpl() |
| 6 | Untrusted project's AGENTS.md drives the agent into harmful action | Low | Trust store (`/trust`) |
| 7 | DNS exfiltration of secrets from sandboxed Bash | Partly mitigated | M3.5-ext DNS allowlist (netns.ts) — names only; raw-IP dials still pass |

## Defence layers

Expand Down Expand Up @@ -72,7 +72,8 @@ written to `$TMPDIR/deepcode-sb-*.sb`. Policy:
- `denyRead` / `denyWrite` rules appended LAST so they override allows on
overlap.
- Network: default-allow unless `network.allowedDomains: []` (empty array)
meaning "no network". Domain whitelist needs M3.5-ext (userspace proxy).
meaning "no network". Per-domain allowlisting is NOT available on macOS (SBPL
has no usable remote-host predicate here) — only on Linux (see below).
- Unix sockets: blocked unless `network.allowUnixSockets: true`.

**Linux — `bwrap` argv**
Expand All @@ -84,7 +85,28 @@ Generated by `buildLinuxBwrapArgs`:
- `/proc`, `/dev`, `/tmp` (tmpfs).
- cwd is the only bare `--bind` (rw).
- Always `--unshare-pid`, `--unshare-ipc`, `--unshare-uts`.
- `--unshare-net` when `network.allowedDomains: []`.
- `--unshare-net` when `network.allowedDomains: []` (deny-all-net).

**Linux — selective per-domain allowlist (`netns.ts`)**

When `network.allowedDomains` is a NON-EMPTY allowlist, `BashTool` runs the
command via `spawnNetworkSandbox`:

- `bwrap --unshare-net --uid 0 --gid 0` gives the command its own netns; a
generated `resolv.conf` (→ slirp gateway `10.0.2.2`) is bound in.
- `slirp4netns` provides rootless userspace NAT (`tap0`) and is attached to the
sandbox's netns by PID (the `--uid 0` mapping is what lets it `setns`).
- The allowlisting DNS proxy (`dns-proxy.ts`) on `127.0.0.1:53` answers only
allowlisted names and NXDOMAINs the rest; `slirp4netns --disable-dns` closes
the `10.0.2.3` bypass.

**Threat model + limits:** this is DNS-NAME allowlisting — a process that dials
a raw IP bypasses it (adequate for the git/npm/pip-over-https workload). It
requires binding `127.0.0.1:53` (CAP_NET_BIND_SERVICE or a relaxed
`net.ipv4.ip_unprivileged_port_start`). When that's unavailable, `BashTool`
**fails closed** to deny-all-net (the command runs with no network, with a note)
rather than running unrestricted. Background commands always fail closed (the
slirp helper can't safely outlive the turn).

**Windows — not supported.** Sandbox is a no-op (see plan §0.2).

Expand Down Expand Up @@ -148,7 +170,8 @@ etc.) as **untrusted**. We:
- no implicit network when allowedDomains is empty
- no implicit file-write to /usr, /System, /Library
- **3 bwrap-arg safety** tests:
- no --share-net even with non-empty allowedDomains (until M3.5-ext)
- no --share-net even with a non-empty allowedDomains (connectivity comes from
slirp4netns externally, never by sharing the host netns)
- only cwd is bare --bind
- always --unshare-{pid,ipc,uts}
- **4 excluded-command spoofing** tests:
Expand All @@ -159,21 +182,26 @@ etc.) as **untrusted**. We:
- **2 sandbox-exec e2e** (macOS, runIf the binary exists):
- block write to `/usr/local/bin/*`
- profile is syntactically valid (smoke)
- **2 bwrap e2e** (Linux, runIf the binary exists):
- block write outside cwd
- DNS unshared when allowedDomains: []
- **bwrap e2e** (Linux CI, runIf `bwrap` exists — `bwrap-integration.test.ts`):
- write inside the rw-bound cwd succeeds; write to `/etc` is read-only-denied
- `/usr` readable (sandbox is usable); deny-all-net blocks outbound
- block write outside cwd (host file never created)
- **selective net allowlist e2e** (Linux CI, `netns-integration.test.ts`):
- an allowlisted domain returns HTTP 200; a non-allowlisted domain NXDOMAINs
- the sandbox resolv.conf points at the slirp gateway

## What we do NOT yet protect against

| Gap | Tracking |
| -------------------------------------------------- | ------------------------------------- |
| DNS exfil from sandboxed Bash | M3.5-ext (UDP proxy) |
| OS sandbox wrapping the plugin subprocess | M5.1-ext |
| Pipeline analysis (`git ... && rm -rf /`) | M5.2 |
| Domain whitelist enforcement (allowedDomains) | M3.5-ext |
| Image input prompt injection (model multimodal) | v1.1 |
| Side-channel timing leaks (e.g. via exec duration) | Out of scope |
| Local malicious binaries already on $PATH | Out of scope (assume host is trusted) |
| Gap | Tracking |
| -------------------------------------------------- | ---------------------------------------------- |
| Raw-IP egress bypassing the DNS-name allowlist | by design (name-level); IP-level filtering TBD |
| Per-domain allowlist on macOS | SBPL has no usable remote-host predicate |
| Allowlist where `127.0.0.1:53` can't be bound | fails closed to deny-all-net (documented) |
| OS sandbox wrapping the plugin subprocess | M5.1-ext |
| Pipeline analysis (`git ... && rm -rf /`) | M5.2 |
| Image input prompt injection (model multimodal) | v1.1 |
| Side-channel timing leaks (e.g. via exec duration) | Out of scope |
| Local malicious binaries already on $PATH | Out of scope (assume host is trusted) |

## How to file a security issue

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ export {
buildLinuxBwrapArgs,
detectPlatform,
spawnNetworkSandbox,
needsNetworkSandbox,
denyAllNetwork,
NetworkSandboxUnavailable,
startDnsProxy,
type SandboxPlatform,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export {

export {
spawnNetworkSandbox,
needsNetworkSandbox,
denyAllNetwork,
NetworkSandboxUnavailable,
type SpawnNetworkSandboxOpts,
type NetworkSandboxHandle,
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/sandbox/netns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Pure unit tests for the network-sandbox decision helpers. The end-to-end
// orchestration (spawnNetworkSandbox) is exercised by netns-integration.test.ts
// on the Linux CI runner; here we just cover the branch logic that decides
// WHEN to use it and the fail-closed config derivation.

import { describe, expect, it } from 'vitest';
import type { SandboxConfig } from '../config/types.js';
import { denyAllNetwork, needsNetworkSandbox } from './netns.js';

describe('needsNetworkSandbox', () => {
const cfg = (network?: SandboxConfig['network'], enabled = true): SandboxConfig => ({
enabled,
network,
});

it('is true for a non-empty allowlist on Linux', () => {
expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'linux')).toBe(true);
});

it('is false for an empty allowlist (deny-all-net, handled by --unshare-net)', () => {
expect(needsNetworkSandbox(cfg({ allowedDomains: [] }), 'linux')).toBe(false);
});

it('is false when allowedDomains is undefined (full network)', () => {
expect(needsNetworkSandbox(cfg({}), 'linux')).toBe(false);
expect(needsNetworkSandbox(cfg(undefined), 'linux')).toBe(false);
});

it('is false on non-Linux platforms (macOS uses sandbox-exec)', () => {
expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'darwin')).toBe(false);
expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }), 'win32')).toBe(false);
});

it('is false when the sandbox is disabled', () => {
expect(needsNetworkSandbox(cfg({ allowedDomains: ['github.com'] }, false), 'linux')).toBe(
false,
);
});

it('is false for an undefined config', () => {
expect(needsNetworkSandbox(undefined, 'linux')).toBe(false);
});
});

describe('denyAllNetwork', () => {
it('forces allowedDomains to [] (no network)', () => {
const out = denyAllNetwork({ enabled: true, network: { allowedDomains: ['github.com'] } });
expect(out.network?.allowedDomains).toEqual([]);
});

it('preserves other config + network fields', () => {
const out = denyAllNetwork({
enabled: true,
excludedCommands: ['git'],
network: { allowedDomains: ['a.com'], allowUnixSockets: true },
});
expect(out.enabled).toBe(true);
expect(out.excludedCommands).toEqual(['git']);
expect(out.network?.allowUnixSockets).toBe(true);
expect(out.network?.allowedDomains).toEqual([]);
});

it('does not mutate the input', () => {
const input: SandboxConfig = { enabled: true, network: { allowedDomains: ['a.com'] } };
denyAllNetwork(input);
expect(input.network?.allowedDomains).toEqual(['a.com']);
});
});
24 changes: 24 additions & 0 deletions packages/core/src/sandbox/netns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ export class NetworkSandboxUnavailable extends Error {
}
}

/**
* True iff a command should run under the selective-allowlist network sandbox:
* Linux + sandbox enabled + `network.allowedDomains` is a NON-EMPTY allowlist.
* (An empty array means deny-all-net — handled by plain bwrap --unshare-net;
* `undefined` means full network — no netns orchestration needed.)
*/
export function needsNetworkSandbox(
config: SandboxConfig | undefined,
platform: NodeJS.Platform = process.platform,
): boolean {
if (!config?.enabled || platform !== 'linux') return false;
const domains = config.network?.allowedDomains;
return Array.isArray(domains) && domains.length > 0;
}

/**
* Derive a deny-all-network config from `config` (allowedDomains: []). Used as
* the fail-closed fallback when the selective allowlist can't be set up — the
* command runs with NO network rather than unrestricted.
*/
export function denyAllNetwork(config: SandboxConfig): SandboxConfig {
return { ...config, network: { ...config.network, allowedDomains: [] } };
}

export interface SpawnNetworkSandboxOpts {
/** The user shell command to run inside the sandbox. */
userCommand: string;
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/tools/bash.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import type { ChildProcess } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { NetworkSandboxUnavailable } from '../sandbox/index.js';
import type { NetworkSandboxHandle } from '../sandbox/index.js';
import { BashTool } from './bash.js';

// A fake network-sandbox handle: stdout emits `text` then ends; `exited`
// resolves with `code` once stdout drains (so output is captured first).
function fakeNetHandle(text: string, code: number): NetworkSandboxHandle {
const stdout = Readable.from([Buffer.from(text)]);
const stderr = Readable.from([]);
const child = { stdout, stderr } as unknown as ChildProcess;
const exited = new Promise<number | null>((res) => stdout.once('end', () => res(code)));
return { child, exited, close: async () => {} };
}

describe('BashTool', () => {
let tmp: string;
beforeAll(async () => {
Expand Down Expand Up @@ -61,4 +75,54 @@ describe('BashTool', () => {
const suffix = tmp.replace(/^\/tmp\//, '').replace(/^\/private\/tmp\//, '');
expect(r.content).toContain(suffix);
});

// ── selective network allowlist wiring (M3.5-ext) ────────────────────────
// platform + spawner are injected so these run on any OS without real bwrap.
const allowlistCtx = (extra: Record<string, unknown>) => ({
cwd: tmp,
sandboxConfig: { enabled: true, network: { allowedDomains: ['example.com'] } },
sandboxPlatform: 'linux',
...extra,
});

it('routes a non-empty allowlist through the network sandbox', async () => {
let called = false;
const r = await BashTool.execute(
{ command: 'echo ignored-by-fake' },
allowlistCtx({
sandboxNetSpawn: async () => {
called = true;
return fakeNetHandle('net-sandbox-ran\n', 0);
},
}) as never,
);
expect(called).toBe(true);
expect(r.content).toContain('net-sandbox-ran');
expect(r.data?.exitCode).toBe(0);
expect(r.isError).toBeFalsy();
});

it('fails closed to deny-all-net when the network sandbox is unavailable', async () => {
const r = await BashTool.execute(
{ command: 'echo fallback-ran' },
allowlistCtx({
sandboxNetSpawn: async () => {
throw new NetworkSandboxUnavailable('cannot bind DNS proxy on 127.0.0.1:53');
},
}) as never,
);
// The fail-closed note is surfaced, and the command still ran (reached the
// close handler via the deny-all-net fallback wrap).
expect(r.content).toContain('network allowlist unavailable');
expect(r.content).toMatch(/exit:/);
}, 10000);

it('disables network for background commands under an allowlist', async () => {
const r = await BashTool.execute(
{ command: 'echo bg', run_in_background: true },
allowlistCtx({ sessionDir: tmp }) as never,
);
expect(r.isError).toBeFalsy();
expect(r.content).toContain('not supported for background');
}, 5000);
});
Loading
Loading