diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index a405449..e9b00b0 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -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); @@ -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 ']; - 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 ']; + } + 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 = {}; + try { + onDisk = JSON.parse(await readFile(ctx.userSettingsPath, 'utf8')) as Record; + } catch { + /* missing or unreadable โ†’ start fresh */ + } + const perms = (onDisk.permissions ?? {}) as Record; + 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).', + ]; }, }; diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 3437d2d..f011175 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -38,6 +38,7 @@ import { loadMemory, loadOutputStyles, loadSettings, + withAdditionalWritableDirs, loadSkills, makeSkillTool, resolveCredentials, @@ -234,7 +235,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise { 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) { @@ -295,7 +300,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise { hooks, pluginDirs: pluginContrib.dirs, autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, + sandboxConfig: withAdditionalWritableDirs( + settings.sandbox, + settings.permissions?.additionalDirectories, + cwd, + ), }); const result = await runtime.run({ systemPrompt, diff --git a/apps/cli/src/parity-commands.test.ts b/apps/cli/src/parity-commands.test.ts index 1950df2..be25438 100644 --- a/apps/cli/src/parity-commands.test.ts +++ b/apps/cli/src/parity-commands.test.ts @@ -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'; @@ -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'); + }); +}); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 35abfa1..4e2c5bd 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -41,6 +41,7 @@ import { resolveCredentials, settingsPaths, wirePlugins, + withAdditionalWritableDirs, collectPluginContributions, type Effort, type McpClientHandle, @@ -435,7 +436,11 @@ export async function startRepl(opts: ReplOpts): Promise { 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) { @@ -453,7 +458,11 @@ export async function startRepl(opts: ReplOpts): Promise { hooks, pluginDirs: pluginContrib.dirs, autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, + sandboxConfig: withAdditionalWritableDirs( + settings.sandbox, + settings.permissions?.additionalDirectories, + cwd, + ), }); const ctx: SessionContext = { cwd, diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index e727c89..3972fb0 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -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, @@ -79,3 +83,4 @@ export function createDefaultTurnExecutor( sessionManager, }); } +import { withAdditionalWritableDirs } from '@deepcode/core'; diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts index 7b2db53..d66ba3c 100644 --- a/apps/server/src/runtime-composition.ts +++ b/apps/server/src/runtime-composition.ts @@ -1,4 +1,5 @@ import type { Effort, Mode, Provider } from '@deepcode/core'; +import { withAdditionalWritableDirs } from '@deepcode/core'; import type { DeepCodeSettings, McpServerConfig, @@ -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) { diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index 3898874..05d2db0 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -23,55 +23,55 @@ Legend: `โœ…` matches ยท `๐ŸŸก` matches with caveats ยท `๐Ÿ”„` deferred ยท `โš  ## Slash commands (30+ in Claude Code, ~32 shipped in DeepCode) -| Command | Claude Code | DeepCode | Status | -| -------------------------- | ----------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/help` | โœ“ | โœ“ | โœ… | -| `/clear` | โœ“ | โœ“ | โœ… | -| `/exit` / `/quit` | โœ“ | โœ“ | โœ… | -| `/status` / `/doctor` | โœ“ | โœ“ | โœ… | -| `/model` | โœ“ | โœ“ | โœ… DeepCode constrains to deepseek-\* (model picker doesn't show foreign providers) | -| `/mode` | โœ“ | โœ“ | โœ… | -| `/effort` | โœ“ | โœ“ | ๐ŸŸก โ€” CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort `; arrow-key selector is GUI-only (M6) | -| `/cost` / `/usage` | โœ“ | โœ“ | โœ… | -| `/context` | โœ“ | โœ“ | โœ… | -| `/config` | โœ“ | โœ“ | ๐ŸŸก โ€” dumps merged settings + `/config set ` (dotted keys, JSON values) writes user settings; no full arrow-key editor | -| `/resume` | โœ“ | โœ“ | โœ… โ€” lists recent sessions; `/resume ` switches the live session in-REPL; `--resume ` / `-r` at launch | -| `/init` | โœ“ | โœ“ | โœ… โ€” interactive 3-phase REPL flow (scan โ†’ draft โ†’ approve-write `AGENTS.md`) | -| `/mcp` | โœ“ | โœ“ | โœ… | -| `/add-dir` | โœ“ | โœ“ (records intent) | ๐ŸŸก โ€” M3 will enforce | -| `/todos` | โœ“ | โœ“ | โœ… โ€” reads `/todos.json` written by TodoWrite tool | -| `/plugins` | โœ“ | โœ“ | โœ… โ€” lists wired plugins + contributed hook events + warnings (M5.2) | -| `/compact` | โœ“ | โœ“ | โœ… โ€” manual `/compact` + automatic threshold trigger in the agent loop | -| `/diff` | โœ“ | โœ“ | โœ… โ€” git diff + untracked files in the working tree (PR #150) | -| `/btw` | โœ“ | โœ“ | ๐ŸŸก โ€” queues a "by the way" context note the agent sees with your next message (no turn fired); exact Claude Code behavior may differ | -| `/recap` | โœ“ | โœ“ | โœ… โ€” provider-summarized recap of the session so far | -| `/rewind` | โœ“ | โœ“ | โœ… โ€” 5 ops (code/conversation/both/summarize-from/up-to); `Esc Esc` bound | -| `/voice` | โœ“ | โœ“ | ๐ŸŸก โ€” local whisper.cpp dictation on CLI (`/voice`: ffmpeg/sox โ†’ transcribe โ†’ pre-fill) **and** desktop (๐ŸŽ™ composer button, ffmpeg). Fully on-device; real-mic round-trip needs local verification | -| `/teleport` | โœ“ | โœ— | ๐Ÿ”„ M8 | -| `/desktop` | โœ“ | โœ— | ๐Ÿ”„ M6 | -| `/background` | โœ“ | โœ“ | โœ… โ€” runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too | -| `/batch` | โœ“ | โœ— | ๐Ÿ”„ โ€” batch-of-prompts not yet wired (use `/background` per prompt) | -| `/tasks` | โœ“ | โœ“ | โœ… โ€” lists this session's background tasks; `/tasks ` shows one's status + output | -| `/plan` | โœ“ | โœ— | ๐Ÿ”„ โ€” set via `/mode plan` in DeepCode | -| `/login` / `/logout` | โœ“ | โœ“ | โœ… โ€” /logout clears creds + exits; /login stores a new key (next launch) | -| `/export` | โœ“ | โœ“ | โœ… โ€” writes the conversation to a markdown file | -| `/bug` (alias `/feedback`) | โœ“ | โœ“ | โœ… โ€” prints a prefilled GitHub issue link (model/mode/effort in the body) | -| `/upgrade` | โœ“ | โœ“ | โœ… โ€” prints version + `npm i -g deepcode-cli@latest` (also the `deepcode upgrade` subcommand) | -| `/pr_comments` | โœ“ | โœ“ | โœ… โ€” `gh pr view` comments for the current branch's PR | -| `/review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | -| `/security-review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | -| `/schedule` | โœ“ | โœ— (skill avail) | ๐ŸŸก | -| `/loop` | โœ“ | โœ— (skill avail) | ๐ŸŸก | -| `/terminal-setup` | โœ“ | โœ— | ๐Ÿ”„ | -| `/vim` | โœ“ | โœ“ | โœ… โ€” toggles Vim mode (persists to `~/.deepcode/keybindings.json`) | -| `/keybindings` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” Claude Code opens/creates the keybindings config; ours lists bindings (edit `~/.deepcode/keybindings.json` manually) | -| `/agents` | โœ“ | โœ“ | โœ… โ€” lists sub-agents from `.deepcode/agents/` | -| `/hooks` | โœ“ | โœ“ | โœ… โ€” lists hooks configured in settings.json | -| `/skills` | โœ“ | โœ“ | โœ… โ€” lists built-in + user + project skills | -| `/permissions` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” shows rules + default mode (interactive editor deferred) | -| `/privacy-settings` | โœ“ | โœ“ | โœ… โ€” summarizes local data locations + what's sent to the DeepSeek API (read-only) | -| `/migrate-installer` | โœ“ | โœ— | ๐Ÿ”„ | -| `/release-notes` | โœ“ | โœ“ | โœ… โ€” prints the latest `CHANGELOG.md` entry | +| Command | Claude Code | DeepCode | Status | +| -------------------------- | ----------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/help` | โœ“ | โœ“ | โœ… | +| `/clear` | โœ“ | โœ“ | โœ… | +| `/exit` / `/quit` | โœ“ | โœ“ | โœ… | +| `/status` / `/doctor` | โœ“ | โœ“ | โœ… | +| `/model` | โœ“ | โœ“ | โœ… DeepCode constrains to deepseek-\* (model picker doesn't show foreign providers) | +| `/mode` | โœ“ | โœ“ | โœ… | +| `/effort` | โœ“ | โœ“ | ๐ŸŸก โ€” CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort `; arrow-key selector is GUI-only (M6) | +| `/cost` / `/usage` | โœ“ | โœ“ | โœ… | +| `/context` | โœ“ | โœ“ | โœ… | +| `/config` | โœ“ | โœ“ | ๐ŸŸก โ€” dumps merged settings + `/config set ` (dotted keys, JSON values) writes user settings; no full arrow-key editor | +| `/resume` | โœ“ | โœ“ | โœ… โ€” lists recent sessions; `/resume ` switches the live session in-REPL; `--resume ` / `-r` at launch | +| `/init` | โœ“ | โœ“ | โœ… โ€” interactive 3-phase REPL flow (scan โ†’ draft โ†’ approve-write `AGENTS.md`) | +| `/mcp` | โœ“ | โœ“ | โœ… | +| `/add-dir` | โœ“ | โœ“ | โœ… โ€” persists to `permissions.additionalDirectories`; folded into the sandbox's `filesystem.allowWrite` by every host (CLI REPL/headless + app-server, so desktop/VS Code/LSP too) | +| `/todos` | โœ“ | โœ“ | โœ… โ€” reads `/todos.json` written by TodoWrite tool | +| `/plugins` | โœ“ | โœ“ | โœ… โ€” lists wired plugins + contributed hook events + warnings (M5.2) | +| `/compact` | โœ“ | โœ“ | โœ… โ€” manual `/compact` + automatic threshold trigger in the agent loop | +| `/diff` | โœ“ | โœ“ | โœ… โ€” git diff + untracked files in the working tree (PR #150) | +| `/btw` | โœ“ | โœ“ | ๐ŸŸก โ€” queues a "by the way" context note the agent sees with your next message (no turn fired); exact Claude Code behavior may differ | +| `/recap` | โœ“ | โœ“ | โœ… โ€” provider-summarized recap of the session so far | +| `/rewind` | โœ“ | โœ“ | โœ… โ€” 5 ops (code/conversation/both/summarize-from/up-to); `Esc Esc` bound | +| `/voice` | โœ“ | โœ“ | ๐ŸŸก โ€” local whisper.cpp dictation on CLI (`/voice`: ffmpeg/sox โ†’ transcribe โ†’ pre-fill) **and** desktop (๐ŸŽ™ composer button, ffmpeg). Fully on-device; real-mic round-trip needs local verification | +| `/teleport` | โœ“ | โœ— | ๐Ÿ”„ M8 | +| `/desktop` | โœ“ | โœ— | ๐Ÿ”„ M6 | +| `/background` | โœ“ | โœ“ | โœ… โ€” runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too | +| `/batch` | โœ“ | โœ— | ๐Ÿ”„ โ€” batch-of-prompts not yet wired (use `/background` per prompt) | +| `/tasks` | โœ“ | โœ“ | โœ… โ€” lists this session's background tasks; `/tasks ` shows one's status + output | +| `/plan` | โœ“ | โœ— | ๐Ÿ”„ โ€” set via `/mode plan` in DeepCode | +| `/login` / `/logout` | โœ“ | โœ“ | โœ… โ€” /logout clears creds + exits; /login stores a new key (next launch) | +| `/export` | โœ“ | โœ“ | โœ… โ€” writes the conversation to a markdown file | +| `/bug` (alias `/feedback`) | โœ“ | โœ“ | โœ… โ€” prints a prefilled GitHub issue link (model/mode/effort in the body) | +| `/upgrade` | โœ“ | โœ“ | โœ… โ€” prints version + `npm i -g deepcode-cli@latest` (also the `deepcode upgrade` subcommand) | +| `/pr_comments` | โœ“ | โœ“ | โœ… โ€” `gh pr view` comments for the current branch's PR | +| `/review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | +| `/security-review` | โœ“ | โœ— (skill avail) | ๐ŸŸก โ€” via Skill tool | +| `/schedule` | โœ“ | โœ— (skill avail) | ๐ŸŸก | +| `/loop` | โœ“ | โœ— (skill avail) | ๐ŸŸก | +| `/terminal-setup` | โœ“ | โœ— | ๐Ÿ”„ | +| `/vim` | โœ“ | โœ“ | โœ… โ€” toggles Vim mode (persists to `~/.deepcode/keybindings.json`) | +| `/keybindings` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” Claude Code opens/creates the keybindings config; ours lists bindings (edit `~/.deepcode/keybindings.json` manually) | +| `/agents` | โœ“ | โœ“ | โœ… โ€” lists sub-agents from `.deepcode/agents/` | +| `/hooks` | โœ“ | โœ“ | โœ… โ€” lists hooks configured in settings.json | +| `/skills` | โœ“ | โœ“ | โœ… โ€” lists built-in + user + project skills | +| `/permissions` | โœ“ | โœ“ (read-only) | ๐ŸŸก โ€” shows rules + default mode (interactive editor deferred) | +| `/privacy-settings` | โœ“ | โœ“ | โœ… โ€” summarizes local data locations + what's sent to the DeepSeek API (read-only) | +| `/migrate-installer` | โœ“ | โœ— | ๐Ÿ”„ | +| `/release-notes` | โœ“ | โœ“ | โœ… โ€” prints the latest `CHANGELOG.md` entry | --- diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ffd0acc..f6fabdb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -221,6 +221,7 @@ export { denyAllNetwork, NetworkSandboxUnavailable, startDnsProxy, + withAdditionalWritableDirs, type SandboxPlatform, type SandboxedCommand, type SpawnNetworkSandboxOpts, diff --git a/packages/core/src/sandbox/additional-dirs.test.ts b/packages/core/src/sandbox/additional-dirs.test.ts new file mode 100644 index 0000000..93ee237 --- /dev/null +++ b/packages/core/src/sandbox/additional-dirs.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { withAdditionalWritableDirs } from './additional-dirs.js'; +import type { SandboxConfig } from '../config/types.js'; + +const enabled: SandboxConfig = { enabled: true, filesystem: { allowWrite: ['/repo'] } }; + +describe('withAdditionalWritableDirs', () => { + it('folds additional directories into filesystem.allowWrite', () => { + const out = withAdditionalWritableDirs(enabled, ['/data', '/scratch']); + expect(out?.filesystem?.allowWrite).toEqual(['/repo', '/data', '/scratch']); + }); + + it('never mutates its input', () => { + const input: SandboxConfig = { enabled: true, filesystem: { allowWrite: ['/repo'] } }; + withAdditionalWritableDirs(input, ['/data']); + expect(input.filesystem?.allowWrite).toEqual(['/repo']); + }); + + it('dedupes against existing entries', () => { + const out = withAdditionalWritableDirs(enabled, ['/repo', '/data']); + expect(out?.filesystem?.allowWrite).toEqual(['/repo', '/data']); + }); + + it('returns the same object when nothing new is added', () => { + expect(withAdditionalWritableDirs(enabled, ['/repo'])).toBe(enabled); + }); + + it('seeds allowWrite when the sandbox has no filesystem section', () => { + const out = withAdditionalWritableDirs({ enabled: true }, ['/data']); + expect(out?.filesystem?.allowWrite).toEqual(['/data']); + }); + + it('is a no-op when the sandbox is disabled โ€” never silently enables it', () => { + const off: SandboxConfig = { enabled: false }; + expect(withAdditionalWritableDirs(off, ['/data'])).toBe(off); + expect(withAdditionalWritableDirs(undefined, ['/data'])).toBeUndefined(); + }); + + it('is a no-op for empty/undefined directory lists', () => { + expect(withAdditionalWritableDirs(enabled, [])).toBe(enabled); + expect(withAdditionalWritableDirs(enabled, undefined)).toBe(enabled); + }); + + it('resolves relative entries against cwd', () => { + const out = withAdditionalWritableDirs(enabled, ['sub/dir'], '/work'); + expect(out?.filesystem?.allowWrite).toEqual(['/repo', '/work/sub/dir']); + }); + + it('drops relative entries when no cwd is available rather than passing them through', () => { + // Sandbox profile writers require absolute paths; a bare relative string + // would be meaningless (or worse, mis-anchored) by the time it got there. + expect(withAdditionalWritableDirs(enabled, ['sub/dir'])).toBe(enabled); + }); +}); diff --git a/packages/core/src/sandbox/additional-dirs.ts b/packages/core/src/sandbox/additional-dirs.ts new file mode 100644 index 0000000..b9670b7 --- /dev/null +++ b/packages/core/src/sandbox/additional-dirs.ts @@ -0,0 +1,44 @@ +// Folds `permissions.additionalDirectories` into the sandbox's writable roots. +// +// The file tools (Read/Write/Edit/Glob/Grep) already accept any absolute path โ€” +// there is no cwd containment to widen. The only thing that actually restricts +// writes is the OS sandbox wrapping Bash, so "enforcing /add-dir" means adding +// those directories to `filesystem.allowWrite`. +// +// Every host that builds a SandboxConfig must route through this, or the +// setting is enforced in some clients and silently ignored in others. + +import { isAbsolute, resolve } from 'node:path'; +import type { SandboxConfig } from '../config/types.js'; + +/** + * Return a copy of `sandbox` whose `filesystem.allowWrite` also contains + * `dirs`. Never mutates its input. A no-op when the sandbox is disabled or + * `dirs` is empty, so hosts can call it unconditionally. + * + * Relative entries are resolved against `cwd` when provided; entries that are + * still not absolute are dropped rather than being handed to the sandbox + * profile writer, which expects absolute paths. + */ +export function withAdditionalWritableDirs( + sandbox: SandboxConfig | undefined, + dirs: readonly string[] | undefined, + cwd?: string, +): SandboxConfig | undefined { + if (!sandbox?.enabled) return sandbox; + if (!dirs || dirs.length === 0) return sandbox; + + const normalized = dirs + .map((dir) => (isAbsolute(dir) ? dir : cwd ? resolve(cwd, dir) : undefined)) + .filter((dir): dir is string => dir !== undefined); + if (normalized.length === 0) return sandbox; + + const existing = sandbox.filesystem?.allowWrite ?? []; + const merged = [...new Set([...existing, ...normalized])]; + if (merged.length === existing.length) return sandbox; + + return { + ...sandbox, + filesystem: { ...sandbox.filesystem, allowWrite: merged }, + }; +} diff --git a/packages/core/src/sandbox/index.ts b/packages/core/src/sandbox/index.ts index ba481b6..5ddd91e 100644 --- a/packages/core/src/sandbox/index.ts +++ b/packages/core/src/sandbox/index.ts @@ -92,3 +92,5 @@ export async function wrapBashCommand(args: { // Windows / unsupported: explicit per ยง0.2 โ€” sandbox disabled, run unwrapped return { command: '/bin/sh', args: ['-c', args.userCommand] }; } + +export { withAdditionalWritableDirs } from './additional-dirs.js';