From ccdd74d506e3b11eb8811876fcc59189d4fd6c59 Mon Sep 17 00:00:00 2001 From: ConsultingFuture4200 Date: Sat, 25 Jul 2026 10:05:18 -0700 Subject: [PATCH] fix(sandbox): honour the requested cwd under bubblewrap wrapCommand always passed `--chdir `. That takes precedence over the working directory bubblewrap inherits from the spawning process, so it silently overrode the subdirectory the caller had already resolved and set on the spawn options. The result was a silent platform split for the same tool call: run_command({cwd: "packages/api"}) ran in packages/api on macOS (sandbox-exec adds no chdir, so the inherited cwd stands) and when unsandboxed, but in the workspace root on Linux with bubblewrap available. create_terminal({cwd}) had the same problem via terminal-manager. wrapCommand now takes the start directory as an option and chdirs to it, defaulting to the workspace root when unset. All three callers already resolve it through resolveSafePath, and the writable bind mount is still only the workspace root, so this does not widen what the sandbox allows. --- app/src/agent-tools.ts | 4 ++-- app/src/command-sandbox.test.ts | 23 +++++++++++++++++++++++ app/src/command-sandbox.ts | 11 ++++++++++- app/src/terminal-manager.ts | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/agent-tools.ts b/app/src/agent-tools.ts index 94e8a08..0d18216 100644 --- a/app/src/agent-tools.ts +++ b/app/src/agent-tools.ts @@ -818,7 +818,7 @@ export async function runCommand( if (dangerReason) throw new Error(dangerReason); const cwd = resolveSafePath(workspaceRoot, relativeCwd); - const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd }); const settings = settingsStore.getSettings(); let stopMonitor = () => {}; try { @@ -913,7 +913,7 @@ export function startBackgroundCommand( } const cwd = resolveSafePath(workspaceRoot, relativeCwd); - const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd }); // detached so the shell becomes its own process group leader — lets // killProcessTree() below signal the whole group (shell + whatever it // spawned, e.g. `npm run dev` spawning `node`) instead of just the shell diff --git a/app/src/command-sandbox.test.ts b/app/src/command-sandbox.test.ts index 1a1a8de..f4b8371 100644 --- a/app/src/command-sandbox.test.ts +++ b/app/src/command-sandbox.test.ts @@ -79,6 +79,29 @@ describe("wrapCommand", () => { expect(wrapped?.args[1]).toContain("(allow network*)"); expect(wrapped?.args[1]).not.toContain("(deny network*)"); }); + + // --chdir takes precedence over the working directory bwrap inherits from + // the spawning process, so pinning it to the workspace root silently + // overrode the subdirectory the caller had already resolved. + it("chdirs to the requested subdirectory, not just the workspace root", () => { + const wrapped = wrapCommand( + "npm test", + { workspaceRoot: "/home/user/project", allowNetwork: false, cwd: "/home/user/project/packages/api" }, + "linux", + has(["bwrap"]) + ); + const chdirIndex = wrapped!.args.indexOf("--chdir"); + expect(chdirIndex).toBeGreaterThan(-1); + expect(wrapped!.args[chdirIndex + 1]).toBe("/home/user/project/packages/api"); + // The workspace root is still what gets bound writable. + expect(wrapped!.args).toEqual(expect.arrayContaining(["--bind", "/home/user/project", "/home/user/project"])); + }); + + it("falls back to the workspace root when no cwd is given", () => { + const wrapped = wrapCommand("npm test", { workspaceRoot: "/home/user/project", allowNetwork: false }, "linux", has(["bwrap"])); + const chdirIndex = wrapped!.args.indexOf("--chdir"); + expect(wrapped!.args[chdirIndex + 1]).toBe("/home/user/project"); + }); }); describe("applySandbox", () => { diff --git a/app/src/command-sandbox.ts b/app/src/command-sandbox.ts index e604ca2..79d2543 100644 --- a/app/src/command-sandbox.ts +++ b/app/src/command-sandbox.ts @@ -73,6 +73,14 @@ export function detectSandboxCapabilities( export interface WrapCommandOptions { workspaceRoot: string; allowNetwork: boolean; + // Directory the command should start in. Defaults to the workspace root. + // bubblewrap is passed --chdir, which takes precedence over the working + // directory inherited from the spawning process, so a caller that resolved + // a subdirectory has to pass it through here — setting it only on the + // spawn options would be silently overridden. Callers are responsible for + // confining this to the workspace (they go through resolveSafePath); it is + // not re-validated here. + cwd?: string; } export interface WrappedCommand { @@ -92,6 +100,7 @@ export function wrapCommand( ): WrappedCommand | null { const caps = detectSandboxCapabilities(platform, hasCommand); const root = path.resolve(opts.workspaceRoot); + const startDir = opts.cwd ? path.resolve(opts.cwd) : root; if (caps.mechanism === "bubblewrap") { const args = [ @@ -113,7 +122,7 @@ export function wrapCommand( root, root, "--chdir", - root, + startDir, "--die-with-parent", "--unshare-all", ]; diff --git a/app/src/terminal-manager.ts b/app/src/terminal-manager.ts index 74abc12..e78c19d 100644 --- a/app/src/terminal-manager.ts +++ b/app/src/terminal-manager.ts @@ -59,7 +59,7 @@ export function createTerminal( // ordinary shell use (curl, ping, package managers) confusingly fail. // Filesystem confinement to the workspace still applies where the // platform supports it. - const wrapped = wrapCommand(shell, { workspaceRoot, allowNetwork: true }); + const wrapped = wrapCommand(shell, { workspaceRoot, allowNetwork: true, cwd }); const ptyOptions = { name: "xterm-256color", cols: 80, rows: 24, cwd, env: process.env }; const ptyProcess = wrapped ? pty.spawn(wrapped.command, wrapped.args, ptyOptions) : pty.spawn(shell, [], ptyOptions);