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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### ⚠️ Breaking

- **`deepcode mcp serve` now applies your permission settings.** It executed
Read/Write/Edit/Bash for any connected MCP peer with no mode, no permission
rules, no file contract and no `PreToolUse` hooks — the same shape as the
`runAgent` bypass fixed in #181, in a different entry point. Every call now
goes through the central gate, a call that would need approval is **refused**
(nobody is attached to that pipe to approve it), and a permissive
`permissions.defaultMode` is clamped to `default` exactly as a scheduled job's
is. A peer can now do what `permissions.allow` says it can and nothing else,
so anyone relying on the old behaviour must add rules — or start the server
with an explicit `--mode`. `--sandbox` also applies now; it did not before.

### 🔒 Security

- **A sub-agent did not inherit the file contract.** The `Task` delegation
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ async function main(): Promise<number> {
cwd: process.cwd(),
output: process.stdout,
errOutput: process.stderr,
// `mcp serve` has no attached user, so a permissive ambient mode is
// clamped unless --mode says otherwise. Same rule as a scheduled job.
mode: args.mode,
sandbox: args.sandbox,
});
}
if (args.positional[0] === 'app-server') {
Expand Down
99 changes: 98 additions & 1 deletion apps/cli/src/mcp-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Writable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Mode, ServeMcpStdioOpts } from '@deepcode/core';
import { runMcpCommand } from './mcp-cmd.js';

function sink(): { stream: Writable; text: () => string } {
Expand Down Expand Up @@ -51,3 +55,96 @@ describe('runMcpCommand', () => {
expect(err.text()).toMatch(/\[mcp\] ready: Read, Write/);
});
});

// The served tools are Read/Write/Edit/Bash in a real project, and nobody is on
// the other end of the pipe to approve anything.
describe('runMcpCommand serve — permission posture', () => {
let home: string;
let cwd: string;

beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dc-mcp-home-'));
cwd = await mkdtemp(join(tmpdir(), 'dc-mcp-cwd-'));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
await rm(cwd, { recursive: true, force: true });
});

async function serve(opts: { mode?: Mode } = {}): Promise<{
err: string;
captured: ServeMcpStdioOpts | undefined;
}> {
const out = sink();
const err = sink();
let captured: ServeMcpStdioOpts | undefined;
await runMcpCommand(['serve'], {
cwd,
home,
output: out.stream,
errOutput: err.stream,
mode: opts.mode,
serve: async (o) => {
captured = o;
},
});
return { err: err.text(), captured };
}

async function writeUserSettings(settings: Record<string, unknown>): Promise<void> {
await mkdir(join(home, '.deepcode'), { recursive: true });
await writeFile(join(home, '.deepcode', 'settings.json'), JSON.stringify(settings));
}

it('passes a gate at all — the server cannot be built without one', async () => {
const { captured } = await serve();
expect(captured?.gate).toBeTypeOf('function');
});

it('refuses a call that would need approval', async () => {
const { captured } = await serve();
const verdict = await captured!.gate({
tool: 'Write',
input: { file_path: join(cwd, 'x.txt'), content: 'x' },
});
expect(verdict.allowed).toBe(false);
expect(verdict.reason).toMatch(/no attached user/);
});

it('clamps a permissive ambient mode and says so', async () => {
// `bypassPermissions` in settings.json is a choice about sitting at a REPL.
// Inheriting it here would hand "never ask me" to whatever connected.
await writeUserSettings({ permissions: { defaultMode: 'bypassPermissions' } });
const { err, captured } = await serve();
expect(err).toMatch(/was not applied to this unattended run/);
expect(err).toMatch(/mode=default/);

const verdict = await captured!.gate({
tool: 'Bash',
input: { command: 'rm -rf /' },
});
expect(verdict.allowed).toBe(false);
});

it('--mode is the explicit opt-in back out of the clamp', async () => {
await writeUserSettings({ permissions: { defaultMode: 'bypassPermissions' } });
const { err, captured } = await serve({ mode: 'bypassPermissions' });
expect(err).not.toMatch(/was not applied/);
expect(err).toMatch(/mode=bypassPermissions/);
expect((await captured!.gate({ tool: 'Bash', input: { command: 'echo hi' } })).allowed).toBe(
true,
);
});

it('honours permissions.allow without any mode change', async () => {
await writeUserSettings({ permissions: { allow: ['Read'] } });
const { captured } = await serve();
expect(
(await captured!.gate({ tool: 'Read', input: { file_path: join(cwd, 'a') } })).allowed,
).toBe(true);
expect(
(await captured!.gate({ tool: 'Write', input: { file_path: join(cwd, 'a'), content: '' } }))
.allowed,
).toBe(false);
});
});
70 changes: 68 additions & 2 deletions apps/cli/src/mcp-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@
// line goes to stderr, and nothing else may touch stdout.

import {
HookDispatcher,
VERSION,
buildMcpGate,
describeClamp,
gateUntrustedSettings,
loadFileContract,
loadSettings,
mcpServableTools,
resolveTriggerMode,
serveMcpOverStdio,
withSandboxMode,
type Mode,
type SandboxMode,
type ServeMcpStdioOpts,
} from '@deepcode/core';
import type { Writable } from 'node:stream';
import { TrustStore } from './trust.js';

export interface McpCmdDeps {
cwd: string;
Expand All @@ -23,6 +34,12 @@ export interface McpCmdDeps {
signal?: AbortSignal;
/** Serve implementation — injectable so tests don't grab the real stdio. */
serve?: (opts: ServeMcpStdioOpts) => Promise<void>;
/** Override `~/.deepcode` (tests). */
home?: string;
/** `--mode`: the explicit opt-in out of the unattended clamp. */
mode?: Mode;
/** `--sandbox`: tightens the sandbox for served commands. */
sandbox?: SandboxMode;
}

export async function runMcpCommand(sub: string[], deps: McpCmdDeps): Promise<number> {
Expand All @@ -32,13 +49,62 @@ export async function runMcpCommand(sub: string[], deps: McpCmdDeps): Promise<nu

if (cmd === 'serve') {
const tools = mcpServableTools();
const { cwd } = deps;

// The served tools are Read/Write/Edit/Bash in a real project. Which of
// them a peer may call is a settings question, not a "you connected, so
// you may" question — so load the same policy every other host loads,
// including the directory trust gate that stops an untrusted checkout from
// widening its own permissions.
const loaded = await loadSettings({ cwd, home: deps.home });
const trustStatus = await new TrustStore({ home: deps.home }).statusFor(cwd);
const trustGate = gateUntrustedSettings(loaded, trustStatus);
const settings = trustGate.settings;
if (trustGate.gated.length > 0) {
err.write(
`[mcp] untrusted directory — ignoring project ${trustGate.gated.join(', ')}. ` +
`Run \`deepcode trust\` to enable.\n`,
);
}

// Nobody is attached to this pipe, so a permissive `defaultMode` picked for
// REPL convenience must not become the posture of whatever connects. Same
// clamp, and the same explicit opt-in, that scheduled jobs use.
const ambient = (settings.permissions?.defaultMode ?? 'default') as Mode;
const resolved = resolveTriggerMode(deps.mode ? { mode: deps.mode } : undefined, ambient);
const clampNote = describeClamp(resolved);
if (clampNote) err.write(`[mcp] ${clampNote}\n`);

const contract = await loadFileContract({ cwd, home: deps.home });
if (contract.status === 'invalid') {
err.write(`[mcp] file contract could not be parsed: ${contract.error}\n`);
}

const hooks = new HookDispatcher({
hooks: settings.hooks,
disableAllHooks: settings.disableAllHooks,
allowedHttpHookUrls: settings.allowedHttpHookUrls,
});

err.write(
`DeepCode MCP server v${VERSION} — exposing ${tools.length} tools over stdio in ${deps.cwd}\n`,
`DeepCode MCP server v${VERSION} — exposing ${tools.length} tools over stdio in ${cwd}\n`,
);
err.write(`[mcp] mode=${resolved.mode}; calls needing approval are refused, not granted\n`);

await (deps.serve ?? serveMcpOverStdio)({
cwd: deps.cwd,
cwd,
version: VERSION,
signal: deps.signal,
gate: buildMcpGate({
cwd,
mode: resolved.mode,
permissions: settings.permissions,
contract: contract.contract,
hooks,
autoMode: settings.autoMode,
}),
contract: contract.contract,
sandboxConfig: withSandboxMode(settings.sandbox, deps.sandbox),
onReady: (names) => err.write(`[mcp] ready: ${names.join(', ')}\n`),
});
return 0;
Expand Down
26 changes: 26 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ decreasing order of operator severity:
| 8 | Model reads an in-project secret (`.env`, `*.pem`) through a read tool | Partly mitigated | File contract `read: deny` — covers Read/Grep/Glob, **not Bash** (see below) |
| 9 | Unattended job runs with a permissive mode inherited from interactive settings | Mitigated | Trigger profile clamp + `onApprovalRequired` |
| 10 | User cannot audit or undo what the agent wrote | Mitigated | Change ledger + `deepcode ledger rollback` through the apply ceremony |
| 11 | An MCP peer calls DeepCode's own tools without any policy | Mitigated | `mcp serve` routes every call through `dispatchToolCall`; `ask` is refused (see below) |

### `deepcode mcp serve` runs with nobody attached

`mcp serve` exposes Read / Write / Edit / Bash / Grep / Glob to whatever MCP
client connects — typically another agent. It executed them directly, with no
mode, no permission rules, no file contract and no `PreToolUse` hooks: the same
shape as the `runAgent` bypass fixed in #181, in a different entry point. The
original plan listed the missing permission model as a known risk and deferred
the design document; the feature shipped without either.

Every call now goes through `dispatchToolCall`, and:

- **`ask` is refused, not granted.** There is no user on that pipe. Granting
would make "whoever connected" the authority on what may run.
- **A permissive `permissions.defaultMode` is clamped** to `default`, exactly as
a scheduled job's is. `bypassPermissions` is a decision about sitting at a
REPL; inheriting it here hands "never ask me" to a peer.
- **`--mode` is the explicit opt-in** back out of that clamp, and `--sandbox`
tightens the sandbox for served commands.
- Directory trust still gates project settings, so an untrusted checkout cannot
widen the posture the server runs under.

The practical consequence: a peer can do what `permissions.allow` says it can,
and nothing else. That is a real reduction in capability for anyone who was
relying on the old behaviour, and it is the point.

### Residual risk: the file contract is policy, not a boundary

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export {
connectAllMcpServers,
closeAllMcpServers,
buildMcpServer,
buildMcpGate,
serveMcpOverStdio,
mcpServableTools,
MCP_SERVE_EXCLUDE,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ export {

export {
buildMcpServer,
buildMcpGate,
serveMcpOverStdio,
mcpServableTools,
MCP_SERVE_EXCLUDE,
type BuildMcpServerOpts,
type McpGateOptions,
type McpGateVerdict,
type McpToolGate,
type ServeMcpStdioOpts,
} from './serve.js';

Expand Down
Loading
Loading