Skip to content

fix(sandbox): honour the requested cwd under bubblewrap - #4

Merged
voidstackloop merged 1 commit into
voidstackloop:mainfrom
ConsultingFuture4200:fix/sandbox-cwd
Jul 25, 2026
Merged

fix(sandbox): honour the requested cwd under bubblewrap#4
voidstackloop merged 1 commit into
voidstackloop:mainfrom
ConsultingFuture4200:fix/sandbox-cwd

Conversation

@ConsultingFuture4200

Copy link
Copy Markdown
Contributor

Problem

wrapCommand always appended --chdir <workspaceRoot> to the bubblewrap invocation. --chdir takes precedence over the working directory bubblewrap inherits from the spawning process, so it silently overrode the subdirectory the caller had already resolved and passed on the spawn options.

runCommand resolves cwd through resolveSafePath and hands it to execAsync; under bubblewrap that value was discarded. createTerminal does the same thing via ptyOptions.cwd.

The result is one tool call behaving three ways:

Platform run_command({cwd: "packages/api"}) runs in
Linux, bubblewrap usable workspace root — wrong
Linux, no bubblewrap packages/api
macOS (sandbox-exec adds no chdir) packages/api
Windows packages/api

So builds and tests silently run in the wrong directory on exactly the configuration where the sandbox is working, which is also the hardest one to notice.

Fix

WrapCommandOptions takes an optional cwd, and the bubblewrap branch chdirs to it, defaulting to the workspace root when unset. All three call sites (runCommand, startBackgroundCommand, createTerminal) pass the value they had already resolved.

Not a widening of the sandbox: all three callers derive that path from resolveSafePath, which enforces lexical containment and a realpath check against symlink escapes, and the writable bind mount is still only the workspace root. --chdir is kept rather than dropped — removing it would make the working directory depend on whether the path survived the bind mounts, which is a subtler version of the same bug.

sandbox-exec is deliberately untouched; it inherits the parent's cwd and the callers already set it.

Tests

  • A requested subdirectory reaches --chdir, and the workspace root is still what gets bound writable.
  • With no cwd, --chdir still points at the workspace root.

Control experiment: the first test fails with --chdir pinned back to the root, and passes with the patch.

I could not execute bubblewrap to demonstrate this end-to-end — it can't create user namespaces in my environment, which is the same AppArmor restriction command-sandbox.ts:27-32 already describes. The behaviour rests on documented --chdir semantics (man bwrap: "Change directory to DIR"; $HOME is only consulted "if --chdir has not been explicitly specified"), and the tests assert the constructed argument vector.

npx tsc -p tsconfig.json --noEmit clean; npm test 229 passed.

wrapCommand always passed `--chdir <workspaceRoot>`. 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.
@voidstackloop
voidstackloop merged commit ea87621 into voidstackloop:main Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants