From af8003d928f11a6460b6b80bd4573aa4862456b7 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 2 Aug 2026 15:08:14 +0800 Subject: [PATCH] feat(cli): enforce /add-dir via sandbox writable roots, in every host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks #166, which was correct in approach but is now incomplete and carries stale docs. `permissions.additionalDirectories` has been declared in the settings schema and read by /permissions for display, but consumed for enforcement nowhere — /add-dir only printed "recorded ... (effective in M3)". Users could reasonably believe it did something. Why the sandbox is the right boundary: the file tools (Read/Write/Edit/ Glob/Grep) already accept any absolute path, so there is no cwd containment to widen. The only thing that actually restricts writes is the OS sandbox wrapping Bash. Enforcing /add-dir therefore means adding those directories to `filesystem.allowWrite`. The part #166 could not have covered: it predates the app-server, and wired only CLI REPL + headless. Since #192/#195/#196, desktop, VS Code and LSP all run through apps/server, so that wiring would have enforced the setting in the CLI and silently ignored it everywhere else — for a security-relevant setting, partial enforcement is worse than none, because it is indistinguishable from full enforcement at the UI. All 7 sandbox assembly sites across 4 files now route through the helper. - core `withAdditionalWritableDirs(sandbox, dirs, cwd?)`: pure, never mutates input, no-op when the sandbox is disabled (never silently enables it), dedupes, and drops non-absolute entries rather than handing them to profile writers that require absolute paths - /add-dir validates the path is an existing directory, stores it absolute (resolved against cwd), refuses duplicates, and lists the current set with no args - BEHAVIOR_PARITY: only the /add-dir row changes. #166 rewrote the whole table with June-era content that would have regressed /btw, /voice, /tasks and /background back to unshipped. (Prettier reflows the table columns; `git diff -w` shows the 2 real lines.) tsc -b --force, lint (--max-warnings=0), format:check, docs:check clean; 1048 tests pass (9 new helper + 6 new /add-dir); build clean. Co-Authored-By: Claude Opus 5 --- apps/cli/src/commands.ts | 46 ++++++++- apps/cli/src/headless.ts | 13 ++- apps/cli/src/parity-commands.test.ts | 65 +++++++++++- apps/cli/src/repl.ts | 13 ++- apps/server/src/default-runtime.ts | 7 +- apps/server/src/runtime-composition.ts | 13 ++- docs/BEHAVIOR_PARITY.md | 98 +++++++++---------- packages/core/src/index.ts | 1 + .../core/src/sandbox/additional-dirs.test.ts | 54 ++++++++++ packages/core/src/sandbox/additional-dirs.ts | 44 +++++++++ packages/core/src/sandbox/index.ts | 2 + 11 files changed, 294 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/sandbox/additional-dirs.test.ts create mode 100644 packages/core/src/sandbox/additional-dirs.ts 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';