diff --git a/scripts/landlock-probe.py b/scripts/landlock-probe.py new file mode 100644 index 0000000..0fb7f37 --- /dev/null +++ b/scripts/landlock-probe.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Landlock availability probe — run INSIDE the prod zero-server container. + +Landlock is the one filesystem-sandbox primitive that fits zero-server's +constraints: it needs no Linux capabilities and no user namespace (the two +things the capless-root + apparmor-userns-restricted OCD host denies), only +PR_SET_NO_NEW_PRIVS, which the hardened container already sets. See the +project memory `project_bash_sandbox_infeasible` for why bubblewrap can't run. + +This probe answers the single gating question: can a process in this exact +container (same kernel, seccomp profile, and caps as the agent's bash) use +Landlock at all? It calls landlock_create_ruleset(NULL, 0, VERSION), which +returns the supported ABI version without changing anything. + +Run it where bash actually executes: + + ocd ssh server-2 --server # real root on the host + docker exec python3 /app/scripts/landlock-probe.py + +Interpreting the result: + - "Landlock ABI vN" -> WORKS. Implement the Landlock bash wrapper. + ABI>=1 covers read/write FS containment (enough + to block cross-project access); ABI>=3 adds + truncate, ABI>=4 adds TCP rules (not needed here). + - errno ENOSYS (38) -> kernel too old / Landlock not compiled in. + - errno EOPNOTSUPP (95) -> Landlock present but disabled at boot + (lsm= line / CONFIG). Fixable host-side. + - errno EPERM/EACCES (1/13)-> a seccomp filter is blocking landlock_* syscalls. + Needs the OCD seccomp profile to allow them. +""" + +import ctypes, ctypes.util, errno, os, sys + +# Generic syscall number, identical on x86_64 and arm64. +SYS_landlock_create_ruleset = 444 +LANDLOCK_CREATE_RULESET_VERSION = 1 # query-ABI flag + +libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True) + +# Also report whether the LSM is even listed, for a clearer diagnosis. +try: + with open("/sys/kernel/security/lsm") as f: + lsms = f.read().strip() + print(f"active LSMs: {lsms}") + print(f"landlock listed in LSMs: {'landlock' in lsms.split(',')}") +except OSError as e: + print(f"could not read /sys/kernel/security/lsm: {e}") + +try: + with open("/proc/version") as f: + print("kernel:", f.read().split(' (')[0].strip()) +except OSError: + pass + +ctypes.set_errno(0) +abi = libc.syscall(SYS_landlock_create_ruleset, None, ctypes.c_size_t(0), + ctypes.c_uint(LANDLOCK_CREATE_RULESET_VERSION)) +err = ctypes.get_errno() + +if abi >= 1: + print(f"\nRESULT: Landlock ABI v{abi} — USABLE in this container. Proceed.") + sys.exit(0) + +name = errno.errorcode.get(err, str(err)) +print(f"\nRESULT: landlock_create_ruleset failed (errno {err} {name}) — NOT usable.") +if err == errno.ENOSYS: + print(" -> Kernel has no Landlock. Need a newer host kernel (>=5.13).") +elif err == errno.EOPNOTSUPP: + print(" -> Landlock compiled but disabled. Enable via host lsm= boot param.") +elif err in (errno.EPERM, errno.EACCES): + print(" -> Likely seccomp-blocked. OCD seccomp profile must allow landlock_*.") +sys.exit(1) diff --git a/server/Dockerfile b/server/Dockerfile index a01340d..2d7f9dd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -70,6 +70,15 @@ RUN bun --filter zero build # Copy server source COPY server/ ./server/ +# Compile the Landlock bash-sandbox helper (server/landlock-exec). Pure C, +# UAPI structs defined inline so it needs no kernel headers; links only libc. +# bash on Linux is routed through this by project-sandbox so a prompt-injected +# command can't read/write sibling projects. `/var/empty` is the GIT_TEMPLATE_DIR +# the bash ops point git at; it must exist and be readable inside the ruleset. +RUN g++ -O2 -x c server/landlock-exec/zero-landlock.c -o /usr/local/bin/zero-landlock \ + && chmod 755 /usr/local/bin/zero-landlock \ + && mkdir -p /var/empty + # Build frontend (build.ts uses `bun x vite build`) COPY web/ ./web/ COPY build.ts ./build.ts diff --git a/server/landlock-exec/zero-landlock.c b/server/landlock-exec/zero-landlock.c new file mode 100644 index 0000000..607fbac --- /dev/null +++ b/server/landlock-exec/zero-landlock.c @@ -0,0 +1,199 @@ +// zero-landlock — apply a Landlock filesystem ruleset, then exec a command. +// +// Why this exists: zero-server runs as capless root (CapEff=0) on a shared, +// hardened OCD host where unprivileged user namespaces are blocked, so +// bubblewrap (the sandbox-runtime Linux backend) cannot engage and bash +// falls back to unsandboxed — letting a prompt-injected command read/write +// sibling projects under the projects root. Landlock is the one FS-sandbox +// primitive that works here: it needs no capabilities and no user namespace, +// only PR_SET_NO_NEW_PRIVS (already set on the container). Verified usable at +// ABI v4 on the prod kernel (6.8). +// +// Model: Landlock is deny-by-default allowlist. We grant rw on the project +// dir + /tmp, ro on system dirs + the zero package/agent roots, rw on a few +// /dev nodes. The projects ROOT is never granted, so sibling projects are +// denied automatically — no explicit deny needed. +// +// Usage: +// zero-landlock --check # exit 0 if Landlock usable +// zero-landlock [--rw DIR]... [--ro DIR]... [--rwfile FILE]... -- CMD [ARG]... +// +// Fail-closed: if a ruleset cannot be created/applied, we exit non-zero +// rather than exec unsandboxed. Missing allow paths are skipped (a dir that +// doesn't exist yet is simply not granted), which is not a failure. + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_landlock_create_ruleset +#define __NR_landlock_create_ruleset 444 +#endif +#ifndef __NR_landlock_add_rule +#define __NR_landlock_add_rule 445 +#endif +#ifndef __NR_landlock_restrict_self +#define __NR_landlock_restrict_self 446 +#endif + +#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0) +#define LANDLOCK_RULE_PATH_BENEATH 1 + +// Filesystem access-right bits (stable UAPI). +#define A_EXECUTE (1ULL << 0) +#define A_WRITE_FILE (1ULL << 1) +#define A_READ_FILE (1ULL << 2) +#define A_READ_DIR (1ULL << 3) +#define A_REMOVE_DIR (1ULL << 4) +#define A_REMOVE_FILE (1ULL << 5) +#define A_MAKE_CHAR (1ULL << 6) +#define A_MAKE_DIR (1ULL << 7) +#define A_MAKE_REG (1ULL << 8) +#define A_MAKE_SOCK (1ULL << 9) +#define A_MAKE_FIFO (1ULL << 10) +#define A_MAKE_BLOCK (1ULL << 11) +#define A_MAKE_SYM (1ULL << 12) +#define A_REFER (1ULL << 13) // ABI >= 2 +#define A_TRUNCATE (1ULL << 14) // ABI >= 3 +#define A_IOCTL_DEV (1ULL << 15) // ABI >= 5 + +struct landlock_ruleset_attr { + uint64_t handled_access_fs; + uint64_t handled_access_net; +}; + +struct landlock_path_beneath_attr { + uint64_t allowed_access; + int32_t parent_fd; +} __attribute__((packed)); + +static int abi_version(void) { + return (int)syscall(__NR_landlock_create_ruleset, NULL, (size_t)0, + LANDLOCK_CREATE_RULESET_VERSION); +} + +// Full FS mask the kernel handles at the given ABI. handled_access_fs must +// not include bits the running kernel doesn't know, or create_ruleset fails. +static uint64_t handled_fs_for_abi(int abi) { + uint64_t m = A_EXECUTE | A_WRITE_FILE | A_READ_FILE | A_READ_DIR | + A_REMOVE_DIR | A_REMOVE_FILE | A_MAKE_CHAR | A_MAKE_DIR | + A_MAKE_REG | A_MAKE_SOCK | A_MAKE_FIFO | A_MAKE_BLOCK | + A_MAKE_SYM; // ABI 1 + if (abi >= 2) m |= A_REFER; + if (abi >= 3) m |= A_TRUNCATE; + if (abi >= 5) m |= A_IOCTL_DEV; + return m; +} + +static int add_path(int ruleset_fd, const char *path, uint64_t access) { + int pfd = open(path, O_PATH | O_CLOEXEC); + if (pfd < 0) { + // Missing path: skip silently (allowlist entry that doesn't exist yet). + if (errno == ENOENT) return 0; + fprintf(stderr, "zero-landlock: open %s: %s\n", path, strerror(errno)); + return -1; + } + struct landlock_path_beneath_attr pb = {.allowed_access = access, + .parent_fd = pfd}; + int rc = (int)syscall(__NR_landlock_add_rule, ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, &pb, 0U); + int saved = errno; + close(pfd); + if (rc != 0) { + fprintf(stderr, "zero-landlock: add_rule %s: %s\n", path, strerror(saved)); + return -1; + } + return 0; +} + +int main(int argc, char **argv) { + int abi = abi_version(); + if (argc == 2 && strcmp(argv[1], "--check") == 0) { + if (abi >= 1) { + printf("landlock-abi=%d\n", abi); + return 0; + } + fprintf(stderr, "zero-landlock: unavailable (abi=%d errno=%d)\n", abi, errno); + return 1; + } + + // Collect allow-lists and find the "--" separator. + const char **rw = calloc(argc, sizeof(char *)); + const char **ro = calloc(argc, sizeof(char *)); + const char **rwf = calloc(argc, sizeof(char *)); + int nrw = 0, nro = 0, nrwf = 0; + int cmd_start = -1; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--") == 0) { + cmd_start = i + 1; + break; + } else if (strcmp(argv[i], "--rw") == 0 && i + 1 < argc) { + rw[nrw++] = argv[++i]; + } else if (strcmp(argv[i], "--ro") == 0 && i + 1 < argc) { + ro[nro++] = argv[++i]; + } else if (strcmp(argv[i], "--rwfile") == 0 && i + 1 < argc) { + rwf[nrwf++] = argv[++i]; + } else { + fprintf(stderr, "zero-landlock: unknown arg: %s\n", argv[i]); + return 2; + } + } + if (cmd_start < 0 || cmd_start >= argc) { + fprintf(stderr, "zero-landlock: no command after --\n"); + return 2; + } + + if (abi < 1) { + // Fail closed: caller only routes through us when it believes Landlock + // works, so a surprise here means the security control is absent. + fprintf(stderr, "zero-landlock: Landlock unavailable (abi=%d) — refusing " + "to run unsandboxed\n", abi); + return 126; + } + + uint64_t handled = handled_fs_for_abi(abi); + struct landlock_ruleset_attr rsattr = {.handled_access_fs = handled, + .handled_access_net = 0}; + int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &rsattr, + sizeof(rsattr), 0U); + if (ruleset_fd < 0) { + fprintf(stderr, "zero-landlock: create_ruleset: %s\n", strerror(errno)); + return 126; + } + + // RW dirs get the full handled mask; RO dirs get read+traverse+execute; + // RW files get read/write (+truncate/ioctl where the ABI handles them). + uint64_t ro_access = A_READ_FILE | A_READ_DIR | A_EXECUTE; + uint64_t rwfile_access = A_READ_FILE | A_WRITE_FILE; + if (abi >= 3) rwfile_access |= A_TRUNCATE; + if (abi >= 5) rwfile_access |= A_IOCTL_DEV; + + for (int i = 0; i < nrw; i++) + if (add_path(ruleset_fd, rw[i], handled) != 0) return 126; + for (int i = 0; i < nro; i++) + if (add_path(ruleset_fd, ro[i], ro_access) != 0) return 126; + for (int i = 0; i < nrwf; i++) + if (add_path(ruleset_fd, rwf[i], rwfile_access) != 0) return 126; + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + fprintf(stderr, "zero-landlock: prctl(NO_NEW_PRIVS): %s\n", strerror(errno)); + return 126; + } + if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0U) != 0) { + fprintf(stderr, "zero-landlock: restrict_self: %s\n", strerror(errno)); + return 126; + } + close(ruleset_fd); + + execvp(argv[cmd_start], &argv[cmd_start]); + fprintf(stderr, "zero-landlock: exec %s: %s\n", argv[cmd_start], + strerror(errno)); + return 127; +} diff --git a/server/lib/browser/host-pool.ts b/server/lib/browser/host-pool.ts index 5f2b3ed..d626bae 100644 --- a/server/lib/browser/host-pool.ts +++ b/server/lib/browser/host-pool.ts @@ -35,7 +35,10 @@ import type { CDPSession, } from "playwright"; import { log } from "@/lib/utils/logger.ts"; -import { projectDirFor } from "@/lib/pi/run-turn.ts"; +import { + chromeStateFileFor, + legacyChromeStateFileFor, +} from "@/lib/pi/run-turn.ts"; import { getCompanionRegistry } from "@/lib/companion/registry.ts"; import type { BrowserAction as ProtocolBrowserAction } from "@/lib/browser/protocol.ts"; @@ -45,13 +48,39 @@ import type { BrowserAction as ProtocolBrowserAction } from "@/lib/browser/proto * without paying the RAM cost of a persistent Chromium per project. * * Written on context close / idle eviction; reloaded on next context create. - * Sensitive (auth tokens) — sandbox denies the agent both reading and - * writing this file, and snapshot-service excludes it from git snapshots. + * Sensitive (auth tokens), so it lives OUTSIDE the project dir + * (`chromeStateFileFor`): the agent's bash is Landlock-confined to the project + * dir but Landlock can't deny a single file within a granted tree, so the only + * reliable protection is keeping this file out of that tree. In-process tools + * are project-dir-scoped and can't reach it either. */ -const STATE_FILENAME = ".chrome-state.json"; - function stateFileFor(projectId: string): string { - return join(projectDirFor(projectId), STATE_FILENAME); + return chromeStateFileFor(projectId); +} + +/** + * One-time migration: older builds stored the file at + * `/.chrome-state.json`. Move any such file to the new + * out-of-tree location on next open so existing sessions aren't logged out + * and the secret no longer sits in the Landlock-readable project dir. + */ +async function migrateLegacyState(projectId: string): Promise { + const legacy = legacyChromeStateFileFor(projectId); + const exists = await stat(legacy) + .then((s) => s.isFile()) + .catch(() => false); + if (!exists) return; + const target = stateFileFor(projectId); + try { + await mkdir(join(target, ".."), { recursive: true }); + await rename(legacy, target); + browserLog.info("migrated legacy chrome-state out of project dir", { projectId }); + } catch (err) { + browserLog.warn("legacy chrome-state migration failed", { + projectId, + err: err instanceof Error ? err.message : String(err), + }); + } } // rebrowser-playwright is a drop-in fork that patches the Runtime.Enable CDP @@ -191,6 +220,7 @@ class HostBrowserPool extends EventEmitter { const p = (async () => { const browser = await this.ensureBrowser(); + await migrateLegacyState(projectId); const statePath = stateFileFor(projectId); const hasState = await stat(statePath) .then((s) => s.isFile()) diff --git a/server/lib/pi/extensions/project-sandbox/index.ts b/server/lib/pi/extensions/project-sandbox/index.ts index 0fb6af5..ccc0f3b 100644 --- a/server/lib/pi/extensions/project-sandbox/index.ts +++ b/server/lib/pi/extensions/project-sandbox/index.ts @@ -13,16 +13,24 @@ * Wrapped to resolve the input path (realpath-ing to follow symlinks) * and reject anything that escapes the project dir. * - * 2. The `bash` tool. Wrapped via `@anthropic-ai/sandbox-runtime` - * (bubblewrap on Linux, sandbox-exec on macOS) with filesystem-only - * restrictions — *no* network sandbox. The bundled pi sandbox - * extension always defines `network.allowedDomains`, which triggers - * `bwrap --unshare-net` and severs the agent's `zero` CLI from the - * in-process server at 127.0.0.1:. By calling - * `SandboxManager.initialize` ourselves with only a `filesystem` - * block we get fs containment (cross-project writes blocked, - * container-global writes blocked) and keep the host's network — - * including loopback — intact. + * 2. The `bash` tool. Confined per-platform: + * + * - **Linux (incl. the prod OCD deploy): Landlock.** bubblewrap can't + * run on the hardened host (capless root + blocked unprivileged + * userns), so we use the `zero-landlock` helper, which applies a + * Landlock filesystem ruleset (deny-by-default allowlist) and then + * execs the command. Landlock needs no caps and no userns — only + * `PR_SET_NO_NEW_PRIVS`, already set on the container. The projects + * root is never granted, so sibling projects are denied by default. + * Networking is untouched, so the agent's `zero` CLI keeps reaching + * the in-process server at 127.0.0.1:. + * + * - **macOS (local dev): `@anthropic-ai/sandbox-runtime`** (sandbox-exec) + * with a filesystem-only block. We call `SandboxManager.initialize` + * ourselves with only a `filesystem` block (the bundled pi sandbox + * extension always sets `network.allowedDomains`, which on Linux would + * trigger `bwrap --unshare-net` and sever loopback) so we get fs + * containment while keeping the host network intact. * * Without this extension, a prompt-injected or buggy bash command could * read/write any sibling project under `data/projects//`, or persist @@ -76,27 +84,92 @@ import { const DENY_READ = ["~/.ssh", "~/.aws", "~/.gnupg"]; const DENY_WRITE_GLOBS = [".env", ".env.*", "*.pem", "*.key"]; -const DENY_WRITE_RELATIVE = [".pi", ".pi-sessions", ".git-snapshots", ".chrome-state.json"]; -// Project-relative paths the agent must not touch at all via the in-process -// read/write/edit tools (the bash sandbox above also denies writes through -// DENY_WRITE_RELATIVE, but in-process tools bypass the sandbox-runtime -// layer entirely). `.chrome-state.json` holds the project's browser cookies -// / localStorage / IndexedDB — exfiltration via prompt injection is real, -// so it's read-denied too. -const DENY_INPROCESS_RELATIVE = [".chrome-state.json"]; - -function isDeniedInProcess(projectDir: string, inputPath: string): string | null { - const abs = path.isAbsolute(inputPath) - ? inputPath - : path.resolve(projectDir, inputPath); - const resolved = realpathOrParent(abs); - for (const rel of DENY_INPROCESS_RELATIVE) { - const denied = path.join(projectDir, rel); - if (resolved === denied || resolved.startsWith(denied + path.sep)) { - return rel; - } - } - return null; +const DENY_WRITE_RELATIVE = [".pi", ".pi-sessions", ".git-snapshots"]; +// The project's browser storageState (`.chrome-state.json`: cookies + +// localStorage + IndexedDB) used to live inside the project dir and was +// special-cased here as read/write-denied. It now lives OUTSIDE the project +// dir (see chromeStateFileFor in run-turn.ts), so both the project-scoped +// in-process tools and Landlock-confined bash are blocked from it by +// construction — no per-file deny needed. + +// Landlock (Linux) configuration. The helper binary applies a deny-by-default +// filesystem ruleset, so this is an allowlist: only what a contained shell +// needs to function. Deliberately granular — NOT `/app` (would expose +// `/app/data`: app.db, credentials, vectors) and NOT the projects root (would +// expose sibling projects). The helper skips paths that don't exist, so listing +// extras is harmless. The zero package root, agents dir, etc. arrive separately +// via `readOnlyRoots`. +const LANDLOCK_BIN = process.env.ZERO_LANDLOCK_BIN ?? "zero-landlock"; +const LANDLOCK_SYSTEM_RO = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib32", + "/lib64", + "/libx32", + "/etc", + "/opt", + "/proc", + "/root/.bun", + "/var/empty", // GIT_TEMPLATE_DIR target set in createBashOps +]; +// Character devices a normal shell expects. Granted read+write (the helper +// also adds TRUNCATE/IOCTL_DEV where the kernel ABI handles them, so /dev/tty +// terminal ioctls work). +const LANDLOCK_DEV_RW = [ + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/tty", +]; + +/** + * Build the `--rw/--ro/--rwfile` flags for the `zero-landlock` helper. + * rw: the project dir + /tmp. ro: system dirs + the read-only roots (zero + * package, agents dir) + the server's node_modules (the `zero` CLI resolves + * its deps there at runtime). The projects root is intentionally absent. + */ +function buildLandlockArgs(projectDir: string, readOnlyRoots: string[]): string[] { + const rw = [projectDir, "/tmp"]; + const ro = [ + ...LANDLOCK_SYSTEM_RO, + ...readOnlyRoots, + path.join(process.cwd(), "node_modules"), + ]; + const args: string[] = []; + for (const d of rw) args.push("--rw", d); + for (const d of ro) args.push("--ro", d); + for (const f of LANDLOCK_DEV_RW) args.push("--rwfile", f); + return args; +} + +type BashSandboxMode = "landlock" | "sandbox-runtime" | "none"; + +// Secrets the server reads from its environment (docker-compose) that bash +// must never inherit — otherwise a prompt-injected command could `echo +// $OPENROUTER_API_KEY` and exfiltrate them. The agent's `zero` CLI reaches +// model/search/etc. through the in-process proxy (ZERO_PROXY_*), so it never +// needs these directly. The proxy token itself is re-added via perTurnEnv +// AFTER this scrub, so stripping here doesn't break it. +const SECRET_ENV_NAMES = new Set([ + "JWT_SECRET", + "CREDENTIALS_KEY", + "OPENROUTER_API_KEY", + "BRAVE_SEARCH_API_KEY", + "TELEGRAM_WEBHOOK_SECRET", +]); +// Catch-all for secret-shaped names the server might gain later. Scrubbing is +// applied only to the inherited process.env base (perTurnEnv is overlaid +// after), so ZERO_PROXY_TOKEN — added via perTurnEnv — survives despite +// matching `_TOKEN`. +const SECRET_ENV_PATTERN = + /(SECRET|PASSWORD|PRIVATE_KEY|_TOKEN|API_KEY|ACCESS_KEY|CREDENTIAL)/i; + +function isSecretEnvKey(key: string): boolean { + return SECRET_ENV_NAMES.has(key) || SECRET_ENV_PATTERN.test(key); } function realpathOrParent(absPath: string): string { @@ -125,19 +198,6 @@ function ensureInProject( } } -function ensureNotDenied( - projectDir: string, - inputPath: string, - toolName: string, -): void { - const hit = isDeniedInProcess(projectDir, inputPath); - if (hit) { - throw new Error( - `${toolName}: path "${inputPath}" is in the project deny list (${hit})`, - ); - } -} - function ensureReadable( projectDir: string, extraRoots: string[], @@ -159,7 +219,7 @@ function ensureReadable( function createBashOps( perTurnEnv: Record, - opts: { sandboxed: boolean }, + opts: { mode: BashSandboxMode; landlockArgs: string[] }, ): BashOperations { return { async exec(command, cwd, { onData, signal, timeout, env }) { @@ -167,19 +227,37 @@ function createBashOps( throw new Error(`Working directory does not exist: ${cwd}`); } - const wrappedCommand = opts.sandboxed - ? await SandboxManager.wrapWithSandbox(command) - : command; + // Choose the spawn target by sandbox mode: + // landlock -> `zero-landlock -- bash -c ` + // (helper applies the ruleset, then execs bash) + // sandbox-runtime -> bash -c (macOS) + // none -> bash -c (unsandboxed fallback) + let spawnCmd: string; + let spawnArgs: string[]; + if (opts.mode === "landlock") { + spawnCmd = LANDLOCK_BIN; + spawnArgs = [...opts.landlockArgs, "--", "bash", "-c", command]; + } else if (opts.mode === "sandbox-runtime") { + spawnCmd = "bash"; + spawnArgs = ["-c", await SandboxManager.wrapWithSandbox(command)]; + } else { + spawnCmd = "bash"; + spawnArgs = ["-c", command]; + } - // Merge order: process.env (base), env from pi (per-call overrides), - // perTurnEnv (turn-scoped overrides — proxy token, run id, etc.). - // Done here instead of mutating process.env so concurrent turns can't - // clobber each other's ZERO_PROXY_TOKEN and cause cross-context - // confusion at the proxy. + // Merge order: process.env (base, with the server's own secrets + // scrubbed), env from pi (per-call overrides), perTurnEnv (turn-scoped + // overrides — proxy token, run id, etc.). Done here instead of mutating + // process.env so concurrent turns can't clobber each other's + // ZERO_PROXY_TOKEN and cause cross-context confusion at the proxy. // GIT_TEMPLATE_DIR=/var/empty sidesteps sandbox-runtime's mandatory // deny on **/.git/hooks/** for git clone/init. + const scrubbedBase: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (!isSecretEnvKey(k)) scrubbedBase[k] = v; + } const mergedEnv: NodeJS.ProcessEnv = { - ...process.env, + ...scrubbedBase, ...env, ...perTurnEnv, }; @@ -193,7 +271,7 @@ function createBashOps( const envWithGitTemplate = mergedEnv; return new Promise((resolve, reject) => { - const child = spawn("bash", ["-c", wrappedCommand], { + const child = spawn(spawnCmd, spawnArgs, { cwd, env: envWithGitTemplate, detached: true, @@ -315,7 +393,6 @@ export function createProjectSandboxExtension( label: "read (project-scoped)", async execute(id, params, signal, onUpdate, _ctx) { ensureReadable(projectDir, readOnlyRoots, params.path, "read"); - ensureNotDenied(projectDir, params.path, "read"); return read.execute(id, params, signal, onUpdate); }, }); @@ -324,7 +401,6 @@ export function createProjectSandboxExtension( label: "write (project-scoped)", async execute(id, params, signal, onUpdate, _ctx) { ensureInProject(projectDir, params.path, "write"); - ensureNotDenied(projectDir, params.path, "write"); return write.execute(id, params, signal, onUpdate); }, }); @@ -333,7 +409,6 @@ export function createProjectSandboxExtension( label: "edit (project-scoped)", async execute(id, params, signal, onUpdate, _ctx) { ensureInProject(projectDir, params.path, "edit"); - ensureNotDenied(projectDir, params.path, "edit"); return edit.execute(id, params, signal, onUpdate); }, }); @@ -364,27 +439,62 @@ export function createProjectSandboxExtension( // Always route bash through our ops so per-turn env (proxy token, // PATH prefix) is injected even if sandbox init fails on this platform. + // `bashMode` is resolved at session_start (Landlock on Linux, sandbox-exec + // on macOS, else unsandboxed) and read at exec time. const localBashTemplate = createBashTool(projectDir); - let sandboxReady = false; + const landlockArgs = buildLandlockArgs(projectDir, readOnlyRoots); + let bashMode: BashSandboxMode = "none"; pi.registerTool({ ...localBashTemplate, label: "bash (sandboxed)", async execute(id, params, signal, onUpdate, _ctx) { const bash = createBashTool(projectDir, { - operations: createBashOps(bashEnv, { sandboxed: sandboxReady }), + operations: createBashOps(bashEnv, { mode: bashMode, landlockArgs }), }); return bash.execute(id, params, signal, onUpdate); }, }); pi.on("user_bash", () => ({ - operations: createBashOps(bashEnv, { sandboxed: sandboxReady }), + operations: createBashOps(bashEnv, { mode: bashMode, landlockArgs }), })); pi.on("session_start", async (_event, ctx) => { const platform = process.platform; - if (platform !== "darwin" && platform !== "linux") { + + if (platform === "linux") { + // Landlock: probe via the helper's `--check`, then route bash through + // it. No SandboxManager.initialize (bubblewrap) on Linux — it can't + // engage on the hardened deploy and would only sever loopback. + try { + await new Promise((resolve, reject) => { + const child = spawn(LANDLOCK_BIN, ["--check"], { stdio: "ignore" }); + child.on("error", reject); + child.on("close", (code) => + code === 0 + ? resolve() + : reject(new Error(`zero-landlock --check exited ${code}`)), + ); + }); + bashMode = "landlock"; + ctx.ui.setStatus( + "sandbox", + ctx.ui.theme.fg("accent", `🔒 Bash sandboxed (Landlock) to ${projectDir}`), + ); + } catch (err) { + bashMode = "none"; + ctx.ui.notify( + `Landlock bash sandbox unavailable — bash runs unsandboxed: ${ + err instanceof Error ? err.message : err + }`, + "error", + ); + } + return; + } + + if (platform !== "darwin") { ctx.ui.notify( `Sandbox not supported on ${platform} — bash runs unsandboxed`, "warning", @@ -392,19 +502,13 @@ export function createProjectSandboxExtension( return; } + // macOS local dev: sandbox-exec via sandbox-runtime. try { type InitArgs = Parameters[0]; await SandboxManager.initialize({ network: {} as InitArgs["network"], filesystem: { - denyRead: [ - ...DENY_READ, - projectsRoot, - // Same set the in-process read/write tools refuse via - // ensureNotDenied — keep them blocked when the agent shells - // out (`cat .chrome-state.json`). - ...DENY_INPROCESS_RELATIVE.map((p) => path.join(projectDir, p)), - ], + denyRead: [...DENY_READ, projectsRoot], allowRead: [projectDir, ...readOnlyRoots], allowWrite: [projectDir, "/tmp"], denyWrite: [ @@ -418,13 +522,13 @@ export function createProjectSandboxExtension( allowGitConfig: true, }, }); - sandboxReady = true; + bashMode = "sandbox-runtime"; ctx.ui.setStatus( "sandbox", ctx.ui.theme.fg("accent", `🔒 Bash sandboxed to ${projectDir}`), ); } catch (err) { - sandboxReady = false; + bashMode = "none"; ctx.ui.notify( `Bash sandbox init failed: ${err instanceof Error ? err.message : err}`, "error", @@ -433,7 +537,7 @@ export function createProjectSandboxExtension( }); pi.on("session_shutdown", async () => { - if (sandboxReady) { + if (bashMode === "sandbox-runtime") { try { await SandboxManager.reset(); } catch { diff --git a/server/lib/pi/run-turn.ts b/server/lib/pi/run-turn.ts index 21d0ceb..f7d95c2 100644 --- a/server/lib/pi/run-turn.ts +++ b/server/lib/pi/run-turn.ts @@ -20,7 +20,7 @@ * in-process (no child `pi`), so no env-based model inheritance is needed. */ import { existsSync, mkdirSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { randomBytes } from "node:crypto"; import { type AgentSessionEvent, @@ -116,6 +116,24 @@ export function projectDirFor(projectId: string): string { return join(PROJECTS_ROOT, projectId); } +// Browser storageState (cookies + localStorage + IndexedDB) lives in a sibling +// of the projects root, NOT inside the project dir. The agent's bash is +// confined to the project dir by Landlock (which grants the whole project dir +// rw and can't do per-file deny), so keeping this secret file out of that tree +// is the only way to stop a prompt-injected command from reading session +// tokens. The in-process tools are project-dir-scoped, so they can't reach it +// either. See server/lib/browser/host-pool.ts. +const CHROME_STATE_ROOT = join(dirname(PROJECTS_ROOT), "chrome-state"); + +export function chromeStateFileFor(projectId: string): string { + return join(CHROME_STATE_ROOT, `${projectId}.json`); +} + +/** Legacy in-project location, kept only for one-time migration on open. */ +export function legacyChromeStateFileFor(projectId: string): string { + return join(projectDirFor(projectId), ".chrome-state.json"); +} + export function sessionsDirFor(projectId: string): string { return join(projectDirFor(projectId), ".pi-sessions"); } diff --git a/server/lib/snapshots/snapshot-service.ts b/server/lib/snapshots/snapshot-service.ts index 520c7c5..43cdcfb 100644 --- a/server/lib/snapshots/snapshot-service.ts +++ b/server/lib/snapshots/snapshot-service.ts @@ -33,9 +33,10 @@ const EXCLUDE_PATTERNS = [ "node_modules/", ".venv/", "__pycache__/", - // Browser storageState — cookies + localStorage + IndexedDB persisted by - // server/lib/browser/host-pool.ts. Excluded from snapshots so auth tokens - // don't get committed into the per-turn snapshot history. + // Browser storageState now lives outside the project dir (see + // chromeStateFileFor in run-turn.ts), but older projects may still have a + // legacy in-project `.chrome-state.json` until it's migrated on next browser + // open. Keep excluding it so those stragglers never land in snapshot history. ".chrome-state.json", ];