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
46 changes: 41 additions & 5 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
type Effort,
} from '@deepcode/core';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -423,10 +424,45 @@ export const ConfigCommand: SlashCommand = {

export const AddDirCommand: SlashCommand = {
name: '/add-dir',
description: 'Add an additional allowed directory (M3 enforced; M2 records intent).',
run(args) {
if (args.length === 0) return ['Usage: /add-dir <path>'];
return [`Recorded ${args[0]} as additional allowed directory (effective in M3).`];
description: 'Add a directory the sandboxed Bash tool may write to (persists to settings).',
async run(args, ctx) {
const current = ctx.settings.permissions?.additionalDirectories ?? [];
if (args.length === 0) {
return current.length > 0
? ['Additional writable directories:', ...current.map((d) => ` ${d}`)]
: ['No additional directories yet. Usage: /add-dir <path>'];
}
if (!ctx.userSettingsPath) return ['(/add-dir is unavailable here.)'];

// Store an absolute path: the sandbox profile writers require one, and a
// relative entry would otherwise resolve against whatever cwd a later
// session happens to start in.
const dir = isAbsolute(args[0]!) ? args[0]! : resolve(ctx.cwd, args[0]!);
try {
if (!(await stat(dir)).isDirectory()) return [`Not a directory: ${dir}`];
} catch {
return [`No such directory: ${dir}`];
}

let onDisk: Record<string, unknown> = {};
try {
onDisk = JSON.parse(await readFile(ctx.userSettingsPath, 'utf8')) as Record<string, unknown>;
} catch {
/* missing or unreadable → start fresh */
}
const perms = (onDisk.permissions ?? {}) as Record<string, unknown>;
const existing = Array.isArray(perms.additionalDirectories)
? (perms.additionalDirectories as string[])
: [];
if (existing.includes(dir)) return [`${dir} is already an additional writable directory.`];
perms.additionalDirectories = [...existing, dir];
onDisk.permissions = perms;
await writeSettings(ctx.userSettingsPath, onDisk as DeepCodeSettings);

return [
`Added ${dir} as an additional writable directory.`,
'Applies to the sandboxed Bash tool in new sessions (CLI, desktop, VS Code and LSP).',
];
},
};

Expand Down
13 changes: 11 additions & 2 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
loadMemory,
loadOutputStyles,
loadSettings,
withAdditionalWritableDirs,
loadSkills,
makeSkillTool,
resolveCredentials,
Expand Down Expand Up @@ -234,7 +235,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilitiesHeadless(cwd),
sandbox: settings.sandbox,
sandbox: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
log: (s) => errOutput.write(s + '\n'),
});
} catch (err) {
Expand Down Expand Up @@ -295,7 +300,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
hooks,
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
});
const result = await runtime.run({
systemPrompt,
Expand Down
65 changes: 64 additions & 1 deletion apps/cli/src/parity-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// never real creds). /recap uses a mock provider; /pr_comments' renderer is pure.

import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CredentialsStore, SessionManager } from '@deepcode/core';
Expand Down Expand Up @@ -223,3 +223,66 @@ describe('/btw', () => {
expect(out.join('\n')).toMatch(/Usage: \/btw/);
});
});

describe('/add-dir', () => {
it('persists a validated absolute directory to permissions.additionalDirectories', async () => {
const home = await tmpHome();
const path = join(home, 'settings.json');
const out = await reg.match('/add-dir')!.cmd.run([home], ctx({ userSettingsPath: path }));
expect(out.join('\n')).toMatch(/Added .* writable directory/i);
const written = JSON.parse(await readFile(path, 'utf8')) as {
permissions?: { additionalDirectories?: string[] };
};
expect(written.permissions?.additionalDirectories).toEqual([home]);
});

it('resolves a relative path against cwd before persisting', async () => {
const home = await tmpHome();
const path = join(home, 'settings.json');
await reg.match('/add-dir')!.cmd.run(['.'], ctx({ cwd: home, userSettingsPath: path }));
const written = JSON.parse(await readFile(path, 'utf8')) as {
permissions?: { additionalDirectories?: string[] };
};
// Stored absolute — a relative entry would re-anchor to whatever cwd a
// later session started in.
expect(written.permissions?.additionalDirectories?.[0]).toBe(home);
});

it('rejects a non-existent directory', async () => {
const home = await tmpHome();
const out = await reg
.match('/add-dir')!
.cmd.run([join(home, 'nope')], ctx({ userSettingsPath: join(home, 'settings.json') }));
expect(out.join('\n')).toMatch(/no such directory/i);
});

it('rejects a path that exists but is a file', async () => {
const home = await tmpHome();
const file = join(home, 'a-file');
await writeFile(file, 'x');
const out = await reg
.match('/add-dir')!
.cmd.run([file], ctx({ userSettingsPath: join(home, 'settings.json') }));
expect(out.join('\n')).toMatch(/not a directory/i);
});

it('does not duplicate an already-added directory', async () => {
const home = await tmpHome();
const path = join(home, 'settings.json');
const c = ctx({ userSettingsPath: path });
await reg.match('/add-dir')!.cmd.run([home], c);
const out = await reg.match('/add-dir')!.cmd.run([home], c);
expect(out.join('\n')).toMatch(/already an additional/i);
const written = JSON.parse(await readFile(path, 'utf8')) as {
permissions?: { additionalDirectories?: string[] };
};
expect(written.permissions?.additionalDirectories).toEqual([home]);
});

it('lists current directories with no args', async () => {
const out = await reg
.match('/add-dir')!
.cmd.run([], ctx({ settings: { permissions: { additionalDirectories: ['/x/y'] } } }));
expect(out.join('\n')).toContain('/x/y');
});
});
13 changes: 11 additions & 2 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
resolveCredentials,
settingsPaths,
wirePlugins,
withAdditionalWritableDirs,
collectPluginContributions,
type Effort,
type McpClientHandle,
Expand Down Expand Up @@ -435,7 +436,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
disabled: settings.disabledPlugins,
hooks,
capabilities: buildPluginCapabilities(cwd),
sandbox: settings.sandbox,
sandbox: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
log: (s) => output.write(s + '\n'),
});
} catch (err) {
Expand All @@ -453,7 +458,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
hooks,
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
});
const ctx: SessionContext = {
cwd,
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/default-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export function createDefaultTurnExecutor(
permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] },
hooks: composition.hooks,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
pluginDirs: composition.pluginDirs,
}),
systemPrompt: composition.systemPrompt,
Expand All @@ -79,3 +83,4 @@ export function createDefaultTurnExecutor(
sessionManager,
});
}
import { withAdditionalWritableDirs } from '@deepcode/core';
13 changes: 11 additions & 2 deletions apps/server/src/runtime-composition.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Effort, Mode, Provider } from '@deepcode/core';
import { withAdditionalWritableDirs } from '@deepcode/core';
import type {
DeepCodeSettings,
McpServerConfig,
Expand Down Expand Up @@ -214,11 +215,19 @@ export async function composeRuntime(
hooks,
provider: options.provider,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
sandboxConfig: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
requestApproval: options.requestApproval,
signal: options.signal,
}),
sandbox: settings.sandbox,
sandbox: withAdditionalWritableDirs(
settings.sandbox,
settings.permissions?.additionalDirectories,
cwd,
),
log: () => undefined,
});
for (const plugin of pluginsWire.plugins) {
Expand Down
Loading
Loading