From 71b6e963ebc6ffac75c317d015b7f1ffb34bd6dc Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 4 Aug 2026 20:23:45 -0300 Subject: [PATCH 1/5] Deny in-container reads of the CLI's memory (Fixes #3028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit container, but neither stops an in-container process from reading the CLI's heap: reading a same-UID process's /proc//mem needs no capability, and it is open()+pread() rather than the ptrace syscall a seccomp filter could deny. That left the capability token — and the provider API key, which lives in the same address space — readable, conditional only on the host's kernel.yama.ptrace_scope. The CLI now marks itself non-dumpable with prctl(PR_SET_DUMPABLE, 0) at the launcher bootstrap. /proc//{maps,mem} become root-owned, so ptrace_may_access denies an ordinary same-UID reader regardless of the host Yama setting. Measured in real containers: the read is refused at maps with EACCES. The two controls compose but are not interchangeable. PR_SET_DUMPABLE alone denies the ordinary reader; CAP_SYS_PTRACE is a privileged override of the dumpable check, so #3022's capability drop is what prevents that override. Dropping capabilities alone denies nothing here. prctl is a raw syscall and the dumpable flag is reset on every execve, so it must be set in-process by the final token-holding process. That process is always Bun — index.ts runs runBunLauncherIfNeeded() before importing the CLI and resolveRequiredBunPath throws rather than falling back to Node — so bun:ffi can call it with no new dependency and no native addon. The call lands before the CLI import, ahead of settings, extensions, hooks, MCP, and the credential-store factory. Failure policy is conditional rather than uniformly fail-open. When the process is credential-bearing (LLXPRT_CAPABILITY_FD or LLXPRT_CREDENTIAL_SOCKET set) and hardening fails, the CLI refuses to start rather than load a credential it cannot protect. When it is not credential-bearing it warns and continues, which keeps tokenless custom images working. The gate engages on Linux when either sandboxed or credential-bearing, so a direct credential-bearing launch is covered too. The real-container test has the parent read a child rather than the reverse, so both the positive and the negative arm are independent of the host ptrace_scope; verified passing at scope 0 and scope 1. Neutralizing the prctl call turns the positive arm red. This does not defend against code running inside the CLI process itself — a malicious dependency or compromised in-process extension reads the token from its own heap. That remains the non-goal it was in #1954. --- docs/sandbox.md | 106 ++++-- .../process-memory-hardening-driver.ts | 296 ++++++++++++++++ .../sandboxPrivilege.real.test.ts | 157 +++++++++ packages/cli/index.ts | 8 + packages/cli/src/launcher/bun-ffi.d.ts | 43 +++ .../launcher/process-memory-hardening.test.ts | 317 ++++++++++++++++++ .../src/launcher/process-memory-hardening.ts | 221 ++++++++++++ packages/cli/vitest.test-groups.ts | 2 +- .../issue-3028-process-memory-hardening.md | 120 +++++++ 9 files changed, 1235 insertions(+), 35 deletions(-) create mode 100644 integration-tests/fixtures/process-memory-hardening-driver.ts create mode 100644 packages/cli/src/launcher/bun-ffi.d.ts create mode 100644 packages/cli/src/launcher/process-memory-hardening.test.ts create mode 100644 packages/cli/src/launcher/process-memory-hardening.ts create mode 100644 project-plans/issue-3028-process-memory-hardening.md 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..254b1a0e33 --- /dev/null +++ b/integration-tests/fixtures/process-memory-hardening-driver.ts @@ -0,0 +1,296 @@ +/** + * @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_EXITED — the CLI exited before ownership could be read. + * RESULT=E2E_TIMEOUT — timed out polling. + * + * 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 = 'ERROR:unreachable'; + try { + const childPid = await waitForReady(child); + result = scanProcessMemory(childPid, SECRET); + } catch (err) { + result = `ERROR:${err instanceof Error ? err.message : String(err)}`; + } finally { + 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 = 'E2E_TIMEOUT'; + try { + const childPid = await waitForReady(child); + result = checkMapsOwnership(childPid); + } catch (err) { + result = `E2E_ERROR:${err instanceof Error ? err.message : String(err)}`; + } finally { + 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 +// --------------------------------------------------------------------------- + +function killChild(child: ChildProcess): void { + try { + child.kill('SIGTERM'); + } catch { + // best-effort + } +} + +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..0f1ab7d1bf 100644 --- a/integration-tests/sandboxPrivilege.real.test.ts +++ b/integration-tests/sandboxPrivilege.real.test.ts @@ -348,3 +348,160 @@ 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. + */ + function runMemoryProbe(sandboxEnv: 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, + ], + { timeout: RUN_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024 }, + ).toString(); + } + + /** + * 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 execFileSync( + runtime!, + [ + 'run', + '--rm', + ...productionSecurityFlags(), + '--volume', + `${repoRoot}:/repo:ro`, + '--env', + 'SANDBOX=e2e-probe', + '--env', + 'BUN_INSTALL_CACHE_DIR=/tmp/.bun', + image, + 'bun', + driverContainerPath, + '__e2e__', + ], + { timeout: RUN_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024 }, + ).toString(); + } + + 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..9ccce6822f 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,6 +8,7 @@ import { FatalError, writeToStderr } from '@vybestack/llxprt-code-core'; import { runBunLauncherIfNeeded } from './src/launcher/bun-launcher.js'; +import { applyProcessMemoryHardening } from './src/launcher/process-memory-hardening.js'; // --- Global Entry Point --- @@ -87,6 +88,13 @@ 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. + await applyProcessMemoryHardening(); 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..c13e1641fb --- /dev/null +++ b/packages/cli/src/launcher/process-memory-hardening.test.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { FatalError } from '@vybestack/llxprt-code-core/utils/errors.js'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + applyProcessMemoryHardening, + 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 vi.fn spy matching the prctl callable signature. */ +function prctlSpy(): ReturnType> { + return vi.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; + vi.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; + vi.restoreAllMocks(); + }); + + it('warns and returns normally when prctl returns non-zero', async () => { + const prctl = vi.fn((() => -1) as PrctlCallable); + const { sink, messages } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).resolves.toBeUndefined(); + + 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 = vi.fn((() => { + throw new Error('boom'); + }) as PrctlCallable); + const { sink, messages } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).resolves.toBeUndefined(); + + 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 = vi.fn((() => 1) as PrctlCallable); + + await expect( + applyProcessMemoryHardening({ prctl, platform: 'linux' }), + ).resolves.toBeUndefined(); + + 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; + vi.restoreAllMocks(); + }); + + it('FAILS CLOSED (throws FatalError exit 44) when credential-bearing and prctl returns non-zero', async () => { + const prctl = vi.fn((() => -1) as PrctlCallable); + const { sink, messages } = warningSink(); + + let caught: unknown; + try { + await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(FatalError); + expect((caught as FatalError).exitCode).toBe(44); + // The warning sink must NOT have been called — we threw, not warned. + expect(messages).toHaveLength(0); + }); + + it('FAILS CLOSED when credential-bearing and prctl throws', async () => { + const prctl = vi.fn((() => { + throw new Error('boom'); + }) as PrctlCallable); + const { sink } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).rejects.toBeInstanceOf(FatalError); + }); + + it('FAILS CLOSED when credential-bearing and prctl cannot be resolved (null)', async () => { + const { sink } = warningSink(); + + await expect( + // No injected prctl => resolves from libc; force null by injecting null. + applyProcessMemoryHardening({ + prctl: null as unknown as PrctlCallable, + platform: 'linux', + writeWarning: sink, + }), + ).rejects.toBeInstanceOf(FatalError); + }); + + 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 = vi.fn((() => -1) as PrctlCallable); + const { sink } = warningSink(); + + await expect( + applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }), + ).rejects.toBeInstanceOf(FatalError); + }); +}); + +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..738f965162 --- /dev/null +++ b/packages/cli/src/launcher/process-memory-hardening.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FatalError } from '@vybestack/llxprt-code-core/utils/errors.js'; + +/** + * 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). */ +const HARDENING_FAILURE_EXIT_CODE = 44; + +/** + * 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; + /** 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; + return (option, arg2, arg3, arg4, arg5) => + prctl(option, arg2, arg3, arg4, arg5); + } catch { + return null; + } +} + +/** + * Applies the failure policy for a hardening failure. When the process is + * credential-bearing this **fails closed** by throwing a {@link FatalError}: + * 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 continues, + * preserving the compatibility path for tokenless custom images. + */ +function reportHardeningFailure( + reason: string, + credentialBearing: boolean, + writeWarning: (message: string) => void, +): void { + if (credentialBearing) { + throw new FatalError( + '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.', + HARDENING_FAILURE_EXIT_CODE, + ); + } + writeWarning( + 'Process memory hardening skipped: ' + + reason + + ' The CLI will continue, but an in-container process may be able to ' + + 'read its memory.\n', + ); +} + +/** + * 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** => throws `FatalError` (fail closed). The CLI + * refuses to start because it cannot protect the credential. + * - **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); + const writeWarning = + options.writeWarning ?? + ((message: string): void => { + process.stderr.write(message); + }); + const prctl = options.prctl ?? (await resolveLibcPrctl()); + + if (prctl === null) { + reportHardeningFailure( + 'Could not resolve prctl from libc.', + credentialBearing, + writeWarning, + ); + return; + } + + let result: number; + try { + result = prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + reportHardeningFailure( + `prctl(PR_SET_DUMPABLE) threw ${detail}.`, + credentialBearing, + writeWarning, + ); + return; + } + + if (result !== 0) { + reportHardeningFailure( + `prctl(PR_SET_DUMPABLE) returned ${result}.`, + credentialBearing, + writeWarning, + ); + } +} diff --git a/packages/cli/vitest.test-groups.ts b/packages/cli/vitest.test-groups.ts index c25d0cddae..3ee196bf35 100644 --- a/packages/cli/vitest.test-groups.ts +++ b/packages/cli/vitest.test-groups.ts @@ -496,4 +496,4 @@ export function buildTestGroups( * The expected total selected file count after integrating the v0.11.0 test set. * Exported for behavioral tests to assert against an independent oracle. */ -export const SELECTED_FILE_COUNT: number = 530; +export const SELECTED_FILE_COUNT: number = 531; 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. From 923af7bf1bf41c3cddbd74b3127bd1fff7586dd9 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 4 Aug 2026 20:29:47 -0300 Subject: [PATCH 2/5] Make the prctl-resolution failure path deterministic Open Code Review found that injecting null for the prctl seam did not short-circuit: 'options.prctl ?? await resolveLibcPrctl()' treats null the same as undefined, so the null-injection test fell through to the real libc resolution. Under Bun on Linux that would resolve a working prctl and harden the test runner instead of exercising the failure path, so the test passed only because bun:ffi is unavailable under Node. Widen the seam to PrctlCallable | null and select it with an explicit undefined check so an injected null genuinely models 'prctl could not be resolved', independent of runtime. Drops the double type-cast in the test. Also records why the dlopen handle is deliberately not closed (closing it would dlclose libc while we still invoke the captured function pointer), and collapses the two near-identical container probe helpers into one. --- .../sandboxPrivilege.real.test.ts | 32 +++++++------------ .../launcher/process-memory-hardening.test.ts | 6 ++-- .../src/launcher/process-memory-hardening.ts | 13 ++++++-- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/integration-tests/sandboxPrivilege.real.test.ts b/integration-tests/sandboxPrivilege.real.test.ts index 0f1ab7d1bf..0a2d4a8508 100644 --- a/integration-tests/sandboxPrivilege.real.test.ts +++ b/integration-tests/sandboxPrivilege.real.test.ts @@ -423,7 +423,12 @@ describe.skipIf(skipTests)( * (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. */ - function runMemoryProbe(sandboxEnv: string): string { + /** + * 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!, [ @@ -439,11 +444,16 @@ describe.skipIf(skipTests)( 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 @@ -451,25 +461,7 @@ describe.skipIf(skipTests)( * to verify the real production function hardened the process. */ function runE2EProbe(): string { - return execFileSync( - runtime!, - [ - 'run', - '--rm', - ...productionSecurityFlags(), - '--volume', - `${repoRoot}:/repo:ro`, - '--env', - 'SANDBOX=e2e-probe', - '--env', - 'BUN_INSTALL_CACHE_DIR=/tmp/.bun', - image, - 'bun', - driverContainerPath, - '__e2e__', - ], - { timeout: RUN_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024 }, - ).toString(); + return runDriver('e2e-probe', '__e2e__'); } it('a parent process is DENIED the hardened child process maps (AC1, AC5)', () => { diff --git a/packages/cli/src/launcher/process-memory-hardening.test.ts b/packages/cli/src/launcher/process-memory-hardening.test.ts index c13e1641fb..287e26b728 100644 --- a/packages/cli/src/launcher/process-memory-hardening.test.ts +++ b/packages/cli/src/launcher/process-memory-hardening.test.ts @@ -254,9 +254,11 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block const { sink } = warningSink(); await expect( - // No injected prctl => resolves from libc; force null by injecting null. + // 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. applyProcessMemoryHardening({ - prctl: null as unknown as PrctlCallable, + prctl: null, platform: 'linux', writeWarning: sink, }), diff --git a/packages/cli/src/launcher/process-memory-hardening.ts b/packages/cli/src/launcher/process-memory-hardening.ts index 738f965162..fc29f29709 100644 --- a/packages/cli/src/launcher/process-memory-hardening.ts +++ b/packages/cli/src/launcher/process-memory-hardening.ts @@ -40,7 +40,7 @@ export interface ProcessMemoryHardeningOptions { * Injectable prctl callable. When omitted, the real libc symbol is resolved * lazily via `bun:ffi` (Linux only). */ - readonly prctl?: PrctlCallable; + 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. */ @@ -117,6 +117,10 @@ async function resolveLibcPrctl(): Promise { }, }); 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 { @@ -187,7 +191,12 @@ export async function applyProcessMemoryHardening( ((message: string): void => { process.stderr.write(message); }); - const prctl = options.prctl ?? (await resolveLibcPrctl()); + // 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) { reportHardeningFailure( From 2312b849e411068b9751efcd3b5ff37fd171bf56 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 4 Aug 2026 20:51:55 -0300 Subject: [PATCH 3/5] Keep the bootstrap hardening module free of package imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI import-boundary guard rejected the deep subpath import of FatalError (@vybestack/llxprt-code-core/utils/errors.js). The subpath was used to dodge the core barrel, which transitively pulls sharp and therefore cannot load in the sandbox image used by the container tests. Resolve both by removing the import entirely: applyProcessMemoryHardening now returns an optional abortReason instead of throwing, and packages/cli/index.ts — which already imports FatalError from the package root — owns the fatal-error policy. The module runs at the earliest bootstrap point, so having no package imports is the better shape regardless of the guard. Behavior is unchanged: credential-bearing plus hardening failure still exits 44, and the tokenless path still warns and continues. --- packages/cli/index.ts | 13 ++- .../launcher/process-memory-hardening.test.ts | 79 +++++++++---------- .../src/launcher/process-memory-hardening.ts | 52 +++++++----- 3 files changed, 78 insertions(+), 66 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 9ccce6822f..ac4155e075 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,7 +8,10 @@ import { FatalError, writeToStderr } from '@vybestack/llxprt-code-core'; import { runBunLauncherIfNeeded } from './src/launcher/bun-launcher.js'; -import { applyProcessMemoryHardening } from './src/launcher/process-memory-hardening.js'; +import { + applyProcessMemoryHardening, + HARDENING_FAILURE_EXIT_CODE, +} from './src/launcher/process-memory-hardening.js'; // --- Global Entry Point --- @@ -94,7 +97,13 @@ runBunLauncherIfNeeded() // /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. - await applyProcessMemoryHardening(); + // 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/process-memory-hardening.test.ts b/packages/cli/src/launcher/process-memory-hardening.test.ts index 287e26b728..cb3a10d055 100644 --- a/packages/cli/src/launcher/process-memory-hardening.test.ts +++ b/packages/cli/src/launcher/process-memory-hardening.test.ts @@ -5,12 +5,12 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { FatalError } from '@vybestack/llxprt-code-core/utils/errors.js'; 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'; @@ -158,7 +158,7 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear platform: 'linux', writeWarning: sink, }), - ).resolves.toBeUndefined(); + ).resolves.toStrictEqual({}); expect(prctl).toHaveBeenCalledWith(4, 0, 0, 0, 0); expect(messages).toHaveLength(1); @@ -178,7 +178,7 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear platform: 'linux', writeWarning: sink, }), - ).resolves.toBeUndefined(); + ).resolves.toStrictEqual({}); expect(prctl).toHaveBeenCalledTimes(1); expect(messages).toHaveLength(1); @@ -193,7 +193,7 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear await expect( applyProcessMemoryHardening({ prctl, platform: 'linux' }), - ).resolves.toBeUndefined(); + ).resolves.toStrictEqual({}); expect(prctl).toHaveBeenCalledTimes(1); }); @@ -214,24 +214,20 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block vi.restoreAllMocks(); }); - it('FAILS CLOSED (throws FatalError exit 44) when credential-bearing and prctl returns non-zero', async () => { + it('FAILS CLOSED (returns abortReason, exit code 44) when credential-bearing and prctl returns non-zero', async () => { const prctl = vi.fn((() => -1) as PrctlCallable); const { sink, messages } = warningSink(); - let caught: unknown; - try { - await applyProcessMemoryHardening({ - prctl, - platform: 'linux', - writeWarning: sink, - }); - } catch (e) { - caught = e; - } - - expect(caught).toBeInstanceOf(FatalError); - expect((caught as FatalError).exitCode).toBe(44); - // The warning sink must NOT have been called — we threw, not warned. + 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); }); @@ -241,28 +237,26 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block }) as PrctlCallable); const { sink } = warningSink(); - await expect( - applyProcessMemoryHardening({ - prctl, - platform: 'linux', - writeWarning: sink, - }), - ).rejects.toBeInstanceOf(FatalError); + 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(); - await expect( - // 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. - applyProcessMemoryHardening({ - prctl: null, - platform: 'linux', - writeWarning: sink, - }), - ).rejects.toBeInstanceOf(FatalError); + // 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 () => { @@ -271,13 +265,12 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block const prctl = vi.fn((() => -1) as PrctlCallable); const { sink } = warningSink(); - await expect( - applyProcessMemoryHardening({ - prctl, - platform: 'linux', - writeWarning: sink, - }), - ).rejects.toBeInstanceOf(FatalError); + const { abortReason } = await applyProcessMemoryHardening({ + prctl, + platform: 'linux', + writeWarning: sink, + }); + expect(abortReason).toBeDefined(); }); }); diff --git a/packages/cli/src/launcher/process-memory-hardening.ts b/packages/cli/src/launcher/process-memory-hardening.ts index fc29f29709..62d76d31d8 100644 --- a/packages/cli/src/launcher/process-memory-hardening.ts +++ b/packages/cli/src/launcher/process-memory-hardening.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { FatalError } from '@vybestack/llxprt-code-core/utils/errors.js'; - /** * 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 @@ -16,7 +14,19 @@ import { FatalError } from '@vybestack/llxprt-code-core/utils/errors.js'; const PR_SET_DUMPABLE = 4; /** Exit code for a fatal sandbox-hardening failure (matches FatalSandboxError). */ -const HARDENING_FAILURE_EXIT_CODE = 44; +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 @@ -130,26 +140,26 @@ async function resolveLibcPrctl(): Promise { /** * Applies the failure policy for a hardening failure. When the process is - * credential-bearing this **fails closed** by throwing a {@link FatalError}: - * 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 continues, - * preserving the compatibility path for tokenless custom images. + * 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, -): void { +): ProcessMemoryHardeningResult { if (credentialBearing) { - throw new FatalError( - 'Process memory hardening failed and this process is credential-bearing ' + + 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.', - HARDENING_FAILURE_EXIT_CODE, - ); + }; } writeWarning( 'Process memory hardening skipped: ' + @@ -157,6 +167,7 @@ function reportHardeningFailure( ' The CLI will continue, but an in-container process may be able to ' + 'read its memory.\n', ); + return {}; } /** @@ -170,8 +181,8 @@ function reportHardeningFailure( * 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** => throws `FatalError` (fail closed). The CLI - * refuses to start because it cannot protect the credential. + * - **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. * @@ -179,10 +190,10 @@ function reportHardeningFailure( */ export async function applyProcessMemoryHardening( options: ProcessMemoryHardeningOptions = {}, -): Promise { +): Promise { const platform = options.platform ?? process.platform; if (!shouldHarden(platform, process.env)) { - return; + return {}; } const credentialBearing = isCredentialBearing(process.env); @@ -199,12 +210,11 @@ export async function applyProcessMemoryHardening( options.prctl !== undefined ? options.prctl : await resolveLibcPrctl(); if (prctl === null) { - reportHardeningFailure( + return reportHardeningFailure( 'Could not resolve prctl from libc.', credentialBearing, writeWarning, ); - return; } let result: number; @@ -212,19 +222,19 @@ export async function applyProcessMemoryHardening( result = prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - reportHardeningFailure( + return reportHardeningFailure( `prctl(PR_SET_DUMPABLE) threw ${detail}.`, credentialBearing, writeWarning, ); - return; } if (result !== 0) { - reportHardeningFailure( + return reportHardeningFailure( `prctl(PR_SET_DUMPABLE) returned ${result}.`, credentialBearing, writeWarning, ); } + return {}; } From a12d0df317aaeb92842710b58591690ad8c7f046 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 4 Aug 2026 21:20:57 -0300 Subject: [PATCH 4/5] Address PR review on the driver contract and warning sink - The driver header documented E2E_EXITED and E2E_TIMEOUT results that runE2e never produced; all failure modes surface as E2E_ERROR with the underlying message. Documented what the code actually returns and dropped the dead initial values in both modes. - killChild only sent SIGTERM and did not wait, so the parent could exit while a child still held the secret. It now waits for exit and escalates to SIGKILL after a bounded delay. - The default warning sink could turn warn-and-continue into a fatal bootstrap failure: process.stderr.write throws synchronously on a destroyed stream, and index.ts would catch that and exit. The default sink now tolerates an unusable stderr. An injected sink stays strict so a throwing test sink still surfaces. --- .../process-memory-hardening-driver.ts | 42 +++++++++++++++---- .../src/launcher/process-memory-hardening.ts | 24 ++++++++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/integration-tests/fixtures/process-memory-hardening-driver.ts b/integration-tests/fixtures/process-memory-hardening-driver.ts index 254b1a0e33..96e80a6425 100644 --- a/integration-tests/fixtures/process-memory-hardening-driver.ts +++ b/integration-tests/fixtures/process-memory-hardening-driver.ts @@ -47,8 +47,10 @@ * 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_EXITED — the CLI exited before ownership could be read. - * RESULT=E2E_TIMEOUT — timed out polling. + * 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. @@ -125,14 +127,14 @@ async function runParent(): Promise { env: { ...process.env }, }); - let result = 'ERROR:unreachable'; + 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 { - killChild(child); + await killChild(child); } process.stdout.write(`RESULT=${result}\n`); @@ -233,14 +235,14 @@ async function runE2e(): Promise { }, }); - let result = 'E2E_TIMEOUT'; + 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 { - killChild(child); + await killChild(child); } process.stdout.write(`RESULT=${result}\n`); @@ -268,12 +270,36 @@ function checkMapsOwnership(pid: number): string { // Helpers // --------------------------------------------------------------------------- -function killChild(child: ChildProcess): void { +/** + * 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 { - // best-effort + 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 { diff --git a/packages/cli/src/launcher/process-memory-hardening.ts b/packages/cli/src/launcher/process-memory-hardening.ts index 62d76d31d8..1944ebf6c3 100644 --- a/packages/cli/src/launcher/process-memory-hardening.ts +++ b/packages/cli/src/launcher/process-memory-hardening.ts @@ -138,6 +138,19 @@ async function resolveLibcPrctl(): Promise { } } +/** + * 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 @@ -197,11 +210,12 @@ export async function applyProcessMemoryHardening( } const credentialBearing = isCredentialBearing(process.env); - const writeWarning = - options.writeWarning ?? - ((message: string): void => { - process.stderr.write(message); - }); + // 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 From 322d9b86be4523078bd7359eaabecfd4dba4de23 Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 5 Aug 2026 01:19:29 -0300 Subject: [PATCH 5/5] Move the new tests to bun:test rather than the Vitest shim Both new test files imported from 'vitest'. They executed under Bun via the augment-bun-vi compat preload, but the project is migrating one direction and new or modified tests must use the native bun:test API. - process-memory-hardening.test.ts now imports from bun:test and uses jest.fn / jest.restoreAllMocks instead of vi. It is registered in scripts/bun-test-manifest.ts and excluded from the Vitest selection, so it runs only under bun test. SELECTED_FILE_COUNT returns to 531 because the file is no longer part of the Vitest set. - sandboxPrivilege.real.test.ts now imports from bun:test. The integration-tests root is already a fully migrated Bun root with an include glob, so it is picked up with no manifest edit. Verified: describe.skipIf is supported by bun:test; unit suite 15/15 under bun test; container suite 9/9 on Docker and on Podman at ptrace_scope=1; the Vitest selection oracle passes at 531. --- .../sandboxPrivilege.real.test.ts | 2 +- .../launcher/process-memory-hardening.test.ts | 26 +++++++++---------- packages/cli/vitest.test-groups.ts | 5 +++- scripts/bun-test-manifest.ts | 4 +++ 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/integration-tests/sandboxPrivilege.real.test.ts b/integration-tests/sandboxPrivilege.real.test.ts index 0a2d4a8508..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'; diff --git a/packages/cli/src/launcher/process-memory-hardening.test.ts b/packages/cli/src/launcher/process-memory-hardening.test.ts index cb3a10d055..5ac4964ab7 100644 --- a/packages/cli/src/launcher/process-memory-hardening.test.ts +++ b/packages/cli/src/launcher/process-memory-hardening.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +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'; @@ -19,9 +19,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** Signature of the injectable prctl callable. */ type PrctlCallable = NonNullable; -/** Builds a vi.fn spy matching the prctl callable signature. */ -function prctlSpy(): ReturnType> { - return vi.fn((() => 0) as PrctlCallable); +/** 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. */ @@ -54,7 +54,7 @@ describe('applyProcessMemoryHardening — gate (AC2)', () => { afterEach(() => { process.env = originalEnv; - vi.restoreAllMocks(); + jest.restoreAllMocks(); }); it('invokes prctl(4, 0, 0, 0, 0) on Linux inside a container sandbox', async () => { @@ -145,11 +145,11 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear afterEach(() => { process.env = originalEnv; - vi.restoreAllMocks(); + jest.restoreAllMocks(); }); it('warns and returns normally when prctl returns non-zero', async () => { - const prctl = vi.fn((() => -1) as PrctlCallable); + const prctl = jest.fn((() => -1) as PrctlCallable); const { sink, messages } = warningSink(); await expect( @@ -167,7 +167,7 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear }); it('warns and returns normally when prctl throws', async () => { - const prctl = vi.fn((() => { + const prctl = jest.fn((() => { throw new Error('boom'); }) as PrctlCallable); const { sink, messages } = warningSink(); @@ -189,7 +189,7 @@ describe('applyProcessMemoryHardening — warn-and-continue (not credential-bear 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 = vi.fn((() => 1) as PrctlCallable); + const prctl = jest.fn((() => 1) as PrctlCallable); await expect( applyProcessMemoryHardening({ prctl, platform: 'linux' }), @@ -211,11 +211,11 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block afterEach(() => { process.env = originalEnv; - vi.restoreAllMocks(); + jest.restoreAllMocks(); }); it('FAILS CLOSED (returns abortReason, exit code 44) when credential-bearing and prctl returns non-zero', async () => { - const prctl = vi.fn((() => -1) as PrctlCallable); + const prctl = jest.fn((() => -1) as PrctlCallable); const { sink, messages } = warningSink(); const { abortReason } = await applyProcessMemoryHardening({ @@ -232,7 +232,7 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block }); it('FAILS CLOSED when credential-bearing and prctl throws', async () => { - const prctl = vi.fn((() => { + const prctl = jest.fn((() => { throw new Error('boom'); }) as PrctlCallable); const { sink } = warningSink(); @@ -262,7 +262,7 @@ describe('applyProcessMemoryHardening — fail-closed (credential-bearing, Block 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 = vi.fn((() => -1) as PrctlCallable); + const prctl = jest.fn((() => -1) as PrctlCallable); const { sink } = warningSink(); const { abortReason } = await applyProcessMemoryHardening({ diff --git a/packages/cli/vitest.test-groups.ts b/packages/cli/vitest.test-groups.ts index 3ee196bf35..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/**', @@ -496,4 +499,4 @@ export function buildTestGroups( * The expected total selected file count after integrating the v0.11.0 test set. * Exported for behavioral tests to assert against an independent oracle. */ -export const SELECTED_FILE_COUNT: number = 531; +export const SELECTED_FILE_COUNT: number = 530; 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