diff --git a/docs/sandbox.md b/docs/sandbox.md index f33982c78a..64e3ed5143 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -73,13 +73,11 @@ llxprt --sandbox-engine podman "review this code" `network` is `on`, outbound network access is available to tool execution. - Reading the sandboxed CLI process's own memory. The credential never enters the sandbox's filesystem, environment, or argv, but it does enter the sandbox - CLI process's address space whenever a provider call is made. Whether a - same-UID in-container process can read that memory is **conditional on the - host kernel's Yama `ptrace_scope` setting**: on a permissive host (scope 0, - or no Yama — notably Docker Desktop for macOS) a descendant process can read - the CLI's `/proc//mem`; on a host with `ptrace_scope >= 1` (the Ubuntu - default) that read is denied. The container flags below cannot change this. - See + CLI process's address space whenever a provider call is made. In container + mode (Docker/Podman) the CLI marks itself non-dumpable (`prctl +PR_SET_DUMPABLE 0`) on startup, so an in-container process running as the same + UID **cannot** read `/proc//mem` — regardless of the host kernel's Yama + `ptrace_scope`. See [Credential residency and process memory](#credential-residency-and-process-memory). The sandbox raises the bar for accidental damage and limits the blast radius of @@ -106,40 +104,80 @@ This is unavoidable: the CLI needs the credential to call the provider. **The consequence.** Anything that can read the CLI process's memory can read both the capability token and the credential. The agent's shell commands are -**descendants** of the token-holding CLI process, so they are trying to read an -**ancestor**. Whether that succeeds is **conditional on the host kernel's Yama -`ptrace_scope`**, because the container shares the host (or VM) kernel: - -- On a host with `kernel.yama.ptrace_scope >= 1` (the default on Ubuntu and many - distributions), the kernel **denies** the read (`EACCES`). -- On a host with `ptrace_scope == 0`, or where the Yama LSM is absent — notably - **Docker Desktop for macOS**, whose VM kernel reports no `ptrace_scope` at all - — a descendant process can read the ancestor CLI's `/proc//mem` and - recover the secret. - -This was confirmed empirically (see issue -[#2902](https://github.com/vybestack/llxprt-code/issues/2902)): under -`ptrace_scope == 0` (Podman machine VM) and on Docker Desktop (no Yama), a -descendant of the token-holding CLI process recovered the secret from the CLI's -heap under every combination of `--cap-drop=ALL`, `--security-opt -no-new-privileges`, and `--user root` versus non-root; with the Podman machine VM -set to `ptrace_scope == 1`, the same probe was denied. None of the container -invocation flags can change this: reading `/proc//mem` is an `open()` plus -`pread()`, not the `ptrace` syscall, so `--cap-drop=ALL` is irrelevant and a -seccomp filter on `ptrace` is ineffective (filtering `open`/`pread` by path is -not possible with classic seccomp). The deciding factor is the host's Yama -setting. +**descendants** of the token-holding CLI process, so an attack vector is for a +descendant to read an ancestor's memory via `/proc//mem`. + +**In container mode this is blocked unconditionally.** The CLI process calls +`prctl(PR_SET_DUMPABLE, 0)` at startup (before the CLI module is imported), +which makes `/proc//{maps,mem,...}` root-owned, so the kernel's +`ptrace_may_access` check denies a same-UID in-container reader with `EACCES` — +the read is refused at `maps`, before `mem` is ever reached. This holds +**regardless of the host kernel's Yama `ptrace_scope`**: it holds at +`ptrace_scope == 0` and on Docker Desktop for macOS, whose VM kernel reports no +`ptrace_scope` at all — precisely the environments where the read used to +succeed. + +This control composes with the privilege hardening shipped in +[#3022](https://github.com/vybestack/llxprt-code/pull/3022): every container run +drops `--cap-drop=ALL` (removing `CAP_SYS_PTRACE`) and sets +`--security-opt no-new-privileges`. `PR_SET_DUMPABLE(0)` alone denies an ordinary +same-UID reader — it makes the proc files root-owned so `ptrace_may_access` +returns false. `CAP_SYS_PTRACE` is a privileged override that bypasses the +dumpable check, so the #3022 capability drop is what prevents that override. +Dropping the capability alone does NOT deny the ordinary reader: a dumpable +process is still readable by same-UID without any capability. The two controls +compose — `PR_SET_DUMPABLE` denies the ordinary reader, and the capability drop +denies the privileged override — and together they close the vector +unconditionally and vector-agnostically: it does not matter whether the attacker +reached code execution through the shell tool, an MCP server, a hook, an +extension, or a malicious `npm postinstall` — the OS boundary denies the read, +not an allowlist that has to stay exhaustive. It also covers the credential, not +just the token: the provider API key resides in the same address space, so both +are protected. + +This was confirmed empirically against real containers (see issues +[#2902](https://github.com/vybestack/llxprt-code/issues/2902) and +[#3028](https://github.com/vybestack/llxprt-code/issues/3028)): with +`--cap-drop=ALL` + `no-new-privileges` but the process still dumpable, a +descendant recovered the secret from the CLI's heap; adding +`prctl(PR_SET_DUMPABLE, 0)` made the descendant's open of `/proc//maps` +fail with `EACCES`. The behavioral test in +`integration-tests/sandboxPrivilege.real.test.ts` reproduces this in a real +container and fails if the `prctl` call is removed. The capability token remains a meaningful secret in its own right: it persists for the session and can fetch a credential from the proxy at a time when no credential is yet resident in memory. Once a credential is resident, however, both live in the same address space. +**Surviving non-goal — in-process attackers.** Code executing **inside** the CLI +process itself — a malicious dependency, a compromised in-process extension, or +any other code sharing the CLI's address space — can still read the token and +the credential directly from its own heap. `PR_SET_DUMPABLE` is an OS boundary +against _other_ processes; it cannot defend against code running _within_ this +one. This is the same non-goal it has always been (see issue +[#1954](https://github.com/vybestack/llxprt-code/issues/1954)). + +**Trade-offs.** Marking the CLI non-dumpable disables core dumps for the CLI +process and prevents external ptrace-attach debugging of the CLI inside the +container. (`--inspect` is socket-based and unaffected.) It is applied on Linux +when the process is running inside a container sandbox (`SANDBOX` set to a +non-`sandbox-exec` value) **or** when it is credential-bearing +(`LLXPRT_CAPABILITY_FD` or `LLXPRT_CREDENTIAL_SOCKET` is set); the Seatbelt +(macOS-host) path is unchanged. On a user-supplied non-glibc sandbox image, +`prctl` cannot be resolved from libc; in that case: + +- If the process is **credential-bearing**, the CLI **fails closed** — it prints + a fatal error and refuses to start, because it cannot protect the credential + in memory. Use the official Debian bookworm / glibc sandbox image. +- If the process is **not credential-bearing** (e.g. a tokenless custom image), + the CLI writes a visible warning to stderr and continues, and the in-container + memory read is not blocked. + The sandbox therefore defends against a prompt-injected agent that reads files, -inspects the environment, scans argv, or speaks the proxy protocol. On a -permissive Yama host it does not defend against one that reads the CLI process's -memory; on a host with Yama `ptrace_scope >= 1` that descendant-reads-ancestor -vector is blocked by the kernel. +inspects the environment, scans argv, speaks the proxy protocol, or — in +container mode — attempts to read the CLI process's memory from another +in-container process. ## Using GitHub from a Sandbox diff --git a/integration-tests/fixtures/process-memory-hardening-driver.ts b/integration-tests/fixtures/process-memory-hardening-driver.ts new file mode 100644 index 0000000000..96e80a6425 --- /dev/null +++ b/integration-tests/fixtures/process-memory-hardening-driver.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Real-container driver for the process-memory-hardening behavioral tests + * (issue #3028, AC1/AC4/AC5). This runs INSIDE the sandbox container under Bun. + * + * It operates in three modes, selected by argv: + * + * 1. Default (parent/tracer) — spawns a CHILD process and reads the CHILD's + * `/proc//{maps,mem}` from the parent. The relationship is deliberately + * parent-reads-child (the more permissive direction) so the test is + * Yama-independent: under `ptrace_scope` 0 AND 1 a parent may trace a + * descendant, so denying that read implies denying the realistic + * descendant-reads-ancestor direction. + * + * 2. `__child__` (target) — imports the REAL production module + * (`packages/cli/src/launcher/process-memory-hardening.ts`), calls the REAL + * `applyProcessMemoryHardening()`, holds a 64-hex secret resident in its + * heap, signals readiness, and stays alive until killed. + * + * 3. `__e2e__` — spawns a child in `__child__` mode (which imports the REAL + * production module from the same path as `packages/cli/index.ts` and calls + * the REAL `applyProcessMemoryHardening()` with `SANDBOX` set), then stats + * `/proc//maps` and asserts it is root-owned (which is exactly what + * non-dumpable produces for a non-root process). This proves the real + * production hardening function makes the process non-dumpable. + * + * NOTE: a full `bun packages/cli/index.ts` launch is not achievable inside + * the current sandbox container image because `index.ts` statically imports + * `@vybestack/llxprt-code-core` (the barrel), which transitively loads + * `@vybestack/llxprt-code-tools` → `sharp`, and `sharp` is not installed in + * the image. The `__e2e__` mode exercises the same real production function + * that index.ts calls; the lexical ordering test in the unit suite proves + * index.ts actually calls it. + * + * The parent prints one of: + * RESULT=MAPS_DENIED — /proc//maps open denied (EACCES): + * the hardening held. + * RESULT=TOKEN_RECOVERED — maps+mem readable and the secret was found. + * RESULT=MAPS_OK_MEM_DENIED — maps readable but mem denied. + * RESULT=MAPS_OK_NOT_FOUND — maps+mem readable but secret not located. + * + * The E2E mode prints one of: + * RESULT=E2E_HARDENED — /proc//maps is root-owned (non-dumpable). + * RESULT=E2E_NOT_HARDENED — maps is owned by the process user (dumpable). + * RESULT=E2E_ERROR: — the child exited before signalling readiness, + * readiness timed out, or ownership could not be + * read. All failure modes surface here with the + * underlying message. + * + * The repository is mounted at `/repo` by the test (read-only), so relative + * imports resolve the real module regardless of the mount path. + */ + +import { applyProcessMemoryHardening } from '../../packages/cli/src/launcher/process-memory-hardening.js'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { openSync, readSync, closeSync, readFileSync, statSync } from 'node:fs'; + +const CHILD_ARG = '__child__'; +const E2E_ARG = '__e2e__'; + +// 64-hex token-shaped secret. Doubled and pinned on a global so it cannot be +// optimized away before the parent scans. +const SECRET = 'deadbeef'.repeat(8); + +const PROBE_POLL_BUDGET_MS = 15_000; +const MAX_REGION_BYTES = 1024 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Mode dispatch +// --------------------------------------------------------------------------- + +await main(); + +async function main(): Promise { + const mode = process.argv[2]; + if (mode === CHILD_ARG) { + await runChild(); + } else if (mode === E2E_ARG) { + await runE2e(); + } else { + await runParent(); + } +} + +// --------------------------------------------------------------------------- +// Child (target) mode +// --------------------------------------------------------------------------- + +async function runChild(): Promise { + // Pin the secret in this process's heap before hardening. + const pinned = SECRET + SECRET; + (globalThis as Record).__LLXPRT_PROBE_SECRET = pinned; + + // Force JavaScriptCore to materialize the rope string. Without this, Bun + // (especially 1.3.x / JavaScriptCore) may keep the concatenation as a + // deferred rope, and the byte pattern will not be present in the heap for + // the parent to find. Iterating charCodeAt flattens the rope. + let checksum = 0; + for (let i = 0; i < pinned.length; i++) { + checksum += pinned.charCodeAt(i); + } + (globalThis as Record).__LLXPRT_CHECKSUM = checksum; + + // Harden THIS process via the real production function. The gate engages + // when SANDBOX is set (controlled by the test's container env). + await applyProcessMemoryHardening(); + + // Signal readiness to the parent, then stay alive so /proc//mem remains + // readable. + process.stdout.write(`READY ${process.pid}\n`); + process.stdin.resume(); +} + +// --------------------------------------------------------------------------- +// Parent (tracer) mode +// --------------------------------------------------------------------------- + +async function runParent(): Promise { + const scriptPath = process.argv[1]!; + const child = spawn('bun', [scriptPath, CHILD_ARG], { + stdio: ['pipe', 'pipe', 'inherit'], + env: { ...process.env }, + }); + + let result: string; + try { + const childPid = await waitForReady(child); + result = scanProcessMemory(childPid, SECRET); + } catch (err) { + result = `ERROR:${err instanceof Error ? err.message : String(err)}`; + } finally { + await killChild(child); + } + + process.stdout.write(`RESULT=${result}\n`); + process.exit(0); +} + +function waitForReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const timer = setTimeout(() => { + reject(new Error('timed out waiting for child READY')); + }, PROBE_POLL_BUDGET_MS); + + child.stdout!.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const newlineIdx = buffer.indexOf('\n'); + if (newlineIdx !== -1) { + const line = buffer.slice(0, newlineIdx).trim(); + clearTimeout(timer); + const match = line.match(/^READY\s+(\d+)$/); + if (match !== null) { + resolve(Number.parseInt(match[1], 10)); + } else { + reject(new Error(`unexpected child output: ${line}`)); + } + } + }); + + child.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`child exited (code=${code}) before READY`)); + }); + }); +} + +/** + * Reads /proc//maps and, if readable, scans each writable memory region + * of /proc//mem for the secret. Returns a RESULT= token. + */ +function scanProcessMemory(pid: number, secret: string): string { + let maps: string; + try { + maps = readFileSync(`/proc/${pid}/maps`, 'latin1'); + } catch (err) { + if (isEacces(err)) return 'MAPS_DENIED'; + return `MAPS_OPEN_FAILED:${errnoOf(err)}`; + } + + let memFd: number; + try { + memFd = openSync(`/proc/${pid}/mem`, 'r'); + } catch (err) { + if (isEacces(err)) return 'MAPS_OK_MEM_DENIED'; + return `MEM_OPEN_FAILED:${errnoOf(err)}`; + } + + try { + const secretBuf = Buffer.from(secret, 'latin1'); + for (const line of maps.split('\n')) { + const parts = line.split(/\s+/); + if (parts.length < 2 || !parts[0].includes('-')) continue; + if (!parts[1].includes('w')) continue; + const range = parts[0].split('-'); + const start = Number.parseInt(range[0], 16); + const end = Number.parseInt(range[1], 16); + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; + const size = end - start; + if (size <= 0 || size > MAX_REGION_BYTES) continue; + const buf = Buffer.alloc(size); + const bytesRead = readSync(memFd, buf, 0, size, start); + if (buf.subarray(0, bytesRead).includes(secretBuf)) { + return 'TOKEN_RECOVERED'; + } + } + return 'MAPS_OK_NOT_FOUND'; + } finally { + closeSync(memFd); + } +} + +// --------------------------------------------------------------------------- +// E2E mode — exercises the real production function and asserts ownership +// --------------------------------------------------------------------------- + +async function runE2e(): Promise { + // Spawn a child in __child__ mode, which imports the REAL production module + // from the same path as packages/cli/index.ts and calls the REAL + // applyProcessMemoryHardening(). A full index.ts launch is not possible in + // this container image (the core barrel transitively requires sharp, which + // is not installed); this exercises the identical function that index.ts + // calls. + const scriptPath = process.argv[1]!; + const child = spawn('bun', [scriptPath, CHILD_ARG], { + stdio: ['pipe', 'pipe', 'inherit'], + env: { + ...process.env, + SANDBOX: process.env['SANDBOX'] ?? 'e2e-probe', + }, + }); + + let result: string; + try { + const childPid = await waitForReady(child); + result = checkMapsOwnership(childPid); + } catch (err) { + result = `E2E_ERROR:${err instanceof Error ? err.message : String(err)}`; + } finally { + await killChild(child); + } + + process.stdout.write(`RESULT=${result}\n`); + process.exit(0); +} + +/** + * Stats /proc//maps. When the process is non-dumpable the proc files are + * owned by root (uid 0, gid 0); when dumpable they are owned by the process's + * own uid. + */ +function checkMapsOwnership(pid: number): string { + try { + const st = statSync(`/proc/${pid}/maps`); + if (st.uid === 0 && st.gid === 0) { + return 'E2E_HARDENED'; + } + return 'E2E_NOT_HARDENED'; + } catch { + return 'E2E_STAT_FAILED'; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Terminates the child and waits for it to actually exit, escalating to + * SIGKILL if it does not honour SIGTERM, so the parent never exits leaving an + * orphan holding the secret. + */ +async function killChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + const exited = new Promise((resolve) => { + child.once('exit', () => { + resolve(); + }); + }); + try { + child.kill('SIGTERM'); + } catch { + return; + } + const escalate = new Promise((resolve) => { + setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // best-effort + } + resolve(); + }, 2000).unref(); + }); + await Promise.race([exited, escalate.then(() => exited)]); +} + +function isEacces(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + (err as NodeJS.ErrnoException).code === 'EACCES' + ); +} + +function errnoOf(err: unknown): string { + if ( + typeof err === 'object' && + err !== null && + (err as NodeJS.ErrnoException).errno !== undefined + ) { + return String((err as NodeJS.ErrnoException).errno); + } + return 'unknown'; +} diff --git a/integration-tests/sandboxPrivilege.real.test.ts b/integration-tests/sandboxPrivilege.real.test.ts index 6f776f4dee..e15dc8a08e 100644 --- a/integration-tests/sandboxPrivilege.real.test.ts +++ b/integration-tests/sandboxPrivilege.real.test.ts @@ -29,7 +29,7 @@ * - Override the image with `LLXPRT_SANDBOX_TEST_IMAGE=`. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -348,3 +348,152 @@ describe.skipIf(skipTests)( }); }, ); + +// --------------------------------------------------------------------------- +// Issue #3028: process memory hardening via prctl(PR_SET_DUMPABLE). +// +// Drives the PRODUCTION module inside a real container. The driver fixture +// spawns a CHILD process that calls the real `applyProcessMemoryHardening()` +// and holds a 64-hex secret in its heap; the PARENT reads +// /proc//{maps,mem} and scans for the secret. The parent-reads-child +// direction is the more permissive one (allowed under ptrace_scope 0 AND 1), +// so denying it implies denying the realistic descendant-reads-ancestor vector. +// This makes BOTH test arms Yama-independent. The container runs with the +// production security flags (sourced from `buildContainerRunArgs`), and +// `SANDBOX` is set in the container env so the production gate engages. +// +// A third test (AC4-E2E) exercises the real production hardening function +// (same import path and call signature as index.ts) inside the container and +// asserts the process's /proc files are root-owned, proving the real +// production code path hardens the process. A full index.ts launch is not +// possible in the current sandbox image (the core barrel transitively requires +// sharp, which is not installed); the lexical ordering test in the unit suite +// proves index.ts actually calls the function. +// --------------------------------------------------------------------------- + +describe.skipIf(skipTests)( + 'Process memory hardening PR_SET_DUMPABLE (real container) #3028', + () => { + const repoRoot = join(__dirname, '..'); + const driverContainerPath = + '/repo/integration-tests/fixtures/process-memory-hardening-driver.ts'; + const savedSandboxFlags = process.env.SANDBOX_FLAGS; + const savedSetUidGid = process.env.SANDBOX_SET_UID_GID; + let workdir = ''; + + beforeAll(() => { + // Ensure the production argv is the clean default (no stray SANDBOX_FLAGS + // leaking into flag extraction), exactly as the #2902 tests do. + delete process.env.SANDBOX_FLAGS; + delete process.env.SANDBOX_SET_UID_GID; + workdir = mkdtempSync(join(tmpdir(), 'sandbox3028-real-')); + }); + + afterAll(() => { + if (savedSandboxFlags !== undefined) { + process.env.SANDBOX_FLAGS = savedSandboxFlags; + } else { + delete process.env.SANDBOX_FLAGS; + } + if (savedSetUidGid !== undefined) { + process.env.SANDBOX_SET_UID_GID = savedSetUidGid; + } else { + delete process.env.SANDBOX_SET_UID_GID; + } + if (workdir !== '') { + rmSync(workdir, { recursive: true, force: true }); + } + }); + + /** Security flags production emits for a default-path run. */ + function productionSecurityFlags(): string[] { + const args = buildContainerRunArgs( + { command: 'docker', image }, + image, + workdir, + '/workspace', + workdir, + ); + return extractSecurityFlags(args); + } + + /** + * Runs the real production driver inside a container using the + * production-derived security flags, with `SANDBOX` set to `sandboxEnv` + * (engages the production gate when non-empty; an empty value disengages it + * so the prctl call is never made). The repo is mounted read-only. + */ + /** + * Runs the driver fixture in a real container using the exact security + * flags the production argv builder emits. `sandboxEnv` drives the + * production gate; `extraArgs` selects the driver mode. + */ + function runDriver(sandboxEnv: string, ...extraArgs: string[]): string { + return execFileSync( + runtime!, + [ + 'run', + '--rm', + ...productionSecurityFlags(), + '--volume', + `${repoRoot}:/repo:ro`, + '--env', + `SANDBOX=${sandboxEnv}`, + '--env', + 'BUN_INSTALL_CACHE_DIR=/tmp/.bun', + image, + 'bun', + driverContainerPath, + ...extraArgs, + ], + { timeout: RUN_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024 }, + ).toString(); + } + + function runMemoryProbe(sandboxEnv: string): string { + return runDriver(sandboxEnv); + } + + /** + * Runs the E2E driver mode, which spawns a child that calls the REAL + * production `applyProcessMemoryHardening()` (same import path as + * index.ts) inside the container, then stats /proc//maps ownership + * to verify the real production function hardened the process. + */ + function runE2EProbe(): string { + return runDriver('e2e-probe', '__e2e__'); + } + + it('a parent process is DENIED the hardened child process maps (AC1, AC5)', () => { + // SANDBOX set => production gate engages => prctl(PR_SET_DUMPABLE, 0) => + // the parent's read of /proc//maps is denied with EACCES. Parent + // reads child (the permissive direction), so this denial holds under both + // ptrace_scope 0 and 1. + const out = runMemoryProbe('docker-memory-probe'); + expect(out).toContain('RESULT=MAPS_DENIED'); + }); + + it('is falsifiable: with the production gate disengaged the secret IS recovered', () => { + // The SAME real module and driver, but SANDBOX overridden to empty so the + // production gate is a no-op and prctl(PR_SET_DUMPABLE) is never called. + // The parent then reads the child's maps+mem and recovers the secret, + // proving the prctl call in the production path is load-bearing. Parent + // reads child is permitted under both ptrace_scope 0 and 1, so this + // recovers the secret on Yama hosts too. + const out = runMemoryProbe(''); + expect(out).toContain('RESULT=TOKEN_RECOVERED'); + }); + + it('the real production hardening function makes the process non-dumpable (AC4-E2E)', () => { + // Spawns a child that imports the REAL production module (same path as + // index.ts) and calls the REAL applyProcessMemoryHardening(). The driver + // stats /proc//maps ownership; non-dumpable makes it root-owned + // (uid 0), proving the real production function makes the process + // non-dumpable in a real container. A full index.ts launch is not + // possible in this image (core barrel requires sharp); the lexical + // ordering unit test proves index.ts actually calls the function. + const out = runE2EProbe(); + expect(out).toContain('RESULT=E2E_HARDENED'); + }); + }, +); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 89fbfccdbe..ac4155e075 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,6 +8,10 @@ import { FatalError, writeToStderr } from '@vybestack/llxprt-code-core'; import { runBunLauncherIfNeeded } from './src/launcher/bun-launcher.js'; +import { + applyProcessMemoryHardening, + HARDENING_FAILURE_EXIT_CODE, +} from './src/launcher/process-memory-hardening.js'; // --- Global Entry Point --- @@ -87,6 +91,19 @@ function writeCriticalErrorAndGetExitCode(error: unknown): number { // runtime resources for safeRunExitCleanup() to release. runBunLauncherIfNeeded() .then(async () => { + // Mark the process non-dumpable before importing the CLI so the credential + // proxy token and provider keys — which land in this process's address + // space once the CLI runs — cannot be read by an in-container process via + // /proc//mem. No-op off Linux or when neither sandboxed nor + // credential-bearing; fails closed (FatalError) if hardening fails while + // credential-bearing, warns and continues otherwise. See issue #3028. + // The hardening module returns an abort reason rather than throwing so it + // stays free of package imports at this earliest bootstrap point; the + // fatal-error policy lives here. + const { abortReason } = await applyProcessMemoryHardening(); + if (abortReason !== undefined) { + throw new FatalError(abortReason, HARDENING_FAILURE_EXIT_CODE); + } const { main } = await import('./src/cli.js'); try { await main(); diff --git a/packages/cli/src/launcher/bun-ffi.d.ts b/packages/cli/src/launcher/bun-ffi.d.ts new file mode 100644 index 0000000000..7be774853e --- /dev/null +++ b/packages/cli/src/launcher/bun-ffi.d.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal ambient typing for the `bun:ffi` built-in module. + * + * The full `bun-types/ffi.d.ts` declaration is NOT auto-included by + * `packages/cli/tsconfig.json` (only `bun-types/test` is in the `types` + * array), so a plain `import('bun:ffi')` fails to typecheck even though the + * module is available at runtime under Bun. This file declares only the + * surface used by `process-memory-hardening.ts`. + * + * `FFIType` is a real Bun numeric enum; the members below use the true Bun + * ordinals (see `bun-types/ffi.d.ts`). Only the members this module consumes + * are declared. + */ +declare module 'bun:ffi' { + enum FFIType { + /** 32-bit signed integer (Bun ordinal 5). */ + i32 = 5, + /** 64-bit unsigned integer (Bun ordinal 8). */ + u64 = 8, + } + + interface FFIFunctionDefinition { + readonly args: readonly FFIType[]; + readonly returns: FFIType; + } + + type FFISymbol = (...args: number[]) => number; + + interface Library { + readonly symbols: Readonly>; + } + + function dlopen( + name: string, + definitions: Readonly>, + ): Library; +} diff --git a/packages/cli/src/launcher/process-memory-hardening.test.ts b/packages/cli/src/launcher/process-memory-hardening.test.ts new file mode 100644 index 0000000000..5ac4964ab7 --- /dev/null +++ b/packages/cli/src/launcher/process-memory-hardening.test.ts @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, jest, beforeEach, afterEach } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + applyProcessMemoryHardening, + HARDENING_FAILURE_EXIT_CODE, + type ProcessMemoryHardeningOptions, +} from './process-memory-hardening.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Signature of the injectable prctl callable. */ +type PrctlCallable = NonNullable; + +/** Builds a jest.fn spy matching the prctl callable signature. */ +function prctlSpy(): ReturnType> { + return jest.fn((() => 0) as PrctlCallable); +} + +/** Builds a warning sink that captures every message it receives. */ +function warningSink(): { + sink: (message: string) => void; + messages: string[]; +} { + const messages: string[] = []; + return { sink: (m) => messages.push(m), messages }; +} + +/** + * Clears the credential-bearing env markers so the gate tests do not + * accidentally engage the credential-bearing arm when only the sandbox arm is + * under test. + */ +function clearCredentialMarkers(): void { + delete process.env.LLXPRT_CAPABILITY_FD; + delete process.env.LLXPRT_CREDENTIAL_SOCKET; +} + +describe('applyProcessMemoryHardening — gate (AC2)', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...process.env }; + clearCredentialMarkers(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('invokes prctl(4, 0, 0, 0, 0) on Linux inside a container sandbox', async () => { + process.env.SANDBOX = 'docker-llxprt-sandbox-0'; + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: warningSink().sink, + }); + + expect(prctl).toHaveBeenCalledTimes(1); + expect(prctl).toHaveBeenCalledWith(4, 0, 0, 0, 0); + }); + + it('invokes prctl on Linux when credential-bearing even if SANDBOX is unset', async () => { + delete process.env.SANDBOX; + process.env.LLXPRT_CREDENTIAL_SOCKET = '/tmp/cred.sock'; + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: warningSink().sink, + }); + + expect(prctl).toHaveBeenCalledTimes(1); + expect(prctl).toHaveBeenCalledWith(4, 0, 0, 0, 0); + }); + + it('invokes prctl on Linux when LLXPRT_CAPABILITY_FD is set even if SANDBOX is unset', async () => { + delete process.env.SANDBOX; + process.env.LLXPRT_CAPABILITY_FD = '3'; + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: warningSink().sink, + }); + + expect(prctl).toHaveBeenCalledTimes(1); + }); + + it('does not invoke prctl when not sandboxed and not credential-bearing (Linux)', async () => { + delete process.env.SANDBOX; + clearCredentialMarkers(); + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + }); + + expect(prctl).not.toHaveBeenCalled(); + }); + + it("does not invoke prctl when SANDBOX is 'sandbox-exec' and not credential-bearing", async () => { + process.env.SANDBOX = 'sandbox-exec'; + clearCredentialMarkers(); + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + }); + + expect(prctl).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32'])( + 'does not invoke prctl off Linux (platform=%s) even when SANDBOX is set', + async (platform) => { + process.env.SANDBOX = 'docker-llxprt-sandbox-0'; + const prctl = prctlSpy(); + await applyProcessMemoryHardening({ prctl, platform }); + + expect(prctl).not.toHaveBeenCalled(); + }, + ); +}); + +describe('applyProcessMemoryHardening — warn-and-continue (not credential-bearing, AC3)', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...process.env }; + process.env.SANDBOX = 'docker-llxprt-sandbox-0'; + clearCredentialMarkers(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('warns and returns normally when prctl returns non-zero', async () => { + const prctl = jest.fn((() => -1) as PrctlCallable); + const { sink, messages } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).resolves.toStrictEqual({}); + + expect(prctl).toHaveBeenCalledWith(4, 0, 0, 0, 0); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatch(/memory hardening/i); + expect(messages[0]).toContain('-1'); + }); + + it('warns and returns normally when prctl throws', async () => { + const prctl = jest.fn((() => { + throw new Error('boom'); + }) as PrctlCallable); + const { sink, messages } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).resolves.toStrictEqual({}); + + expect(prctl).toHaveBeenCalledTimes(1); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatch(/threw/); + expect(messages[0]).toContain('boom'); + }); + + it('uses the default stderr writer without throwing when no warning sink is injected', async () => { + // Exercises the production default warning path (process.stderr.write) to + // prove it does not throw; prctl is injected so no bun:ffi is touched. + const prctl = jest.fn((() => 1) as PrctlCallable); + + await expect( + applyProcessMemoryHardening({ prctl, platform: 'linux' }), + ).resolves.toStrictEqual({}); + + expect(prctl).toHaveBeenCalledTimes(1); + }); +}); + +describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Blocker #1)', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...process.env }; + process.env.SANDBOX = 'docker-llxprt-sandbox-0'; + process.env.LLXPRT_CAPABILITY_FD = '3'; + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('FAILS CLOSED (returns abortReason, exit code 44) when credential-bearing and prctl returns non-zero', async () => { + const prctl = jest.fn((() => -1) as PrctlCallable); + const { sink, messages } = warningSink(); + + const { abortReason } = await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }); + + expect(abortReason).toBeDefined(); + expect(abortReason).toContain('credential-bearing'); + expect(HARDENING_FAILURE_EXIT_CODE).toBe(44); + // The warning sink must NOT have been called — we aborted, not warned. + expect(messages).toHaveLength(0); + }); + + it('FAILS CLOSED when credential-bearing and prctl throws', async () => { + const prctl = jest.fn((() => { + throw new Error('boom'); + }) as PrctlCallable); + const { sink } = warningSink(); + + const { abortReason } = await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }); + expect(abortReason).toBeDefined(); + }); + + it('FAILS CLOSED when credential-bearing and prctl cannot be resolved (null)', async () => { + const { sink } = warningSink(); + + // Injecting null models "prctl could not be resolved from libc" and + // short-circuits resolveLibcPrctl(), so this stays deterministic under + // both Node and Bun rather than depending on bun:ffi availability. + const { abortReason } = await applyProcessMemoryHardening({ + prctl: null, + platform: 'linux', + writeWarning: sink, + }); + expect(abortReason).toBeDefined(); + }); + + it('FAILS CLOSED when credential-bearing via LLXPRT_CREDENTIAL_SOCKET even if SANDBOX is unset', async () => { + delete process.env.SANDBOX; + process.env.LLXPRT_CREDENTIAL_SOCKET = '/tmp/cred.sock'; + const prctl = jest.fn((() => -1) as PrctlCallable); + const { sink } = warningSink(); + + const { abortReason } = await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }); + expect(abortReason).toBeDefined(); + }); +}); + +describe('applyProcessMemoryHardening — bootstrap ordering (AC4)', () => { + /** + * The full bootstrap (packages/cli/index.ts) launches the Bun relauncher and + * then starts the CLI; it cannot be executed inside a unit test without + * running the entire launcher/main pipeline. The realistic falsifiable + * assertion for AC4 is over the real production file: the hardening call is + * awaited inside the post-relaunch callback and lexically precedes the + * dynamic import of the CLI module. Moving it after that import, removing the + * await, or dropping the call makes this test fail. + * + * Real behavioral coverage that the production function makes the process + * non-dumpable is in `integration-tests/sandboxPrivilege.real.test.ts` + * (AC4-E2E: exercises the real production function in a real container and + * asserts /proc maps ownership). A full index.ts launch is not possible in + * the current sandbox image (the core barrel transitively requires sharp); + * this lexical test is the guard that index.ts actually calls the function. + */ + function readBootstrapSource(): string { + return readFileSync(join(__dirname, '..', '..', 'index.ts'), 'utf8'); + } + + it('awaits applyProcessMemoryHardening before importing the CLI module', () => { + const src = readBootstrapSource(); + + expect(src).toMatch( + /import\s*\{[^}]*\bapplyProcessMemoryHardening\b[^}]*\}\s*from\s*['"]\.\/src\/launcher\/process-memory-hardening\.js['"]/, + ); + + const hardeningIndex = src.indexOf('await applyProcessMemoryHardening()'); + const cliImportIndex = src.indexOf("import('./src/cli.js')"); + + expect(hardeningIndex).toBeGreaterThan(-1); + expect(cliImportIndex).toBeGreaterThan(-1); + expect(hardeningIndex).toBeLessThan(cliImportIndex); + }); +}); diff --git a/packages/cli/src/launcher/process-memory-hardening.ts b/packages/cli/src/launcher/process-memory-hardening.ts new file mode 100644 index 0000000000..1944ebf6c3 --- /dev/null +++ b/packages/cli/src/launcher/process-memory-hardening.ts @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * prctl(2) option that sets the process "dumpable" flag. Setting it to 0 makes + * `/proc//{maps,mem,...}` root-owned, so `ptrace_may_access` denies a + * same-UID reader. The kernel resets this flag to 1 on every `execve`, so it + * must be applied in-process by the final token-holding process. See issue + * #3028. + */ +const PR_SET_DUMPABLE = 4; + +/** Exit code for a fatal sandbox-hardening failure (matches FatalSandboxError). */ +export const HARDENING_FAILURE_EXIT_CODE = 44; + +/** + * Outcome of {@link applyProcessMemoryHardening}. When `abortReason` is set the + * caller MUST abort startup: the process is credential-bearing and could not be + * protected. The reason is returned rather than thrown as a `FatalError` so this + * module — which runs at the earliest bootstrap point, before the CLI is + * imported — stays free of package imports. `packages/cli/index.ts` owns the + * fatal-error policy. + */ +export interface ProcessMemoryHardeningResult { + readonly abortReason?: string; +} + +/** + * Raw prctl callable signature. The real symbol is resolved lazily from libc + * via `bun:ffi`; tests inject a plain function instead. + */ +type PrctlCallable = ( + option: number, + arg2: number, + arg3: number, + arg4: number, + arg5: number, +) => number; + +/** + * Optional seams for {@link applyProcessMemoryHardening}. Every field defaults + * to the real production behavior; tests inject values to drive the gate and + * failure policy without Bun FFI. + */ +export interface ProcessMemoryHardeningOptions { + /** + * Injectable prctl callable. When omitted, the real libc symbol is resolved + * lazily via `bun:ffi` (Linux only). + */ + readonly prctl?: PrctlCallable | null; + /** Injectable platform read; defaults to `process.platform`. */ + readonly platform?: NodeJS.Platform; + /** Injectable warning sink; defaults to the project's stderr writer. */ + readonly writeWarning?: (message: string) => void; +} + +/** + * True when the process is about to hold a credential. The sandbox entrypoint + * sets `LLXPRT_CAPABILITY_FD=3` before exec'ing the CLI (it remains set until + * the credential-store factory consumes/scrubs it inside the CLI module), and + * `sandbox-containers.ts` injects `LLXPRT_CREDENTIAL_SOCKET` via `--env` for + * the entire session. Both are present at the bootstrap point where this module + * runs (before `import('./src/cli.js')`). + */ +function isCredentialBearing(env: NodeJS.ProcessEnv): boolean { + const fd = env['LLXPRT_CAPABILITY_FD']; + const socket = env['LLXPRT_CREDENTIAL_SOCKET']; + return ( + (fd !== undefined && fd !== '') || (socket !== undefined && socket !== '') + ); +} + +/** + * True when `SANDBOX` indicates a container sandbox (Docker/Podman), mirroring + * the detection idiom in `ui/commands/bugCommand.ts`: a non-empty value other + * than `sandbox-exec` (which is macOS Seatbelt, not a container). + */ +function isContainerSandbox(sandboxEnv: string | undefined): boolean { + return ( + sandboxEnv !== undefined && + sandboxEnv !== '' && + sandboxEnv !== 'sandbox-exec' + ); +} + +/** + * The hardening gate: Linux AND (container sandbox OR credential-bearing). + * + * The credential-bearing arm closes a gap where a custom or direct Linux + * launch is credential-bearing but `SANDBOX` is unset — e.g. a user who + * manually exports `LLXPRT_CAPABILITY_FD` and runs the CLI under bun. If a + * credential is about to enter this process's address space, we must harden + * regardless of how the process was launched. + */ +function shouldHarden( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean { + if (platform !== 'linux') return false; + return isContainerSandbox(env['SANDBOX']) || isCredentialBearing(env); +} + +/** + * Resolves the real `prctl` symbol from glibc via `bun:ffi`. Returns null if + * `bun:ffi` or libc is unavailable (e.g. a non-glibc sandbox image) so the + * caller can apply the appropriate failure policy. + * + * `bun:ffi` is imported dynamically so this module remains loadable in Node + * contexts (vitest, tooling) that have no Bun FFI built-in. + */ +async function resolveLibcPrctl(): Promise { + try { + const ffi = await import('bun:ffi'); + const lib = ffi.dlopen('libc.so.6', { + prctl: { + args: [ + ffi.FFIType.i32, + ffi.FFIType.u64, + ffi.FFIType.u64, + ffi.FFIType.u64, + ffi.FFIType.u64, + ], + returns: ffi.FFIType.i32, + }, + }); + const prctl = lib.symbols.prctl; + // The dlopen handle is deliberately left open. Calling lib.close() would + // dlclose libc while we still hold and invoke the captured native function + // pointer. libc.so.6 stays mapped for the process lifetime regardless, and + // this resolves once per process, so there is nothing to reclaim. + return (option, arg2, arg3, arg4, arg5) => + prctl(option, arg2, arg3, arg4, arg5); + } catch { + return null; + } +} + +/** + * Default warning sink. Writes to stderr, tolerating an already-destroyed + * stream so the warn-and-continue policy cannot become a fatal bootstrap + * failure. See {@link applyProcessMemoryHardening}. + */ +function writeWarningToStderr(message: string): void { + try { + process.stderr.write(message); + } catch { + // stderr is unusable; the warning is best-effort by contract. + } +} + +/** + * Applies the failure policy for a hardening failure. When the process is + * credential-bearing this **fails closed** by returning an abort reason: the + * CLI must not start if it cannot protect the credential in memory. When the + * process is NOT credential-bearing it warns on stderr and returns no abort + * reason, preserving the compatibility path for tokenless custom images. + */ +function reportHardeningFailure( + reason: string, + credentialBearing: boolean, + writeWarning: (message: string) => void, +): ProcessMemoryHardeningResult { + if (credentialBearing) { + return { + abortReason: + 'Process memory hardening failed and this process is credential-bearing ' + + '(LLXPRT_CAPABILITY_FD or LLXPRT_CREDENTIAL_SOCKET is set), so the CLI ' + + 'refuses to start rather than expose the credential to an in-container ' + + `memory read. ${reason} Likely cause: a non-glibc sandbox image where ` + + 'prctl cannot be resolved from libc. Use the official Debian bookworm ' + + '/ glibc sandbox image.', + }; + } + writeWarning( + 'Process memory hardening skipped: ' + + reason + + ' The CLI will continue, but an in-container process may be able to ' + + 'read its memory.\n', + ); + return {}; +} + +/** + * Marks the current process non-dumpable via `prctl(PR_SET_DUMPABLE, 0)` so an + * in-container process running as the same UID cannot read this process's + * memory through `/proc//{maps,mem}`. This composes with the + * `CAP_SYS_PTRACE` drop shipped in #3022: `PR_SET_DUMPABLE(0)` alone denies an + * ordinary same-UID reader, and the capability drop prevents the + * `CAP_SYS_PTRACE` privileged override. + * + * No-op off Linux or when neither sandboxed nor credential-bearing. On any + * failure (`bun:ffi` unavailable, libc missing, `prctl` returns non-zero, or + * the callable throws): + * - **Credential-bearing** => returns an `abortReason` (fail closed). The + * caller must refuse to start because the credential cannot be protected. + * - **Not credential-bearing** => writes a visible warning to stderr and + * returns normally (warn and continue), preserving tokenless custom images. + * + * See issue #3028. + */ +export async function applyProcessMemoryHardening( + options: ProcessMemoryHardeningOptions = {}, +): Promise { + const platform = options.platform ?? process.platform; + if (!shouldHarden(platform, process.env)) { + return {}; + } + + const credentialBearing = isCredentialBearing(process.env); + // The default writer must not be able to turn "warn and continue" into a + // fatal bootstrap failure: process.stderr.write can throw synchronously if + // stderr is already destroyed, and that rejection would be caught by + // index.ts and exit the CLI. An INJECTED sink is deliberately left strict so + // a throwing test sink still surfaces. + const writeWarning = options.writeWarning ?? writeWarningToStderr; + // Explicit `undefined` check rather than `??` so an injected `null` really + // short-circuits to the "could not resolve prctl" path. With `??`, injecting + // null would fall through to resolveLibcPrctl(), making the failure path + // environment-dependent (it would resolve a real prctl under Bun on Linux). + const prctl = + options.prctl !== undefined ? options.prctl : await resolveLibcPrctl(); + + if (prctl === null) { + return reportHardeningFailure( + 'Could not resolve prctl from libc.', + credentialBearing, + writeWarning, + ); + } + + let result: number; + try { + result = prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return reportHardeningFailure( + `prctl(PR_SET_DUMPABLE) threw ${detail}.`, + credentialBearing, + writeWarning, + ); + } + + if (result !== 0) { + return reportHardeningFailure( + `prctl(PR_SET_DUMPABLE) returned ${result}.`, + credentialBearing, + writeWarning, + ); + } + return {}; +} diff --git a/packages/cli/vitest.test-groups.ts b/packages/cli/vitest.test-groups.ts index c25d0cddae..af741c11e6 100644 --- a/packages/cli/vitest.test-groups.ts +++ b/packages/cli/vitest.test-groups.ts @@ -82,6 +82,9 @@ const baseExclude: readonly string[] = [ // registered in scripts/bun-test-manifest.ts; they must not also be // discovered by Vitest (bun:test does not resolve under the Vitest runner). '**/src/zed-integration/zedIntegration.terminal.test.ts', + // Process memory hardening tests import the real `bun:test` API, so they + // run under `bun test` only (issue #3028). + '**/src/launcher/process-memory-hardening.test.ts', '**/dist/**', '**/tmp/**', '**/cypress/**', diff --git a/project-plans/issue-3028-process-memory-hardening.md b/project-plans/issue-3028-process-memory-hardening.md new file mode 100644 index 0000000000..772d146394 --- /dev/null +++ b/project-plans/issue-3028-process-memory-hardening.md @@ -0,0 +1,120 @@ +# Issue #3028 — Make the capability token unreadable from in-container processes + +Follow-up to #2902 / PR #3022. + +## Problem + +#3022 shipped `--cap-drop=ALL` and `--security-opt no-new-privileges` on every +Docker/Podman sandbox run. Those flags close setuid-root escalation, but they do +**not** stop an in-container process from reading the CLI's heap: reading a +same-UID process's `/proc//mem` requires no capability. #3022 therefore +left the property conditional on the host's `kernel.yama.ptrace_scope` and +explicitly did not deliver #2902's third item. + +## Measured basis + +Real containers, `ghcr.io/vybestack/llxprt-code/sandbox:0.11.0`, a parent process +reads the hardened child's heap (the more permissive direction, so the denial +implies the realistic descendant-reads-ancestor denial): + +| Config | Result | +|---|---| +| `--cap-drop=ALL` + nnp (shipped in #3022) | `TOKEN_RECOVERED` | +| the above + `prctl(PR_SET_DUMPABLE, 0)` | **`MAPS_DENIED EACCES`** | +| `prctl(PR_SET_DUMPABLE, 0)` but `CAP_SYS_PTRACE` retained | `TOKEN_RECOVERED` | + +Non-dumpable makes `/proc//{maps,mem}` root-owned, so `ptrace_may_access` +denies an ordinary same-UID reader. `CAP_SYS_PTRACE` is a privileged override +that bypasses the dumpable check, so row 3 shows the two controls compose: +`PR_SET_DUMPABLE(0)` denies the ordinary reader, and the #3022 capability drop +denies the privileged override. Dropping the capability alone does NOT deny the +ordinary reader. + +## Design constraints + +1. `PR_SET_DUMPABLE` is reset to 1 on every `execve`, so it cannot be set by the + container entrypoint or any wrapper. It must be set **in-process by the final + token-holding process**. +2. That process is always Bun. `packages/cli/index.ts` calls + `runBunLauncherIfNeeded()` before importing the CLI, and + `resolveRequiredBunPath` throws `FatalError(..., 43)` rather than falling back + to Node. Inside the resolved `.then()` the process is post-relaunch and final. +3. `bun:ffi` must not be imported at module scope — the module is typechecked and + may be loaded in Node contexts (tests, tooling). Use a dynamic import inside + the guarded branch. +4. The call must land before `import('./src/cli.js')`, i.e. before settings, + extensions, hooks, MCP, and the credential-store factory. + +## Hardening gate + +The gate is: **Linux AND (container sandbox OR credential-bearing)**. + +- **Container sandbox**: `SANDBOX` env var is set to a non-empty, non- + `sandbox-exec` value (mirrors `ui/commands/bugCommand.ts`). +- **Credential-bearing**: `LLXPRT_CAPABILITY_FD` or `LLXPRT_CREDENTIAL_SOCKET` is + set. Both are present at the bootstrap point (before + `import('./src/cli.js')`): the sandbox entrypoint sets + `LLXPRT_CAPABILITY_FD=3` before exec'ing the CLI (scrubbed only later by the + credential-store factory), and `sandbox-containers.ts` injects + `LLXPRT_CREDENTIAL_SOCKET` for the whole session. The credential-bearing arm + closes a gap where a custom or direct Linux launch is credential-bearing but + `SANDBOX` is unset. + +## Fail-closed vs. warn-and-continue + +The failure policy is **conditional on whether the process is credential-bearing**: + +- **Credential-bearing + hardening fails** → **fail closed**: throw `FatalError` + (exit 44). The CLI refuses to start because it cannot protect the credential + in memory. This applies when `bun:ffi` is unavailable, libc is missing, + `prctl` returns non-zero, or the callable throws. The message names the likely + cause (non-glibc sandbox image) and is actionable. Throwing from inside the + `runBunLauncherIfNeeded().then()` callback routes to the existing `.catch()` + and `writeCriticalErrorAndGetExitCode`, producing a clean exit — not an + unhandled rejection. +- **Not credential-bearing + hardening fails** → **warn and continue**: write a + visible warning to stderr and return normally. This preserves the + compatibility path for tokenless custom images. + +## Acceptance matrix + +| AC | Behavior | Evidence | +|---|---|---| +| AC1 | On Linux inside a sandbox, the CLI process is made non-dumpable before the CLI module is imported. | Real-container test: parent read of the child's `/proc//maps` is denied. | +| AC2 | The hardening is a no-op off Linux, a no-op when neither sandboxed nor credential-bearing, and engages when credential-bearing even if `SANDBOX` is unset. | Unit tests over the gate with an injected prctl callable; asserts called/not-called per the gate. | +| AC3 | Credential-bearing + hardening fails → throws `FatalError` (fail closed). Not credential-bearing + hardening fails → warns on stderr and continues. | Unit tests with injected failing/throwing/null callables for both paths. | +| AC4 | The production call site runs after the Bun relaunch decision and before the CLI import. | Lexical ordering test over `packages/cli/index.ts` bootstrap **plus** real-container E2E test (AC4-E2E) that calls the real production `applyProcessMemoryHardening()` (same import path as index.ts) and asserts the process's `/proc//maps` is root-owned. A full `bun packages/cli/index.ts` launch is not achievable inside the current sandbox image because `index.ts` statically imports the core barrel → `@vybestack/llxprt-code-tools` → `sharp`, which is not installed. The lexical test catches deletion of the call from index.ts. | +| AC5 | A parent process in a real container cannot read the hardened child process's memory. | Real-container test driving the **production** module (parent-reads-child, Yama-independent); falsifiable — removing the prctl call turns it red. | +| AC6 | `docs/sandbox.md` states the boundary unconditionally, describes the precise composition, and retains the in-process non-goal. | Doc diff. | + +Real-container tests reuse the gating and helper conventions already in +`integration-tests/sandboxPrivilege.real.test.ts` (run when a runtime + image +are available, skip only when genuinely absent, honor `LLXPRT_SANDBOX`). + +## Test-arm design (Yama independence) + +Both test arms invert the relationship so the **TRACER is the PARENT** and the +**TARGET is a CHILD**: + +- Child process: calls the real production `applyProcessMemoryHardening()` and + holds the 64-hex secret resident in its heap. +- Parent process: reads `/proc//maps` and `/proc//mem` and scans + for the secret. +- Hardened (dumpable=0) => DENIED — requires `CAP_SYS_PTRACE` regardless of + Yama, and #3022 drops it. +- Gate disengaged => RECOVERED under both `ptrace_scope` 0 and 1, because tracing + a descendant is permitted at scope 1. + +This is a strictly stronger test: parent-reads-child is the more permissive +direction, so denying it implies denying the realistic descendant-reads-ancestor +direction. Verified by running with `kernel.yama.ptrace_scope=1` and `=0`. + +## Non-goals + +- In-process attackers. Code executing **inside** the CLI (a malicious + dependency, a compromised in-process extension) reads the token from its own + heap; `PR_SET_DUMPABLE` does not help. Unchanged non-goal from #1954. +- Per-command UID separation. +- Seatbelt / macOS-host path. +- Any change to the credential proxy protocol, token format, or authorization. +- Any workflow, dependency, or quality-tool change. diff --git a/scripts/bun-test-manifest.ts b/scripts/bun-test-manifest.ts index 2f68c56fd6..e23e562034 100644 --- a/scripts/bun-test-manifest.ts +++ b/scripts/bun-test-manifest.ts @@ -184,6 +184,10 @@ export const BUN_NATIVE_TEST_MANIFEST: readonly BunTestWorkspaceEntry[] = [ // Sandbox SSH agent preflight (issue #1699). Bun-native from the start // and likewise excluded from the Vitest selection. 'src/utils/sandbox-ssh-agent-preflight.test.ts', + // Process memory hardening (issue #3028). Imports the real `bun:test` + // API rather than the Vitest shim, so it runs only here and is excluded + // from the Vitest selection. + 'src/launcher/process-memory-hardening.test.ts', 'src/zed-integration/zed-session-lifecycle.test.ts', // Issue #2980: Zed terminal command correlation. Migrated to bun:test // and excluded from the Vitest selection below; the strict wrapper