Status (verified 2026-06-10): ✅ Implemented in v1.6.0 — Phases 1, 2 and 4 complete; Phase 3 (rename_symbol) implemented but its 3 unit tests are still missing (see checklist). Landed across four commits:
3994e7420(wrapper binary),86e9d56fd(enforce sandboxing + rename isolation),3e1dcd33c(quote prefix tokens),097bff4cb(scope Landlock to/dev/nullnode).Implemented entry points (line numbers re-verified against current source):
getWorktreeSandboxPrefix(),getWorktreeCommandWarning()(now macOS/Windows-only),validateWorktreePath(),isEmbeddedWorktreeTask(),shellQuote()+ sandbox prefix application atExecuteCommandTool.ts:136, the rename-boundary guard atRenameSymbolTool.ts:141, and the wrapper binarysrc/sandbox/main.go+src/sandbox/main_test.go.Known gap: the committed binary
sandbox/shofer-sandboxis x86-64 / not-stripped, with no Bazel target and nogo.workentry — arm64 deploys fail with a loudENOEXEC(not silent degradation). Deferred to a build-system follow-up (Med 5 below).
When an agent runs inside an embedded worktree (task.cwd → <workspace>/.worktrees/<name>/),
shell commands executed via execute_command must be unable to write outside the task's assigned
worktree directory. The current getWorktreeCommandWarning()
advisory warning is a best-effort placeholder that does not prevent escape.
Additionally, rename_symbol (LSP rename) could escape worktree boundaries because the LSP rename
provider operates on the entire workspace. This is now fixed (Phase 3 ✅): in addition to the
source-file guard at RenameSymbolTool.execute(),
the handler enumerates every affected file via workspaceEdit.entries() and runs each through
validateWorktreePath() before applying the
edit; if any affected path is outside the worktree, the whole rename is blocked (strict Option A) — see
RenameSymbolTool.ts:139-150. (Note: this is
worktree-boundary enforcement; mode-level fileRegex restrictions for rename_symbol remain
source-path-derived — see adding-new-tools.md.)
We only need to limit writes. Reads can remain unrestricted. This simplifies the sandbox considerably — no need to maintain a system-essentials allowlist for interpreters, libraries, config files, etc. The sandbox policy is:
ALLOW WRITE: <worktree-path>/**
ALLOW WRITE: <main>/.git/worktrees/<name>/** (git metadata; auto-discovered)
ALLOW WRITE: <main>/.git/objects/** (shared object store; auto-discovered)
ALLOW WRITE: <main>/.git/refs/** (shared refs; auto-discovered)
ALLOW WRITE: /tmp/** (shared; not a concern)
ALLOW WRITE: /dev/null (for shell redirects to /dev/null)
DENY WRITE: everything-else
READS: unrestricted
Git metadata discovery is automatic. In a git worktree,
.gitis a plain file containinggitdir: /path/to/.git/worktrees/<name>. The sandbox wrapper reads this file at startup, resolves thegitdirpath, and also reads thecommondirpointer inside the worktree's git metadata directory (typically../..→ the main.gitdir) to whitelistobjects/andrefs/. This meansgit add,git commit,git checkout, and other git operations work inside sandboxed worktree shells without any manual configuration. SeeresolveWorktreeGitPaths().
Linux Landlock is an unprivileged LSM that supports exactly this model — a process can create a ruleset that denies writes outside a set of allowed paths. No root required. Available since kernel 5.13 (Ubuntu 22.04+, Debian 12, Fedora 35+).
Bubblewrap creates a private mount namespace and bind-mounts the worktree as the only writable location. Reads from the host filesystem still work because the bind-mounts are read-only.
The VS Code Terminal backend (TerminalProcess.ts) cannot be sandboxed because VS Code owns the
shell process lifecycle. We will force the execa backend for worktree-scoped execute_command
calls. This means:
- On Linux: use execa + landlock/bwrap wrapper → full sandboxing
- On macOS/Windows: keep VS Code terminal + current advisory warning (no kernel sandbox available)
The execa path is already the fallback when shell integration fails
(ShellIntegrationError).
How the backend is actually selected. In
executeCommandInTerminal() the
backend is chosen by terminalShellIntegrationDisabled ? "execa" : "vscode", and that flag comes
from provider state at ExecuteCommandTool.ts:85.
To force execa for a worktree task we pass terminalShellIntegrationDisabled: true into the
ExecuteCommandOptions for that call rather than reading it from provider state. The terminal is
then created via TerminalRegistry.getOrCreateTerminal(workingDir, taskId, "execa") at
ExecuteCommandTool.ts:374.
Where the sandbox prefix is applied. ExecaTerminalProcess.run() invokes the command with
execa({ shell, cwd, … })`${command}` at
ExecaTerminalProcess.ts:43.
The sandbox wrapper must be the outermost process so the shell itself (and any subprocess it
spawns) inherits the Landlock ruleset. The cleanest options are (a) set the execa shell option to
the wrapper binary so it execs the real shell under restriction, or (b) rewrite command to
<wrapper> /bin/sh -c '<original>' and run without shell: true. Simply prepending the wrapper to
command while shell: true still holds is insufficient — the outer shell runs unrestricted and
only the wrapper's own child is sandboxed.
Note: forcing execa loses VS Code shell-integration exit-code detection;
ExecaTerminalProcess.run()
emits exitCode: 0 on success and only surfaces non-zero via ExecaError. A sandbox write denial
(EACCES) will therefore surface correctly as a non-zero ExecaError, but commands that succeed
despite a denied write will not.
A dedicated wrapper binary (Go, shipped with the extension) that:
- Resolves the git metadata directories from the worktree's
.gitfile (seeresolveWorktreeGitPathsinmain.go) - Detects whether the kernel supports landlock (≥ 5.13)
- If landlock: creates a landlock ruleset with write-only restrictions (worktree + git metadata +
/tmp+/dev/null), self-restricts, thenexecs the target command - If no landlock but
bwrapavailable: bind-mounts the worktree + git metadata paths +/tmp+/dev/nullas writable, thenexecs the target command - If neither: exits with an error (shouldn't happen on Linux)
The ExecuteCommandTool.ts handler:
- Checks
isEmbeddedWorktreeTask(task) - If true: forces the execa backend, prepends the sandbox wrapper to the command
- If false: uses the normal VS Code terminal backend (unchanged)
End to end, from the tool call to the restricted process:
flowchart TD
EX["execute_command — ExecuteCommandTool"]
Q{"isEmbeddedWorktreeTask(task)?"}
NORM["normal VS Code terminal backend, unchanged"]
PLAT{"platform"}
WARN["macOS / Windows: VS Code terminal +<br/>getWorktreeCommandWarning() advisory banner —<br/>no kernel sandbox available"]
FORCE["Linux: pass terminalShellIntegrationDisabled: true so the<br/>backend is execa — TerminalRegistry.getOrCreateTerminal<br/>workingDir, taskId, execa"]
PREFIX["getWorktreeSandboxPrefix() bakes the wrapper into<br/>effectiveCommand via shellQuote():<br/>wrapper worktree -- /bin/sh -c cmd"]
RUN["ExecaTerminalProcess.run() — the wrapper is the outermost<br/>process, so the shell and every subprocess it spawns<br/>inherit the restriction"]
subgraph W["shofer-sandbox wrapper — sandbox/main.go"]
direction TB
G["resolveWorktreeGitPaths() — read the worktree .git file's<br/>gitdir pointer, then the commondir pointer, to whitelist<br/>the worktree git metadata plus objects/ and refs/"]
LK{"kernel supports<br/>Landlock 5.13+?"}
LR["ABI-negotiated Landlock ruleset — writes allowed under the<br/>worktree, the git metadata dirs, /tmp and the /dev/null node;<br/>then self-restrict"]
BW{"bwrap available?"}
BB["bind-mount the worktree + git metadata + /tmp + /dev/null<br/>as writable over a read-only root"]
ERR["exit with an error"]
EXEC["exec the target command"]
G --> LK
LK -->|yes| LR --> EXEC
LK -->|no| BW
BW -->|yes| BB --> EXEC
BW -->|no| ERR
end
EX --> Q
Q -->|no| NORM
Q -->|yes| PLAT
PLAT --> WARN
PLAT --> FORCE --> PREFIX --> RUN --> G
Reads stay unrestricted throughout — only writes are confined.
| File | Change | Status |
|---|---|---|
packages/core/src/tools/ExecuteCommandTool.ts |
Force execa + wrap command via shellQuote() into <wrapper> <worktree> -- /bin/sh -c '<cmd>' |
✅ |
src/utils/worktreePathGuard.ts |
Added getWorktreeSandboxPrefix() (existence check + lazy output-channel diagnostic); repurposed getWorktreeCommandWarning() for macOS/Windows |
✅ |
packages/core/src/tools/RenameSymbolTool.ts |
Validate every affectedRelPaths entry against the worktree boundary before applyEdit |
✅ |
sandbox/main.go + sandbox/main_test.go |
Landlock (ABI-negotiated) + bwrap wrapper + git worktree metadata discovery; 10 Go tests (5 unit + 5 git-resolution) | ✅ |
src/utils/__tests__/worktreePathGuard.test.ts |
5 unit tests for getWorktreeSandboxPrefix |
✅ |
Design deviation: the original plan threaded a sandbox-prefix parameter through
ExecaTerminalProcess. The implementation instead bakes the wrapper into theeffectiveCommandstring atExecuteCommandTool.ts:136, soExecaTerminalProcesswas left unchanged. Functionally equivalent; the wrapper is the outermost process via<wrapper> … -- /bin/sh -c '<cmd>'.
The design below describes the approach that was implemented in Phase 3. The per-affected-path worktree check is live at
RenameSymbolTool.ts:139-150.
RenameSymbolTool calls
vscode.executeDocumentRenameProvider, which operates on the entire workspace. The source-file guard
validateWorktreePath(task, filePath)
only validates the source file's location, so the downstream-effects check below was added on top.
The handler already enumerates every affected file: the loop at
RenameSymbolTool.ts:127 iterates
workspaceEdit.entries() and collects affectedRelPaths/affectedDisplayPaths before the edit
is applied at RenameSymbolTool.ts:155
(vscode.workspace.applyEdit). The validation slots in cleanly between those two points.
flowchart TD
A["RenameSymbolTool.execute — validateWorktreePath<br/>on the source filePath"]
B["vscode.executeDocumentRenameProvider —<br/>operates on the entire workspace"]
C["iterate workspaceEdit.entries() and collect<br/>affectedRelPaths / affectedDisplayPaths"]
D{"every affected path inside the worktree?<br/>validateWorktreePath per entry, on the entry Uri's fsPath"}
E["block the whole rename — Option A, strict.<br/>Nothing has been applied yet, so the abort is clean"]
F["captureOriginal, then vscode.workspace.applyEdit"]
A --> B --> C --> D
D -->|no| E
D -->|yes| F
Approach: after building affectedRelPaths (before captureOriginal / applyEdit), run each path
through validateWorktreePath(). If any edit
targets a file outside the worktree boundary:
- Option A (strict): Block the entire rename with an error (nothing has been applied yet).
- Option B (lenient): Allow the rename but warn that references outside the worktree were modified.
Prefer Option A — the same principle as the other tools: worktree tasks cannot modify
files outside their assigned directory. The LLM should switch to a master-scoped task
if it needs cross-worktree refactoring. (Note: the API here is WorkspaceEdit.entries(), not the
documentChanges shape — validate against the fsPath of each entry's Uri.)
- Implement landlock write-only sandbox in Go (static-linked binary) — ABI-negotiated (
landlockWriteMaskForABI), file-scoped/dev/nullrule - Add bwrap fallback for pre-5.13 kernels (
--ro-bind / /first, then writable overlays) - Integration test: verify writes outside worktree fail with EACCES (
main_test.goTestBinaryIntegration) - Integration test: verify reads outside worktree succeed
- Integration test: verify writes inside worktree succeed
- Integration test: verify writes to /tmp succeed (
TestBinaryWriteTmp)
- Add
getWorktreeSandboxPrefix(task)toworktreePathGuard.ts(:108) - In
ExecuteCommandTool.execute(): force execa path whenisEmbeddedWorktreeTask() -
Thread sandbox wrapper prefix through— superseded: wrapper baked intoExecaTerminalProcesseffectiveCommandstring instead (see Design deviation above);ExecaTerminalProcessunchanged - Unit test: non-worktree tasks use normal backend
- Unit test: worktree tasks use execa + sandbox on Linux
- Unit test: worktree tasks use advisory warning on macOS/Windows
- After the LSP rename, iterate
workspaceEdit.entries()to collectaffectedRelPaths(RenameSymbolTool.ts:127) - Validate each path against the worktree boundary via
validateWorktreePath(), beforeapplyEdit(guard loop atRenameSymbolTool.ts:141; apply at:168) - Block renames that would modify files outside the worktree (nothing applied yet — clean abort)
- Unit test: rename inside worktree succeeds (
RenameSymbolTool.test.ts:72) - Unit test: rename affecting master checkout is blocked (
RenameSymbolTool.test.ts:44) - Unit test: rename affecting sibling worktree is blocked (
RenameSymbolTool.test.ts:58)
- Update
plugins/basics/docs/worktrees.md— Known Limitation collapsed; sandboxing documented - Update
command-execution.md— execa-forcing for worktrees documented -
getWorktreeCommandWarning()repurposed for macOS/Windows-only (worktreePathGuard.ts:152)
- Med 5 — build integration (the one substantive open risk).
sandbox/shofer-sandboxis a committed x86-64 / not-stripped binary with no Bazel target and nogo.workentry. Editingmain.godoes not rebuild the shipped artifact, and arm64 deploys exec it → loudENOEXEC(thefs.existsSyncguard ingetWorktreeSandboxPrefixonly catches a missing binary, not a wrong-arch one). Add a Bazel cross-compile target keyed to the deploy arch and stop committing the prebuilt binary. - Low — i18n. The
🔒 WORKTREE SANDBOXapproval banner inExecuteCommandTool.tsandgetWorktreeCommandWarning()are hard-coded English; route throught(...)when the i18n rule is enforced. Deferred (matches the pre-existing un-localized warning).
Context. Forcing the execa backend (Phase 2) means sandboxed worktree commands run as a headless
execa() child rather than
in a vscode.Terminal. Command text and streamed output are still fully visible — both backends
funnel through the same onLine → task.say("command_output", …) path, so output renders live in the
Shofer chat panel. What's lost is specifically the VS Code integrated-terminal tab in the bottom
panel: the kind with its own trash/X icon. (The chat Stop button remains the kill affordance for
execa, routing to ExecaTerminalProcess.abort()
— SIGKILL + psTree child reaping.) The vscode backend creates a real terminal via
Terminal.ts; the execa backend creates
nothing.
Is it possible? Yes. The constraint that "VS Code owns the shell process, so it can't be
sandboxed" applies only to the shell-spawning terminal API (createTerminal({ cwd, name })). It does
not apply to VS Code's extension-owned pseudoterminal API
(vscode.window.createTerminal({ name, pty }) with a vscode.Pseudoterminal). With a pseudoterminal,
we still own and spawn the process (execa + the Landlock/bwrap wrapper, exactly as today); the
pseudoterminal is only a rendering surface and tab affordance. This decouples "who owns the process"
(us — so sandboxing is preserved) from "is there a terminal tab" (yes). The pty API exists in
code-server too, so this works in the deployed environment.
Sketch.
- Add a
Pseudoterminal-backedShoferTerminalvariant (a thirdShoferTerminalProvideralongsidevscode/execa, or anexeca+ptymode) inTerminalRegistry.createTerminal(). - On
Pseudoterminal.open(), spawn the sandboxed execa process (the currentExecaTerminalProcesslogic, unchanged) and pipe its stdout/stderr into the pty'sonDidWriteemitter so output appears in the tab. Keep the existingonLinechat streaming as-is — the LLM still needs the captured output as the tool result, so the pty is additive, not a replacement. - Wire the two kill directions: the tab's X invokes
Pseudoterminal.close()→ callExecaTerminalProcess.abort(); conversely a chat-Stopabort()shoulddispose()the terminal so the tab closes. Both must converge on the same abort path to avoid orphans.
Trade-offs / caveats to weigh before doing this.
- No shell integration. A pseudoterminal has no VS Code shell-integration decorations (command boundaries, exit-code badges) unless we emit the OSC 633 escape sequences ourselves. This is the same exit-code-visibility limitation already noted for execa above — the pty doesn't fix it, it just adds a visual surface.
- Output duplication. Output would appear in both the chat panel and the pty tab. That's arguably desirable (chat for the agent transcript, tab for the human), but the line-ending normalization the chat path applies must not corrupt the raw pty stream — keep the two sinks independent.
- CRLF / TTY semantics. A pty implies the program may detect a TTY and change behavior (colors,
pagers, line buffering). execa today runs without a controlling TTY; mirroring into a pseudoterminal
is display-only and does not give the child a real TTY, so programs that probe
isatty(stdout)still see a pipe. If true TTY semantics are wanted, that's a larger change (and reintroduces the pager-hang risks the headless path avoids). - Scope. This is a UX nicety, not a correctness or security gap — kill and visibility both already work via the chat panel. Prioritize accordingly, and only after the Med 5 build-integration item, which is the one substantive risk.