diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index bed3941..b6158a1 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -12,6 +12,7 @@ import { runOnboarding } from './onboarding.js'; import { helpText, parseArgs } from './parse-args.js'; import { startRepl } from './repl.js'; import { runCronCommand, runSchedulerRun } from './scheduler.js'; +import { runTrustCommand } from './trust-cmd.js'; async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -60,6 +61,12 @@ async function main(): Promise { errOutput: process.stderr, }); } + if (args.positional[0] === 'trust') { + return runTrustCommand(args.positional.slice(1), { + cwd: process.cwd(), + output: process.stdout, + }); + } // Headless one-shot (-p / --print) if (args.prompt !== undefined) { diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index cbc320c..d1a152b 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -30,6 +30,7 @@ import { closeAllMcpServers, connectAllMcpServers, expandMcpResourceRefs, + gateUntrustedSettings, contextWindowFor, findStyle, loadMemory, @@ -41,13 +42,13 @@ import { runAgent, wirePlugins, type AgentEvent, - type DeepCodeSettings, type Effort, type McpClientHandle, type Mode, type WireResult, } from '@deepcode/core'; import type { Writable } from 'node:stream'; +import { TrustStore } from './trust.js'; export interface HeadlessOpts { output: Writable; @@ -83,8 +84,19 @@ export async function runHeadless(opts: HeadlessOpts): Promise { const { output, errOutput, cwd, prompt, outputFormat } = opts; // ─── load config + credentials ─────────────────────────────────────── + // Trust-gate: a headless run against an untrusted checkout (e.g. a PR branch) + // must not execute that project's hooks/mcpServers/apiKeyHelper/statusLine. + // The user-global layer stays trusted. Pre-trust with `deepcode trust`. const loaded = await loadSettings({ cwd, home: opts.home }); - const settings: DeepCodeSettings = loaded.merged; + const trustStore = new TrustStore({ home: opts.home }); + const trustStatus = await trustStore.statusFor(cwd); + const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + if (gated.length > 0) { + errOutput.write( + `Untrusted directory — ignoring project ${gated.join(', ')} (can execute code). ` + + `Run \`deepcode trust\` to enable.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 611a8b0..3b208dc 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -269,6 +269,7 @@ USAGE deepcode cron Scheduled tasks: install/uninstall/list/status deepcode scheduler run Run due scheduled jobs (invoked by launchd) deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) + deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) MODE --mode default / acceptEdits / plan / auto / dontAsk / bypassPermissions diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index a38f477..a6762ab 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -29,6 +29,7 @@ import { loadMemory, loadOutputStyles, loadSettings, + gateUntrustedSettings, loadSkills, loadSlashCommands, contextWindowFor, @@ -37,7 +38,6 @@ import { runAgent, settingsPaths, wirePlugins, - type DeepCodeSettings, type Effort, type McpClientHandle, type Mode, @@ -49,6 +49,7 @@ import { createInterface } from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; import { CommandRegistry, type SessionContext } from './commands.js'; import { resolveEffort } from './parse-args.js'; +import { TrustStore } from './trust.js'; export interface ReplOpts { input: Readable; @@ -82,9 +83,19 @@ const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered export async function startRepl(opts: ReplOpts): Promise { const { output, cwd } = opts; - // Load config + creds + // Load config + creds. Trust-gate first: in an untrusted directory, project + // /local hooks·mcpServers·apiKeyHelper·statusLine are stripped (the user-global + // layer is always trusted) so a freshly-cloned repo can't run code on launch. const loaded = await loadSettings({ cwd, home: opts.home }); - const settings: DeepCodeSettings = loaded.merged; + const trustStore = new TrustStore({ home: opts.home }); + const trustStatus = await trustStore.statusFor(cwd); + const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + if (gated.length > 0) { + output.write( + ` ⚠ Untrusted directory — ignoring project ${gated.join(', ')} (can execute code).\n` + + ` Run \`deepcode trust\` here to enable them.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, diff --git a/apps/cli/src/trust-cmd.test.ts b/apps/cli/src/trust-cmd.test.ts new file mode 100644 index 0000000..efebdca --- /dev/null +++ b/apps/cli/src/trust-cmd.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { TrustStore } from './trust.js'; +import { runTrustCommand } from './trust-cmd.js'; + +function sink(): { stream: Writable; text: () => string } { + let buf = ''; + const stream = new Writable({ + write(chunk, _enc, cb) { + buf += chunk.toString(); + cb(); + }, + }); + return { stream, text: () => buf }; +} + +describe('runTrustCommand', () => { + let home: string; + const cwd = '/Users/x/project'; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-trustcmd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('trusts the cwd at full mode by default', async () => { + const out = sink(); + const code = await runTrustCommand([], { cwd, home, output: out.stream }); + expect(code).toBe(0); + expect(out.text()).toMatch(/Trusted .* enabled here/); + expect(await new TrustStore({ home }).statusFor(cwd)).toBe('trusted'); + }); + + it('trusts plan-only with --plan-only', async () => { + const out = sink(); + await runTrustCommand(['--plan-only'], { cwd, home, output: out.stream }); + expect(out.text()).toMatch(/plan-only/); + expect(await new TrustStore({ home }).statusFor(cwd)).toBe('plan-only'); + }); + + it('--remove untrusts the cwd', async () => { + await runTrustCommand([], { cwd, home, output: sink().stream }); + const out = sink(); + await runTrustCommand(['--remove'], { cwd, home, output: out.stream }); + expect(out.text()).toMatch(/Removed trust/); + expect(await new TrustStore({ home }).statusFor(cwd)).toBe('untrusted'); + }); + + it('--list shows trusted directories', async () => { + await runTrustCommand([], { cwd, home, output: sink().stream }); + await runTrustCommand(['--plan-only'], { cwd: '/Users/x/other', home, output: sink().stream }); + const out = sink(); + await runTrustCommand(['--list'], { cwd, home, output: out.stream }); + expect(out.text()).toContain('/Users/x/project'); + expect(out.text()).toContain('/Users/x/other'); + expect(out.text()).toMatch(/plan-only/); + }); + + it('--list reports empty store', async () => { + const out = sink(); + await runTrustCommand(['--list'], { cwd, home, output: out.stream }); + expect(out.text()).toMatch(/No trusted directories/); + }); +}); diff --git a/apps/cli/src/trust-cmd.ts b/apps/cli/src/trust-cmd.ts new file mode 100644 index 0000000..9702c19 --- /dev/null +++ b/apps/cli/src/trust-cmd.ts @@ -0,0 +1,49 @@ +// `deepcode trust [--plan-only | --remove | --list]` — manage directory trust. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 +// +// Trusting a directory lets its project-local settings.json run code (hooks, +// MCP servers, apiKeyHelper, statusLine). Until trusted, those are gated (see +// core/config/trust-gate). The user-global layer is always trusted. + +import type { Writable } from 'node:stream'; +import { TrustStore } from './trust.js'; + +export interface TrustCmdDeps { + cwd: string; + home?: string; + output?: Writable; +} + +export async function runTrustCommand(args: string[], deps: TrustCmdDeps): Promise { + const out = deps.output ?? process.stdout; + const store = new TrustStore({ home: deps.home }); + + if (args.includes('--list')) { + const state = await store.load(); + const entries = Object.entries(state.dirs); + if (entries.length === 0) { + out.write('No trusted directories.\n'); + return 0; + } + for (const [dir, info] of entries) { + const label = info.mode === 'plan-only' ? 'plan-only' : 'full'; + out.write(`${label.padEnd(9)} ${dir}\n`); + } + return 0; + } + + if (args.includes('--remove')) { + await store.untrust(deps.cwd); + out.write(`Removed trust for ${deps.cwd}\n`); + return 0; + } + + const mode = args.includes('--plan-only') ? 'plan-only' : 'full'; + await store.trust(deps.cwd, mode); + out.write( + mode === 'plan-only' + ? `Trusted ${deps.cwd} (plan-only — project config can run, but the session starts in plan mode).\n` + : `Trusted ${deps.cwd} — project hooks, MCP servers, apiKeyHelper, and statusLine are now enabled here.\n`, + ); + return 0; +} diff --git a/apps/cli/src/trust.test.ts b/apps/cli/src/trust.test.ts index e4722be..01a75a5 100644 --- a/apps/cli/src/trust.test.ts +++ b/apps/cli/src/trust.test.ts @@ -46,4 +46,20 @@ describe('TrustStore', () => { await s.trust(process.cwd(), 'full'); expect(await s.statusFor('.')).toBe('trusted'); }); + + it('does not leak state across stores with separate homes (no shared empty)', async () => { + // Regression: load() used to return a shallow copy of a module-level EMPTY + // whose `dirs` was shared, so trust() on one store polluted later loads of + // a not-yet-created store file under a different home. + const otherHome = await mkdtemp(join(tmpdir(), 'dc-trust-other-')); + try { + await new TrustStore({ home }).trust('/proj/a', 'full'); + // A brand-new store at a different home must see an EMPTY state. + const fresh = await new TrustStore({ home: otherHome }).load(); + expect(fresh.dirs).toEqual({}); + expect(await new TrustStore({ home: otherHome }).statusFor('/proj/a')).toBe('untrusted'); + } finally { + await rm(otherHome, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/src/trust.ts b/apps/cli/src/trust.ts index f880d5d..89ab5d8 100644 --- a/apps/cli/src/trust.ts +++ b/apps/cli/src/trust.ts @@ -11,7 +11,12 @@ export interface TrustState { dirs: Record; } -const EMPTY: TrustState = { dirs: {} }; +/** A fresh empty state. Must be a factory — returning a shared object literal + * would let `trust()`/`untrust()` mutate `dirs` on the shared instance, leaking + * entries into later `load()`s of a not-yet-created store file. */ +function emptyState(): TrustState { + return { dirs: {} }; +} export interface TrustStoreOpts { home?: string; @@ -32,7 +37,7 @@ export class TrustStore { const raw = await fs.readFile(this.filePath(), 'utf8'); return JSON.parse(raw) as TrustState; } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ...EMPTY }; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return emptyState(); throw err; } } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 3c4d636..02500ee 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -27,6 +27,14 @@ export { type LoadSettingsOpts, } from './loader.js'; +export { + gateUntrustedSettings, + TRUST_GATED_FIELDS, + type TrustStatus, + type TrustGatedField, + type GateResult, +} from './trust-gate.js'; + export { evaluatePermission, matchRule, diff --git a/packages/core/src/config/trust-gate.test.ts b/packages/core/src/config/trust-gate.test.ts new file mode 100644 index 0000000..b6a2e82 --- /dev/null +++ b/packages/core/src/config/trust-gate.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import type { LoadedSettings } from './loader.js'; +import { gateUntrustedSettings, TRUST_GATED_FIELDS } from './trust-gate.js'; + +function loaded(layers: LoadedSettings['layers']): LoadedSettings { + // Minimal merge mirroring loader semantics (project/local override user). + const merged = { ...(layers.user ?? {}), ...(layers.project ?? {}), ...(layers.local ?? {}) }; + return { + merged, + layers, + sources: { userPath: '/u', projectPath: '/p', localPath: '/l' }, + }; +} + +describe('gateUntrustedSettings', () => { + it('trusted: returns merged settings unchanged, nothing gated', () => { + const l = loaded({ + user: { model: 'deepseek-chat' }, + project: { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }, + }); + const r = gateUntrustedSettings(l, 'trusted'); + expect(r.gated).toEqual([]); + expect(r.settings.hooks).toBeDefined(); + expect(r.settings).toBe(l.merged); // same reference — no copy when trusted + }); + + it('untrusted: strips project-layer exec fields and lists them', () => { + const l = loaded({ + user: { model: 'deepseek-chat' }, + project: { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'rm -rf /' }] }] }, + mcpServers: { evil: { command: 'curl', args: ['evil.sh'] } }, + apiKeyHelper: 'leak-my-key.sh', + statusLine: { type: 'command', command: 'pwn.sh' }, + }, + }); + const r = gateUntrustedSettings(l, 'untrusted'); + expect(r.gated.sort()).toEqual([...TRUST_GATED_FIELDS].sort()); + expect(r.settings.hooks).toBeUndefined(); + expect(r.settings.mcpServers).toBeUndefined(); + expect(r.settings.apiKeyHelper).toBeUndefined(); + expect(r.settings.statusLine).toBeUndefined(); + // non-exec fields survive + expect(r.settings.model).toBe('deepseek-chat'); + }); + + it('untrusted: keeps the user-global layer value for an exec field', () => { + const l = loaded({ + user: { apiKeyHelper: 'user-global-helper.sh' }, + project: { apiKeyHelper: 'project-helper.sh' }, + }); + const r = gateUntrustedSettings(l, 'untrusted'); + // project's helper is gated, but the user's own global helper is trusted. + expect(r.settings.apiKeyHelper).toBe('user-global-helper.sh'); + expect(r.gated).toContain('apiKeyHelper'); + }); + + it('untrusted: gates a field set only in the local layer', () => { + const l = loaded({ + user: {}, + local: { mcpServers: { x: { command: 'node' } } }, + }); + const r = gateUntrustedSettings(l, 'untrusted'); + expect(r.gated).toEqual(['mcpServers']); + expect(r.settings.mcpServers).toBeUndefined(); + }); + + it('untrusted: nothing to gate when project/local set no exec fields', () => { + const l = loaded({ user: { hooks: {} }, project: { model: 'deepseek-reasoner' } }); + const r = gateUntrustedSettings(l, 'untrusted'); + expect(r.gated).toEqual([]); + // user-layer hooks preserved; project's model still applies + expect(r.settings.hooks).toEqual({}); + expect(r.settings.model).toBe('deepseek-reasoner'); + }); + + it('plan-only gates exec fields exactly like untrusted', () => { + const l = loaded({ user: {}, project: { apiKeyHelper: 'x.sh' } }); + expect(gateUntrustedSettings(l, 'plan-only').gated).toEqual(['apiKeyHelper']); + expect(gateUntrustedSettings(l, 'plan-only').settings.apiKeyHelper).toBeUndefined(); + }); + + it('does not mutate the input layers', () => { + const l = loaded({ user: {}, project: { apiKeyHelper: 'x.sh' } }); + gateUntrustedSettings(l, 'untrusted'); + expect(l.layers.project?.apiKeyHelper).toBe('x.sh'); // untouched + expect(l.merged.apiKeyHelper).toBe('x.sh'); // merged untouched (copy was returned) + }); +}); diff --git a/packages/core/src/config/trust-gate.ts b/packages/core/src/config/trust-gate.ts new file mode 100644 index 0000000..7c04fd6 --- /dev/null +++ b/packages/core/src/config/trust-gate.ts @@ -0,0 +1,52 @@ +// Trust gating — when a working directory isn't trusted, strip the settings +// fields that execute arbitrary code (hooks, MCP servers, apiKeyHelper, +// statusLine) IF they came from the project/local layers. The user-global layer +// (~/.deepcode/settings.json) is always trusted, so its values are kept. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 (Trust dialog) + +import type { LoadedSettings } from './loader.js'; +import type { DeepCodeSettings } from './types.js'; + +export type TrustStatus = 'trusted' | 'plan-only' | 'untrusted'; + +/** Project/local settings fields that can execute arbitrary shell/processes. */ +export const TRUST_GATED_FIELDS = ['hooks', 'mcpServers', 'apiKeyHelper', 'statusLine'] as const; +export type TrustGatedField = (typeof TRUST_GATED_FIELDS)[number]; + +export interface GateResult { + /** The effective settings after gating (a shallow copy; layers untouched). */ + settings: DeepCodeSettings; + /** Gated fields that were present in the project/local layers and stripped. */ + gated: TrustGatedField[]; +} + +function copyOrDelete(dst: DeepCodeSettings, src: DeepCodeSettings, key: TrustGatedField): void { + const v = src[key]; + if (v !== undefined) (dst as Record)[key] = v; + else delete (dst as Record)[key]; +} + +/** + * Return the effective settings for a directory at the given trust `status`. + * + * - `trusted` → merged settings unchanged. + * - `untrusted` / `plan-only` → each exec-bearing field is reset to the + * user-global layer's value (or removed if the user layer doesn't set it), + * so a project's `.deepcode/settings.json` can't run code until the user + * trusts the directory. `gated` lists which fields were actually stripped + * (i.e. the project/local layer had tried to set them). + */ +export function gateUntrustedSettings(loaded: LoadedSettings, status: TrustStatus): GateResult { + if (status === 'trusted') return { settings: loaded.merged, gated: [] }; + + const user = loaded.layers.user ?? {}; + const { project, local } = loaded.layers; + const settings: DeepCodeSettings = { ...loaded.merged }; + const gated: TrustGatedField[] = []; + + for (const key of TRUST_GATED_FIELDS) { + if (project?.[key] !== undefined || local?.[key] !== undefined) gated.push(key); + copyOrDelete(settings, user, key); + } + return { settings, gated }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f1d935..94f8ba4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -76,6 +76,8 @@ export { settingsPaths, deepMerge, appendAllowMatcher, + gateUntrustedSettings, + TRUST_GATED_FIELDS, evaluatePermission, matchRule, parseRule, @@ -84,6 +86,9 @@ export { type PermissionRules, type LoadedSettings, type LoadSettingsOpts, + type TrustStatus, + type TrustGatedField, + type GateResult, type PermissionVerdict, type PermissionRequest, type Hooks,