diff --git a/README.md b/README.md index cc760232c..b3d7790d6 100644 --- a/README.md +++ b/README.md @@ -905,6 +905,9 @@ Filesystem restrictions are enforced at the OS level: **Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. +- No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. +- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. + **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 4bd28f115..f89265b71 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -23,6 +23,7 @@ import { isAbsenceErrno, isAtOrUnder, isStrictlyUnder, + properAncestors, nameLocation, getDangerousDirectories, workingDirectory, @@ -274,6 +275,97 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } +/** What bubblewrap makes on the host to mount a placeholder on. */ +type MountPointKind = 'file' | 'directory' + +/** open(2)'s O_PATH on x86-64 and arm64; node:fs does not name it. Where the + * bit means something else the open is an ordinary one or fails, and a failure + * keeps the placeholder. */ +const O_PATH = 0o10000000 +/** statfs(2)'s type for 9p, which is how WSL 2 mounts a Windows drive. */ +const V9FS_MAGIC = 0x01021997 + +/** + * The kind of mount point that can be made at `dest`, an absent path whose + * parent is there: `wanted`, a directory where only a file is refused, or + * none. bubblewrap makes it on the host as this user, before the command runs, + * and exits if it cannot. + * + * None only where the refusal is not this user's to lift, so that the command + * cannot create `dest` either; `wanted` on any doubt (see mountPointFor). + */ +function makeableMountPoint( + dest: string, + wanted: MountPointKind, +): MountPointKind | undefined { + /** The errno `attempt` is refused with. Any other failure is a doubt. */ + const refusalOf = (attempt: () => void): string | undefined => { + try { + attempt() + return undefined + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return code + throw err + } + } + let fd: number | undefined + try { + // One descriptor answers every question, through /proc: a command still + // running could put another directory at the path between two of them. + // Its link differs from the path when a symlink was followed on the way. + fd = fs.openSync( + path.dirname(dest), + O_PATH | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, + ) + const parent = `/proc/self/fd/${fd}` + if (fs.readlinkSync(parent) !== path.dirname(dest)) return wanted + const { uid, mode } = fs.fstatSync(fd) + // What the kernel asks of a directory before it creates anything in it. + const refusal = refusalOf(() => + fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK), + ) + + if (fs.statfsSync(parent).type !== V9FS_MAGIC) { + // EACCES is the word of the mode or an ACL, which the owner can change, + // and bubblewrap's user namespace overrides it for the owner. A + // read-only file system (EROFS) and an immutable directory (EPERM) + // refuse whoever asks from in there. + return refusal === undefined || + (refusal === 'EACCES' && uid === process.geteuid?.()) + ? wanted + : undefined + } + + // On a Windows drive the Windows ACL decides, and nothing on this side + // tells it: every directory there is this user's, with a mode that lets it + // write, also one that takes subfolders and no files. So the mount point + // is made here instead of by bubblewrap, which finds it there. + if (refusal === undefined) { + const entry = `${parent}/${path.basename(dest)}` + if ( + wanted === 'file' && + refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === + undefined + ) { + return 'file' + } + if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { + return 'directory' + } + } + // The name may be refused for a reason that passes. The folder is beyond + // this user when it may not even be given the mode it has. + return refusalOf(() => fs.chmodSync(parent, mode & 0o7777)) === undefined + ? wanted + : undefined + } catch { + return wanted + } finally { + if (fd !== undefined) fs.closeSync(fd) + } +} + /** * The part of the mandatory deny set that follows from the cwd alone: the * dangerous files and directories resolved against it, plus `.git/hooks` and @@ -1912,6 +2004,10 @@ async function generateFilesystemArgs( // spellings. Landings and allowed write paths are canonical, so the extra // spelling can only match more of them, never fewer. const denyWriteRawDests = new Map() + // Placeholder dests where no mount point can be made (see mountPointFor). + // Planned like the others, so that their ancestors are pinned and counted + // alike, and left out at emission. + const unmakeableDenyDests = new Set() // The shared empty directory this call's placeholders bind from, resolved at // most once per wrap: resolving again mid-wrap (the cached path having been // tampered with in between) would leave the binds already emitted pointing @@ -2388,10 +2484,8 @@ async function generateFilesystemArgs( // --ro-bind /dev/null hits a char device on the second pass and // bwrap's ensure_file() falls through to creat() on a read-only mount. const seenDenyWrite = new Set() - // Placeholder destination -> the index of its source in denyWriteArgs, so - // a later deny reaching the same destination can upgrade a /dev/null - // placeholder to the directory form in place (see the emission below). - const placeholderSourceArgIndex = new Map() + // Every directory a deny path lies beneath, there or not. + const denyPathAncestors = new Set() // PRE-PASS (order-independent): record the directories the loop below // re-binds read-only, BEFORE any stub decision, so the read-only // conclusion does not depend on where an enclosing directory appears in @@ -2419,6 +2513,9 @@ async function generateFilesystemArgs( if (resolvedPath === null || resolvedPath.startsWith('/dev/')) { continue } + for (const above of properAncestors(resolvedPath)) { + denyPathAncestors.add(above) + } // Same defense-in-depth re-check as the loop: there the --ro-bind is // replaced by a symlink mask, so such a directory must not be recorded // as re-bound here. @@ -2575,6 +2672,87 @@ async function generateFilesystemArgs( } return covered } + // The placeholder dests decided so far. + const decidedMountPoints = new Set() + const decidedMountPointOver = (p: string): string | undefined => + [p, ...properAncestors(p)].find(at => decidedMountPoints.has(at)) + /** + * The placeholder an absent deny path adds to the plan: where, which is + * its first missing component, and the kind of mount point bubblewrap + * makes for it on the host. `kind` is undefined where none can be made, + * and the whole answer where the path adds nothing. + * + * INVARIANT: a placeholder is left out only where the sandboxed command + * cannot create the path either; each branch says why it cannot. On any + * doubt the placeholder stays: at worst bubblewrap cannot make its mount + * point and nothing starts, which loses no deny. + */ + const mountPointFor = ( + absentPath: string, + ): { dest: string; kind: MountPointKind | undefined } | undefined => { + const none = (why: string): undefined => { + logForDebugging( + `[Sandbox Linux] No placeholder for non-existent deny path ${absentPath}: ${why}`, + ) + return undefined + } + // One mount point per destination. Deny paths are deduplicated on the + // deny path, but a placeholder lands on the first MISSING component, so + // two denies can share one: denyWrite '/.claude' together with the + // mandatory '/.claude/commands', in a project with no `.claude/`. + // Asked of the record, not of the host, where the component may be + // there by now (see makeableMountPoint). + const decided = decidedMountPointOver(absentPath) + if (decided !== undefined) return none(`decided with ${decided}`) + + // Read-only in the sandbox, where bwrap could not creat() a mount point + // either. No allowed write path holds it, which leaves it to the initial + // --ro-bind / /, or only covered ones do (see uncoveredWritePaths); or a + // recorded deny directory covers it and survives the + // coveringDirIsUnsafe vetoes (see the INVARIANT at its definition). The + // pre-pass has recorded every such directory, wherever it appears in + // denyPaths; recorded directories exist, so one that covers the path is + // at or above its deepest existing ancestor. + if ( + !uncoveredWritePaths.some(allowedPath => + isAtOrUnder(absentPath, allowedPath), + ) || + coveredBySafeReadOnlyDenyDir(absentPath) + ) { + return none('it is read-only there') + } + // Nothing can be created under a file: a git worktree's .git is one. + // Asked of what lies above the path, not of the path: another sandbox's + // bubblewrap may have made a mount point at it since it was found + // absent, and a file there is no reason to leave it unbound. + if (hasFileAncestor(path.dirname(absentPath))) { + return none('a file lies above it') + } + + // A component with a deny path beneath it is covered by an empty + // read-only directory, which blocks creating it and everything below it + // exactly as /dev/null does and stays traversable; as a file it breaks + // tools that expect a directory there. Whatever the order of denyPaths. + const dest = findFirstNonExistentComponent(absentPath) + decidedMountPoints.add(dest) + const kind = makeableMountPoint( + dest, + denyPathAncestors.has(dest) ? 'directory' : 'file', + ) + if (kind === undefined) { + // The directories above it are pinned all the same: a command that + // may write one of THEM could otherwise move the refusing one aside. + unmakeableDenyDests.add(dest) + } else { + // INVARIANT: only a path found absent here is tracked, for + // cleanupBwrapMountPoints() to remove. An existing file that looks + // like a mount point may be the caller's own, or that of another + // process's sandbox still running, whose deny goes with it. + bwrapMountPoints.add(dest) + registerExitCleanupHandler() + } + return { dest, kind } + } for (const [index, pathPattern] of denyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length @@ -2654,120 +2832,31 @@ async function generateFilesystemArgs( // Handle non-existent paths by mounting /dev/null to block creation. // Without this, a sandboxed process could mkdir+write a denied path that // doesn't exist yet, bypassing the deny rule entirely. - // - // bwrap creates empty files on the host as mount points for these binds. - // We track them in bwrapMountPoints so cleanupBwrapMountPoints() can - // remove them after the command exits. - // - // INVARIANT: only a path found absent here is tracked. An existing file - // that looks like such a mount point may be the caller's own, or that of - // another process's sandbox still running, whose deny goes with it. - if (!fs.existsSync(normalizedPath)) { - // Fix 1 (worktree): If any existing component above the deny path is - // a file (not a directory), skip the deny entirely. You can't mkdir - // under a file, so the deny path can never be created. This handles - // git worktrees where .git is a file. Asked of what lies above the - // path, not of the path: another sandbox's bubblewrap may have made a - // mount point at it since the look above, and a file there is no - // reason to leave it unbound. - if (hasFileAncestor(path.dirname(normalizedPath))) { - logForDebugging( - `[Sandbox Linux] Skipping deny path with file ancestor (cannot create paths under a file): ${normalizedPath}`, - ) - continue - } - - // Find the deepest existing ancestor directory - let ancestorPath = path.dirname(normalizedPath) - while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) { - ancestorPath = path.dirname(ancestorPath) - } - - // Only protect if the existing ancestor is within an allowed write path. - // If not, the path is already read-only from --ro-bind / /. - // (Equality on the absent normalizedPath itself is unreachable, and - // so is an allow entry beneath it — allow entries exist, the deny - // path does not.) - const ancestorIsWithinAllowedPath = - isWithinAnyAllowedWritePath(ancestorPath) || - isWithinAnyAllowedWritePath(normalizedPath) - - // An ancestor inside a directory that an earlier deny re-bound - // read-only (e.g. an explicit denyWrite on the project dir) is - // already read-only in the sandbox: the deny path cannot be created - // there, and stubbing it would make bwrap creat() a mount point - // inside that read-only mount and abort. The order-independent - // pre-pass above has already recorded every directory the loop - // re-binds read-only, so a covering directory deny is visible here - // regardless of where it appears in denyPaths. A recorded covering - // directory is evidence for skipping only if it survives the - // coveringDirIsUnsafe vetoes (see the INVARIANT at its definition). - // (Tested on the absent path itself: a recorded directory that - // covers it is at-or-above its deepest existing ancestor, since - // recorded directories exist.) So is a path that only covered write - // paths hold (see uncoveredWritePaths). - const ancestorIsWithinReadOnlyDeny = - coveredBySafeReadOnlyDenyDir(normalizedPath) || - !uncoveredWritePaths.some(allowedPath => - isAtOrUnder(normalizedPath, allowedPath), - ) - - if (ancestorIsWithinAllowedPath && !ancestorIsWithinReadOnlyDeny) { - const firstNonExistent = findFirstNonExistentComponent(normalizedPath) - - // Fix 2: If firstNonExistent is an intermediate component (not the - // leaf deny path itself), mount a read-only empty directory instead - // of /dev/null. This prevents the component from appearing as a file - // which breaks tools that expect to traverse it as a directory. - const isIntermediate = firstNonExistent !== normalizedPath - const source = isIntermediate - ? (emptySource ??= ensureEmptyMountSourceDir()) - : '/dev/null' - - // One mount point per destination. Deny paths are deduplicated on - // the deny path, but a placeholder lands on the first MISSING - // component, so two denies sharing one arrive here with a single - // destination — denyWrite '/.claude' together with the - // mandatory '/.claude/commands', in a project with no - // `.claude/`. Two binds there make bwrap refuse to start when they - // disagree about the destination's kind ("Can't mkdir : Not a - // directory"). The directory form wins the disagreement: an empty - // read-only directory blocks creating the destination and everything - // below it exactly as /dev/null does, and stays traversable for the - // deeper deny that asked for a directory. - const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent) - if (placeholderAt !== undefined) { - if (isIntermediate) denyWriteArgs[placeholderAt] = source - logForDebugging( - `[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`, - ) - continue - } - denyWriteArgs.push('--ro-bind', source, firstNonExistent) - placeholderSourceArgIndex.set( - firstNonExistent, - denyWriteArgs.length - 2, + if ( + decidedMountPointOver(normalizedPath) !== undefined || + !fs.existsSync(normalizedPath) + ) { + const placeholder = mountPointFor(normalizedPath) + if (placeholder !== undefined) { + const { dest, kind } = placeholder + denyWriteArgs.push( + '--ro-bind', + kind === 'directory' + ? (emptySource ??= ensureEmptyMountSourceDir()) + : '/dev/null', + dest, ) - // First writer wins for a destination several denies share (the - // reuse branch above returns before reaching this), and the record - // is purely additive: it only gives the tmpfs and mask comparisons + // The first of the denies that share the destination. The record is + // purely additive: it only gives the tmpfs and mask comparisons // below a second spelling to test, and `dest` itself is always // tested. - denyWriteRawDests.set(firstNonExistent, rawPath) - bwrapMountPoints.add(firstNonExistent) - registerExitCleanupHandler() - logForDebugging( - `[Sandbox Linux] Mounted ${ - isIntermediate ? 'empty dir' : '/dev/null' - } at ${firstNonExistent} to block creation of ${normalizedPath}`, - ) - } else if (ancestorIsWithinReadOnlyDeny) { - logForDebugging( - `[Sandbox Linux] Skipping non-existent deny path inside a read-only denied directory (already uncreatable): ${normalizedPath}`, - ) - } else { + denyWriteRawDests.set(dest, rawPath) logForDebugging( - `[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`, + kind === undefined + ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` + : `[Sandbox Linux] Mounted ${ + kind === 'directory' ? 'empty dir' : '/dev/null' + } at ${dest} to block creation of ${normalizedPath}`, ) } continue @@ -3099,6 +3188,7 @@ async function generateFilesystemArgs( const restoredReadOnlyWritePaths = new Set() for (let i = 0; i < denyWriteArgs.length; i += 3) { const dest = denyWriteArgs[i + 2]! + if (unmakeableDenyDests.has(dest)) continue const rawDest = denyWriteRawDests.get(dest) ?? dest // A mask's landing, not its dest: the landing is where the mask's bind // actually sits, and this deny's dest is canonical, so the two are diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index a170d0e9a..56a77854a 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -1774,6 +1774,9 @@ function windowsPathKey(p: string): string { let resolved = p if (!isUncPath(p)) { try { + // The final-path lookup itself, which also resolves 8.3 names and the + // case on disk. + // eslint-disable-next-line no-restricted-properties resolved = fs.realpathSync.native(p) } catch { // Not there, so nothing lies beneath it either. diff --git a/test/sandbox/linux-ancestor-pin.test.ts b/test/sandbox/linux-ancestor-pin.test.ts index d33c91d7a..300a21e3d 100644 --- a/test/sandbox/linux-ancestor-pin.test.ts +++ b/test/sandbox/linux-ancestor-pin.test.ts @@ -1509,11 +1509,11 @@ describe.if(isLinux)('Linux sandbox — denyWrite ancestor pinning', () => { // The WORKDIR /app shape: a protected path one level below '/' has no // ancestor to pin, and the cover is the only thing that keeps its // top-level directory a mountpoint. /usr holds no other protected path, - // so the cover can only come from this deny. + // so the cover can only come from this deny, whether or not this user can + // have a mount point made in /usr. const absent = '/usr/srt-ancestor-pin-probe' const command = await wrap({ allowWrite: ['/'], denyWrite: [absent] }) - expect(command).toContain(`--ro-bind /dev/null ${absent}`) expect(command).toContain('--bind /usr /usr') expect(command).not.toContain('--ro-bind /usr /usr') }) @@ -1529,8 +1529,6 @@ describe.if(isLinux)('Linux sandbox — denyWrite ancestor pinning', () => { denyWrite: [`/proc/${probe}`, `/sys/${probe}`, `/usr/${probe}`], }) - expect(command).toContain(`--ro-bind /dev/null /proc/${probe}`) - expect(command).toContain(`--ro-bind /dev/null /sys/${probe}`) expect(command).toContain('--bind /usr /usr') expect(command).not.toContain('--bind /proc /proc') expect(command).not.toContain('--bind /sys /sys') diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts new file mode 100644 index 000000000..e6a6c40ba --- /dev/null +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -0,0 +1,731 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawnSync } from 'node:child_process' +import { basename, dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' +import { countMounts, mountsOf } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' + +const REPO_ROOT = join(import.meta.dir, '../..') +const V9FS_MAGIC = 0x01021997 + +// The originals, for the stand-ins below to fall through to. +const real = { + accessSync: fs.accessSync, + chmodSync: fs.chmodSync, + closeSync: fs.closeSync, + fstatSync: fs.fstatSync, + mkdirSync: fs.mkdirSync, + openSync: fs.openSync, + readlinkSync: fs.readlinkSync, + statfsSync: fs.statfsSync, +} +type Stubbed = keyof typeof real + +const errno = (code: string): Error => + Object.assign(new Error(`${code}: injected`), { code }) + +/** Whether `spelling` is `target` as the wrap names it: by a descriptor on + * its directory, or on itself. */ +function names(spelling: unknown, target: string): boolean { + const viaFd = (p: string, dir: string): boolean => + /^\/proc\/self\/fd\/\d+$/.test(p) && real.readlinkSync(p) === dir + return ( + typeof spelling === 'string' && + (viaFd(spelling, target) || + (basename(spelling) === basename(target) && + viaFd(dirname(spelling), dirname(target)))) + ) +} + +/** + * An absent deny path is blocked by a placeholder, whose mount point + * bubblewrap makes on the host, as this user, before the command runs. Where + * neither it nor the command can create the path there is none to make, and + * the sandbox starts without. + */ +describe.if(isLinux)('A placeholder where no mount point can be made', () => { + let BASE: string + let ROOT: string // the allowed write path + let DIR: string // a directory in it, holding the absent deny paths + const savedCwd = process.cwd() + const spies: Array<{ mockRestore(): void }> = [] + + const CAN_RUN = bwrapCanNamespace() + // Directories of another's that this user cannot write: not root's view. + const UNPRIVILEGED = ['/usr', '/etc'].every(dir => { + try { + fs.accessSync(dir, fs.constants.W_OK) + return false + } catch { + return fs.statSync(dir).uid !== process.geteuid?.() + } + }) + + beforeEach(() => { + BASE = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'no-mount-point-'))) + ROOT = join(BASE, 'root') + DIR = join(ROOT, 'dir') + fs.mkdirSync(DIR, { recursive: true }) + // Outside the allowed write path, so that the mandatory denies add no + // placeholders of their own. + process.chdir(BASE) + }) + + afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore() + standIns.clear() + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + fs.rmSync(BASE, { recursive: true, force: true }) + }) + + /** Stands in for fs[name] until the test ends. A stand-in gets the + * arguments, and answers `undefined` to leave the call to the next one, the + * original being the last. */ + type StandIn = (...args: unknown[]) => { value: unknown } | undefined + const standIns = new Map() + function stub(name: Stubbed, standIn: StandIn): void { + const sofar = standIns.get(name) + if (sofar !== undefined) { + sofar.push(standIn) + return + } + const all = [standIn] + standIns.set(name, all) + const original = real[name] as (...args: unknown[]) => unknown + spies.push( + spyOn(fs, name).mockImplementation(((...args: unknown[]) => { + for (const each of all) { + const stood = each(...args) + if (stood !== undefined) return stood.value + } + return original(...args) + }) as never), + ) + } + /** fs[name] fails with `code` when asked of `target`. */ + const refuse = (name: Stubbed, target: string, code = 'EACCES'): void => + stub(name, spelling => { + if (names(spelling, target)) throw errno(code) + return undefined + }) + /** `directory` belongs to somebody else. */ + const ownedByAnother = (directory: string): void => { + const { ino } = fs.statSync(directory) + stub('fstatSync', fd => { + const stats = real.fstatSync(fd as number) + if (stats.ino !== ino) return undefined + return { value: Object.assign(stats, { uid: stats.uid + 1 }) } + }) + } + /** Everything is on a Windows drive under WSL 2. */ + const onWindowsDrive = (): void => + stub('statfsSync', p => ({ + value: { ...real.statfsSync(p as string), type: V9FS_MAGIC }, + })) + + const wrap = ( + denyWithinAllow: string[], + command = 'true', + allowOnly = [ROOT], + ): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + writeConfig: { allowOnly, denyWithinAllow }, + }) + + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 15000 }) + + /** The mounts that land at `dest`. */ + const mountsAt = (command: string, dest: string): string[] => + mountsOf(command).filter(mount => mount.endsWith(` ${dest}`)) + const emptyDirectoryAt = (dest: string) => + expect.stringMatching( + new RegExp(`^--ro-bind \\S*claude-empty-\\S+ ${dest}$`), + ) + + describe('by what the parent directory answers', () => { + // What access() says of the parent, whose it is, and whether the + // placeholder is planned. + const ANSWERS: [string, string | undefined, boolean, boolean][] = [ + ['writable', undefined, true, true], + ['writable, and of another', undefined, false, true], + ['unwritable by its mode, and of this user', 'EACCES', true, true], + ['unwritable by its mode, and of another', 'EACCES', false, false], + ['on a read-only file system, and of this user', 'EROFS', true, false], + ['on a read-only file system, and of another', 'EROFS', false, false], + ['immutable, and of this user', 'EPERM', true, false], + ['failing to answer, and of another', 'EIO', false, true], + ] + + it.each(ANSWERS)( + 'plans for a parent that is %s', + async (_name, code, own, planned) => { + if (code !== undefined) refuse('accessSync', DIR, code) + if (!own) ownedByAnother(DIR) + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const command = await wrap([leaf, nested]) + + expect(mountsAt(command, leaf)).toEqual( + planned ? [`--ro-bind /dev/null ${leaf}`] : [], + ) + expect(mountsAt(command, join(DIR, 'absent'))).toEqual( + planned ? [emptyDirectoryAt(join(DIR, 'absent'))] : [], + ) + // The parent is pinned either way, beneath the allow bind. + expect(mountsOf(command).indexOf(`--ro-bind ${DIR} ${DIR}`)).toBe(1) + // And the question is asked without a trace. + expect(fs.readdirSync(DIR)).toEqual([]) + }, + ) + + it.skipIf(!CAN_RUN)( + 'keeps a parent that takes no mount point from being moved aside', + async () => { + refuse('accessSync', DIR) + ownedByAnother(DIR) + const aside = join(ROOT, 'aside') + + // In truth DIR is this user's, in a directory it may write: only the + // pin stands in the way. + const result = run( + await wrap( + [join(DIR, 'policy.json')], + `echo BOOTED; mv ${DIR} ${aside}; rmdir ${DIR}; echo x > ${join(ROOT, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(result.stderr).toMatch(/mv: .*busy/) + expect(result.stderr).toMatch(/rmdir: .*busy/) + expect(fs.readdirSync(ROOT).sort()).toEqual(['dir', 'new']) + }, + ) + + it('takes nothing that turns up at the path for its own', async () => { + refuse('accessSync', DIR) + ownedByAnother(DIR) + const denied = join(DIR, 'policy.json') + + await wrap([denied]) + fs.writeFileSync(denied, '') + cleanupBwrapMountPoints() + + expect(fs.existsSync(denied)).toBe(true) + }) + + it('asks for write and search together', async () => { + const asked: unknown[] = [] + stub('accessSync', (spelling, mode) => { + if (names(spelling, DIR)) asked.push(mode) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + + expect(asked).toEqual([fs.constants.W_OK | fs.constants.X_OK]) + }) + }) + + describe('on any doubt', () => { + beforeEach(() => { + // Every answer below would leave the placeholder out. + refuse('accessSync', DIR) + ownedByAnother(DIR) + }) + + const planned = async (): Promise => { + const denied = join(DIR, 'policy.json') + expect(mountsAt(await wrap([denied]), denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + } + + it('leaves it out when there is none', async () => { + const denied = join(DIR, 'policy.json') + expect(mountsAt(await wrap([denied]), denied)).toEqual([]) + }) + + it('plans it when the parent cannot be opened', async () => { + stub('openSync', p => { + if (p === DIR) throw errno('EMFILE') + return undefined + }) + await planned() + }) + + it('plans it when /proc does not say where the descriptor leads', async () => { + stub('readlinkSync', p => { + if (names(p, DIR)) throw errno('ENOENT') + return undefined + }) + await planned() + }) + + it('plans it when the file system cannot be told', async () => { + refuse('statfsSync', DIR, 'ENOSYS') + await planned() + }) + + it('closes the descriptor whatever the answer', async () => { + const open: unknown[] = [] + stub('openSync', (p, flags) => { + if (p !== DIR) return undefined + open.push(real.openSync(DIR, flags as number)) + return { value: open.at(-1) } + }) + stub('closeSync', fd => { + if (open.includes(fd)) open.splice(open.indexOf(fd), 1) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + refuse('statfsSync', DIR, 'ENOSYS') + await wrap([join(DIR, 'other.json')]) + + expect(open).toEqual([]) + }) + }) + + // A command that is still running may write ROOT, and can put something + // else at a path in it at any moment. /usr stands for a directory that + // answers "no mount point": it is another's, and this user cannot write it. + describe.if(UNPRIVILEGED)('with the parent swapped under the wrap', () => { + const swapOnce = ( + name: Stubbed, + when: (p: unknown) => boolean, + swap: () => void, + ) => { + let done = false + stub(name, p => { + if (!done && when(p)) { + done = true + swap() + } + return undefined + }) + } + + it('plans the placeholder when the parent is a symlink as it is opened', async () => { + swapOnce( + 'openSync', + p => p === DIR, + () => { + fs.rmdirSync(DIR) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + + it('plans it when a directory above the parent is a symlink as it is opened', async () => { + const parent = join(DIR, 'share') + fs.mkdirSync(parent) + swapOnce( + 'openSync', + p => p === parent, + () => { + fs.renameSync(DIR, join(ROOT, 'aside')) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(parent, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + + it('plans it when the parent is swapped once it is open', async () => { + swapOnce( + 'fstatSync', + () => true, + () => { + fs.renameSync(DIR, join(ROOT, 'aside')) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + }) + + // So that it is the directory that was opened that answers, whatever the + // path names by then. + it.each([ + ['off a Windows drive', false], + ['on a Windows drive', true], + ])( + 'asks everything of one descriptor on the parent, %s', + async (_, drive) => { + const opened: unknown[][] = [] + const asked: string[] = [] + const note = (p: unknown): void => { + // From the open to the close. + if (opened.length === 1) asked.push(String(p).replace(/\d+/, 'N')) + } + stub('openSync', (p, flags) => { + if (p === DIR) opened.push([p, flags]) + else note(p) + // Refused, like mkdir below, so that the questions go on to the last. + if (names(p, join(DIR, 'policy.json'))) throw errno('EACCES') + return undefined + }) + for (const name of [ + 'readlinkSync', + 'accessSync', + 'statfsSync', + 'mkdirSync', + 'chmodSync', + ] as const) { + stub(name, p => { + note(p) + if (name === 'mkdirSync') throw errno('EACCES') + return undefined + }) + } + if (drive) onWindowsDrive() + stub('closeSync', () => { + opened.push([]) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + + expect(opened[0]).toEqual([ + DIR, + 0o10000000 | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, + ]) + const parent = '/proc/self/fd/N' + expect(asked).toEqual([ + ...[parent, parent, parent], + ...(drive + ? [`${parent}/policy.json`, `${parent}/policy.json`, parent] + : []), + ]) + }, + ) + + describe('on a Windows drive under WSL 2', () => { + beforeEach(onWindowsDrive) + + const listing = () => fs.readdirSync(DIR).sort() + + it('makes the mount point itself, and the clean-up removes it', async () => { + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const command = await wrap([leaf, nested]) + + expect(mountsAt(command, leaf)).toEqual([`--ro-bind /dev/null ${leaf}`]) + expect(mountsAt(command, join(DIR, 'absent'))).toEqual([ + emptyDirectoryAt(join(DIR, 'absent')), + ]) + expect(listing()).toEqual(['absent', 'policy.json']) + expect(fs.statSync(leaf).size).toBe(0) + expect(fs.statSync(leaf).mode & 0o777).toBe(0o444) + expect(fs.readdirSync(join(DIR, 'absent'))).toEqual([]) + cleanupBwrapMountPoints() + expect(listing()).toEqual([]) + }) + + it('covers a file with a directory in a folder that takes subfolders and no files', async () => { + const denied = join(DIR, 'policy.json') + refuse('openSync', denied) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toEqual([emptyDirectoryAt(denied)]) + expect(fs.statSync(denied).isDirectory()).toBe(true) + cleanupBwrapMountPoints() + expect(listing()).toEqual([]) + }) + + it.each([ + ['a file', 'policy.json', 'policy.json'], + ['a directory', 'absent/policy.json', 'absent'], + ])( + 'leaves %s out in a folder that takes nothing and is beyond this user', + async (_kind, denied, dest) => { + refuse('openSync', join(DIR, dest)) + refuse('mkdirSync', join(DIR, dest), 'EPERM') + refuse('chmodSync', DIR) + + const command = await wrap([join(DIR, denied)]) + + expect(mountsAt(command, join(DIR, dest))).toEqual([]) + expect(countMounts(command, '--ro-bind', DIR, DIR)).toBe(1) + expect(listing()).toEqual([]) + }, + ) + + it('plans the kind asked for when the name is refused in a folder this user governs', async () => { + const denied = join(DIR, 'policy.json') + fs.chmodSync(DIR, 0o2750) + refuse('openSync', denied) + refuse('mkdirSync', denied) + const modes: unknown[] = [] + stub('chmodSync', (p, mode) => { + if (names(p, DIR)) modes.push(mode) + return undefined + }) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + // The mode it has, so that nothing changes. + expect(modes).toEqual([0o2750]) + }) + + it.each([ + ['plans it', 'governs', true], + ['leaves it out', 'does not govern', false], + ])( + '%s, and tries nothing, where access() refuses a folder this user %s', + async (_verdict, _governs, planned) => { + const denied = join(DIR, 'policy.json') + refuse('accessSync', DIR) + if (!planned) refuse('chmodSync', DIR, 'EROFS') + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toHaveLength(planned ? 1 : 0) + expect(listing()).toEqual([]) + }, + ) + + it('tries no file where a directory is wanted', async () => { + const tried: unknown[] = [] + stub('openSync', p => { + if (names(p, join(DIR, 'absent'))) tried.push(p) + return undefined + }) + refuse('mkdirSync', join(DIR, 'absent')) + refuse('chmodSync', DIR) + + await wrap([join(DIR, 'absent', 'policy.json')]) + + expect(tried).toEqual([]) + expect(listing()).toEqual([]) + }) + + it('plans it, and takes nothing for its own, when the path is there by the time it is tried', async () => { + const denied = join(DIR, 'policy.json') + stub('openSync', p => { + if (names(p, denied)) fs.writeFileSync(denied, 'theirs') + return undefined + }) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toHaveLength(1) + cleanupBwrapMountPoints() + expect(fs.readFileSync(denied, 'utf8')).toBe('theirs') + }) + + // What one deny path's mount point puts on the host, the next is not + // misled by: the plan is the same in either order, and as off the drive. + describe.each([ + ['a missing folder', ['absent/policy.json', 'absent/policy.d/x']], + ['a path and one beneath it', ['absent', 'absent/commands']], + ])('with deny paths that share %s', (_name, denied) => { + it.each([ + ['as listed', (list: string[]) => list], + ['reversed', (list: string[]) => [...list].reverse()], + ])('plans one directory, %s', async (_order, ordered) => { + const command = await wrap(ordered(denied.map(d => join(DIR, d)))) + + expect(mountsOf(command).filter(m => m.includes(`${DIR}/`))).toEqual([ + emptyDirectoryAt(join(DIR, 'absent')), + ]) + expect(listing()).toEqual(['absent']) + expect(fs.readdirSync(join(DIR, 'absent'))).toEqual([]) + }) + }) + + it('removes what it made when the wrap then fails', async () => { + const wrapped = wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [ROOT], + denyWithinAllow: [join(DIR, 'policy.json')], + }, + binShell: 'no-such-shell', + }) + + expect(wrapped).rejects.toThrow('no-such-shell') + await wrapped.catch(() => {}) + expect(listing()).toEqual([]) + }) + + it.skipIf(!CAN_RUN)( + 'runs on the mount points it made, and the paths cannot be created', + async () => { + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const result = run( + await wrap( + [leaf, nested], + `echo BOOTED; echo x > ${leaf}; echo x > ${nested}; echo x > ${join(DIR, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(result.stderr).not.toContain('bwrap:') + cleanupBwrapMountPoints() + expect(listing()).toEqual(['new']) + }, + ) + }) + + it('asks nothing of the host for a path that needs no placeholder', async () => { + const opened: unknown[] = [] + stub('openSync', p => { + opened.push(p) + return undefined + }) + fs.writeFileSync(join(ROOT, 'file'), '') + + await wrap( + [ + ROOT, // covers DIR, the one allowed write path + join(DIR, 'covered.json'), + join(BASE, 'outside.json'), + ], + 'true', + [DIR], + ) + + expect(opened).toEqual([]) + }) + + it('asks once for deny paths that share a missing directory', async () => { + const opened: unknown[] = [] + stub('openSync', p => { + opened.push(p) + return undefined + }) + + await wrap([join(DIR, 'absent', 'a'), join(DIR, 'absent', 'b', 'c')]) + + expect(opened).toEqual([DIR]) + }) + + describe.if(UNPRIVILEGED)('in directories of another user', () => { + const PROBE = 'srt-no-mount-point-probe' + const denied = [`/usr/${PROBE}/policy.json`, `/etc/${PROBE}`, `/${PROBE}`] + const dests = [`/usr/${PROBE}`, `/etc/${PROBE}`, `/${PROBE}`] + + it('plans no placeholder, and keeps their top-level directories mount points', async () => { + const command = await wrap(denied, 'true', ['/']) + + for (const dest of dests) expect(mountsAt(command, dest)).toEqual([]) + expect(countMounts(command, '--bind', '/usr', '/usr')).toBe(1) + expect(countMounts(command, '--bind', '/etc', '/etc')).toBe(1) + }) + + it.skipIf(!CAN_RUN).each([ + ['"/"', ['/']], + ['the directories themselves', ['/usr', '/etc']], + ])( + 'starts with %s allowed, and the paths cannot be created', + async (_name, allowOnly) => { + const result = run( + await wrap( + denied, + `echo BOOTED; mkdir ${dests[0]}; echo x > ${dests[1]}; echo x > ${dests[2]}; mv /usr/share /usr/aside; echo x > ${join(DIR, 'new')}`, + [...allowOnly, ROOT], + ), + ) + + expect(result.stderr).not.toContain('bwrap:') + expect(result.stdout).toBe('BOOTED\n') + expect(dests.filter(dest => fs.existsSync(dest))).toEqual([]) + expect(fs.readdirSync(DIR)).toEqual(['new']) + }, + ) + }) + + describe('in a directory of this user that its mode closes', () => { + beforeEach(() => fs.chmodSync(DIR, 0o555)) + afterEach(() => fs.chmodSync(DIR, 0o755)) + + it.skipIf(process.geteuid?.() === 0)('plans the placeholder', async () => { + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + }) + + it.skipIf(!CAN_RUN)( + 'holds the path against a command that opens the directory', + async () => { + const denied = join(DIR, 'policy.json') + + const result = run( + await wrap( + [denied], + `echo BOOTED; chmod 755 ${DIR}; echo x > ${denied}; echo x > ${join(DIR, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(fs.readFileSync(denied, 'utf8')).toBe('') + expect(fs.existsSync(join(DIR, 'new'))).toBe(true) + }, + ) + }) + + // A read-only file system that this user owns everything on: a sandbox + // around the wrap and its own, with DIR bound read-only. + it.skipIf(!CAN_RUN)( + 'starts with an absent deny path on a read-only file system', + () => { + const denied = join(DIR, 'policy.json') + const script = ` + const { wrapCommandWithSandboxLinux } = await import('./src/sandbox/linux-sandbox-utils.ts') + const { spawnSync } = await import('node:child_process') + const command = await wrapCommandWithSandboxLinux({ + command: 'echo BOOTED; echo x > ${denied}; echo x > ${join(ROOT, 'new')}', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['${ROOT}'], denyWithinAllow: ['${denied}'] }, + }) + const { stdout, stderr } = spawnSync(command, { shell: true, encoding: 'utf8' }) + console.log(JSON.stringify({ command, stdout, stderr }))` + const outer = spawnSync( + 'bwrap', + [ + ...['--unshare-user', '--bind', '/', '/', '--ro-bind', DIR, DIR], + ...['--dev', '/dev', '--proc', '/proc', '--chdir', REPO_ROOT], + ...[process.execPath, '-e', script], + ], + { encoding: 'utf8', timeout: 60_000 }, + ) + const { command, stdout, stderr } = JSON.parse( + outer.stdout.trim().split('\n').at(-1) ?? '{}', + ) as { command: string; stdout: string; stderr: string } + + expect(stderr).not.toContain('bwrap:') + expect(stdout).toBe('BOOTED\n') + expect(mountsAt(command, denied)).toEqual([]) + expect(fs.readdirSync(DIR)).toEqual([]) + expect(fs.readdirSync(ROOT).sort()).toEqual(['dir', 'new']) + }, + ) +})