From 65d4103e5c90e8766e15b73499419265a4b0a2db Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 9 Jun 2026 15:38:36 +0200 Subject: [PATCH 1/2] fix(sandbox): install bubblewrap/socat + probe so bash isolation actually engages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bash tool is meant to be confined to its project dir via @anthropic-ai/sandbox-runtime (bubblewrap on Linux), blocking cross-project reads. But the image never installed bwrap/socat, so SandboxManager.initialize() threw on the missing deps, the extension caught it, and bash silently ran UNSANDBOXED in production — any prompt-injected command could read/write sibling projects under /app/data/projects/ (the in-process read/ls/grep/find tools stay safe via JS path checks; the gap was bash-specific). - Dockerfile: install bubblewrap + socat (ripgrep already present). - Add a runtime probe (sandboxActuallyRuns): initialize() only which-checks the binaries, but a present bwrap still fails if the container forbids user/mount namespaces. Wrap a trivial command and run it; only trust the sandbox if it exits cleanly. Without this, a deploy that ships bwrap but blocks userns would break *every* bash command instead of falling back. - Log sandbox-unavailable loudly via the server logger (was a silent ctx.ui.notify), so a fail-open can't go unnoticed. - PI_REQUIRE_BASH_SANDBOX=1 makes bash fail *closed* (refused) when the sandbox can't engage — flip it on once the deploy is verified so a regression can't silently re-open the hole. PLATFORM FOLLOW-UP (not expressible in .ocd-deploy.json): bubblewrap needs the container launched with --security-opt seccomp=unconfined (or CAP_SYS_ADMIN / userns). Until OCD grants that, the probe fails and bash falls back to unsandboxed (now loudly logged). --- server/Dockerfile | 11 ++ .../pi/extensions/project-sandbox/index.ts | 101 +++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index a01340d..67e3366 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -19,8 +19,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ less \ tree \ tini \ + bubblewrap \ + socat \ && rm -rf /var/lib/apt/lists/* +# bubblewrap + socat back the agent's bash sandbox (@anthropic-ai/sandbox-runtime +# on Linux). They confine `bash` to the current project dir so a prompt-injected +# command can't read sibling projects. NOTE: bubblewrap also needs the container +# to permit user+mount namespaces — i.e. the platform must launch this container +# with `--security-opt seccomp=unconfined` (or grant CAP_SYS_ADMIN / userns). +# Without that, the binaries are present but `bwrap --unshare-*` fails at runtime; +# the sandbox extension probes for this at session start and falls back rather +# than breaking bash (see server/lib/pi/extensions/project-sandbox/index.ts). + # Install fd from upstream releases rather than apt: Debian bookworm pins # fd-find at 8.7.0, which predates the `--no-require-git` flag the agent's # `find` tool relies on (added in fd 9.0). Provide both `fd` and the diff --git a/server/lib/pi/extensions/project-sandbox/index.ts b/server/lib/pi/extensions/project-sandbox/index.ts index 0fb6af5..7187709 100644 --- a/server/lib/pi/extensions/project-sandbox/index.ts +++ b/server/lib/pi/extensions/project-sandbox/index.ts @@ -50,6 +50,61 @@ import { DANGEROUS_DIRECTORIES, } from "@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js"; import { resolveZeroPackageRoot } from "../../zero-cli.ts"; +import { log } from "../../../utils/logger.ts"; + +const sandboxLog = log.child({ module: "project-sandbox" }); + +// When set truthy, bash is *disabled* (rather than run unsandboxed) if the +// filesystem sandbox can't engage. Use this once the deploy is known to grant +// bubblewrap the namespaces it needs, so a future regression (missing dep, +// dropped capability) can't silently re-open cross-project bash access. +const REQUIRE_BASH_SANDBOX = /^(1|true|yes)$/i.test( + process.env.PI_REQUIRE_BASH_SANDBOX ?? "", +); + +/** + * Confirm the sandbox actually *runs*, not just that its binaries exist. + * + * `SandboxManager.initialize()` only checks that `bwrap`/`socat`/`rg` are on + * PATH (a `which`). But a present `bwrap` still fails at runtime if the + * container forbids user/mount namespaces (no CAP_SYS_ADMIN, or seccomp blocks + * `unshare`) — and then *every* real bash command would die with "Operation + * not permitted", losing the agent its bash tool entirely. So we wrap a + * trivial command and run it: only trust the sandbox if it exits cleanly. + */ +async function sandboxActuallyRuns(projectDir: string): Promise { + let wrapped: string; + try { + wrapped = await SandboxManager.wrapWithSandbox("true"); + } catch { + return false; + } + return new Promise((resolve) => { + const child = spawn("bash", ["-c", wrapped], { cwd: projectDir, stdio: "ignore" }); + let settled = false; + const done = (ok: boolean) => { + if (settled) return; + settled = true; + resolve(ok); + }; + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + done(false); + }, 5000); + child.on("error", () => { + clearTimeout(timer); + done(false); + }); + child.on("close", (code) => { + clearTimeout(timer); + done(code === 0); + }); + }); +} const UNBLOCK_FILES = new Set([".mcp.json", ".gitmodules"]); const UNBLOCK_DIRS = new Set([".vscode", ".idea"]); @@ -371,6 +426,13 @@ export function createProjectSandboxExtension( ...localBashTemplate, label: "bash (sandboxed)", async execute(id, params, signal, onUpdate, _ctx) { + if (!sandboxReady && REQUIRE_BASH_SANDBOX) { + throw new Error( + "bash is disabled: filesystem sandbox is unavailable and " + + "PI_REQUIRE_BASH_SANDBOX is set. Cross-project access can't be " + + "guaranteed, so bash is refused rather than run unsandboxed.", + ); + } const bash = createBashTool(projectDir, { operations: createBashOps(bashEnv, { sandboxed: sandboxReady }), }); @@ -385,6 +447,13 @@ export function createProjectSandboxExtension( pi.on("session_start", async (_event, ctx) => { const platform = process.platform; if (platform !== "darwin" && platform !== "linux") { + sandboxReady = false; + sandboxLog.error( + "bash sandbox unsupported on this platform — bash is running UNSANDBOXED " + + "(cross-project reads/writes are NOT blocked)", + undefined, + { platform, projectDir }, + ); ctx.ui.notify( `Sandbox not supported on ${platform} — bash runs unsandboxed`, "warning", @@ -418,6 +487,19 @@ export function createProjectSandboxExtension( allowGitConfig: true, }, }); + + // initialize() only `which`-checks the binaries; confirm the sandbox + // can actually create its namespaces before we trust it (see + // sandboxActuallyRuns). Without this, a deploy that ships bwrap but + // forbids user namespaces would break *every* bash command. + if (!(await sandboxActuallyRuns(projectDir))) { + throw new Error( + "bubblewrap is installed but cannot create user/mount namespaces — " + + "the container likely needs `--security-opt seccomp=unconfined` " + + "(or CAP_SYS_ADMIN / userns). Falling back to unsandboxed bash.", + ); + } + sandboxReady = true; ctx.ui.setStatus( "sandbox", @@ -425,9 +507,24 @@ export function createProjectSandboxExtension( ); } catch (err) { sandboxReady = false; + if (REQUIRE_BASH_SANDBOX) { + sandboxLog.error( + "bash sandbox unavailable — bash is DISABLED (PI_REQUIRE_BASH_SANDBOX set)", + err, + { projectDir }, + ); + } else { + sandboxLog.error( + "bash sandbox unavailable — bash is running UNSANDBOXED " + + "(cross-project reads/writes are NOT blocked). Set " + + "PI_REQUIRE_BASH_SANDBOX=1 to disable bash instead.", + err, + { projectDir }, + ); + } ctx.ui.notify( - `Bash sandbox init failed: ${err instanceof Error ? err.message : err}`, - "error", + `Bash sandbox unavailable: ${err instanceof Error ? err.message : err}`, + REQUIRE_BASH_SANDBOX ? "error" : "warning", ); } }); From 74491092d1b462404cfa2b6ea8e5233495276893 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 9 Jun 2026 16:04:24 +0200 Subject: [PATCH 2/2] deploy: request userns in manifest so bubblewrap can sandbox bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the OCD per-app userns flag — launches the zero-server container with --security-opt=seccomp=unconfined so the agent's bash sandbox (bubblewrap) can create user/mount namespaces. --- server/.ocd-deploy.json | 1 + 1 file changed, 1 insertion(+) diff --git a/server/.ocd-deploy.json b/server/.ocd-deploy.json index f496936..396ead1 100644 --- a/server/.ocd-deploy.json +++ b/server/.ocd-deploy.json @@ -13,6 +13,7 @@ "size": 20, "path": "/app/data" }, + "userns": true, "env": [ { "key": "PI_PROJECTS_ROOT",