diff --git a/README.md b/README.md index 14d896861..6765ba410 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,9 @@ child.on('exit', async code => { }) ``` -**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you *execute* is not the command the invocation *represents* (e.g. you wrap an assembled `source && eval ''`), also pass `commandText: ''`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`. +**Spawning without a shell (`wrapWithSandboxArgv`).** `SandboxManager.wrapWithSandboxArgv(command)` returns `{ argv, env }` for `spawn(argv[0], argv.slice(1), { shell: false, env })`. On Linux `argv` is the `bwrap` invocation itself, one element per option word, so a large mount profile is never squeezed into a single `sh -c` argument: Linux rejects any single argument over `MAX_ARG_STRLEN` (128 KiB on 4 KiB-page kernels) with `E2BIG`, which the string form can hit under a broad `denyRead`. Prefer it on Linux; the `srt` CLI uses it on every platform, and the string form logs a `[sandbox-runtime] WARNING` (with a per-mount-type breakdown) when its rendered line would exceed that cap. When the effective Linux config needs no sandbox at all, `argv` is `[shell, '-c', command]`, where `shell` is `binShell` or its `/bin/bash` default. On macOS `argv` is `[shell, '-c', ]`; on Windows `wrapWithSandboxArgv` is the only supported entry point (`wrapWithSandbox` throws there). `describeBwrapArgv(argv)` breaks a vector down by mount type and byte size for diagnostics; for the `[shell, '-c', script]` forms it reports the script as `innerCommandBytes` and files everything under `other`. + +**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you _execute_ is not the command the invocation _represents_ (e.g. you wrap an assembled `source && eval ''`), also pass `commandText: ''`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`. ```typescript const wrapped = await SandboxManager.wrapWithSandbox( @@ -226,7 +228,10 @@ const wrapped = await SandboxManager.wrapWithSandbox( { commandId: invocationId, commandText: rawCommand }, ) // ... run it ... -const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr) +const annotated = SandboxManager.annotateStderrWithSandboxFailures( + invocationId, + stderr, +) ``` #### Available exports @@ -372,10 +377,16 @@ Examples: **Path Syntax (Linux):** -**Linux currently does not support glob matching.** Use literal paths only: +bubblewrap binds concrete paths, so glob support is narrower than on macOS: + +- `allowWrite` / `denyWrite` take literal paths only; a glob pattern there is skipped. +- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the matching entries when the command is wrapped (a file that appears later is not covered). A `denyRead` pattern ending in `/**` becomes one mount per matched directory rather than one per file beneath it: the directory is a tmpfs inside the sandbox, exactly as a literal directory `denyRead` is, so writes into it do not reach the host. An entry reached through a symlink is denied at the path the link resolves to. + +Examples: - `"allowWrite": ["src/"]` - Allow write to `src/` directory - `"denyRead": ["/home/user/.ssh"]` - Deny read to SSH directory +- `"denyRead": ["**/build/**"]` - Deny read to every `build/` directory under the current directory - `"denyRead": ["/home"], "allowRead": ["."]` - Deny read to all of `/home`, but re-allow the current directory **All platforms:** diff --git a/src/cli.ts b/src/cli.ts index 5d65450a0..4a6235e8f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { logForDebugging } from './utils/debug.js' import { loadConfig, loadConfigFromString } from './utils/config-loader.js' import * as readline from 'readline' import * as fs from 'fs' +import * as net from 'net' import * as path from 'path' import * as os from 'os' @@ -36,6 +37,22 @@ function getDefaultConfig(): SandboxRuntimeConfig { } } +/** + * A readable stream over the control fd. A pipe or socket is read through a + * libuv stream handle, driven by the event loop; fs.createReadStream would + * park a threadpool thread in a blocking read(2) that process.exit() then + * waits for, so srt would outlive the wrapped command until the parent + * closed the fd. A regular file has no such wait and keeps the fs stream. + * Either way the fd never keeps srt alive on its own. + */ +function openControlFd(fd: number): NodeJS.ReadableStream { + const stat = fs.fstatSync(fd) + if (stat.isFIFO() || stat.isSocket()) { + return new net.Socket({ fd, readable: true, writable: false }).unref() + } + return fs.createReadStream('', { fd }) +} + async function main(): Promise { const program = new Command() @@ -215,11 +232,8 @@ async function main(): Promise { let controlReader: readline.Interface | null = null if (options.controlFd !== undefined) { try { - const controlStream = fs.createReadStream('', { - fd: options.controlFd, - }) controlReader = readline.createInterface({ - input: controlStream, + input: openControlFd(options.controlFd), crlfDelay: Infinity, }) @@ -285,29 +299,20 @@ async function main(): Promise { ), ) - // Wrap the command with sandbox restrictions. On Windows - // the wrapper returns an argv array that MUST be spawned - // with {shell:false} — that's the boundary keeping the - // command bytes off the host shell. On other platforms - // we keep the existing shell-string path. - let child - if (process.platform === 'win32') { - // env carries the proxy vars the sandboxed child must inherit. - const { argv, env } = - await SandboxManager.wrapWithSandboxArgv(command) - child = spawn(argv[0], argv.slice(1), { - shell: false, - stdio: 'inherit', - env, - }) - } else { - const sandboxedCommand = - await SandboxManager.wrapWithSandbox(command) - child = spawn(sandboxedCommand, { - shell: true, - stdio: 'inherit', - }) - } + // Wrap the command with sandbox restrictions as an argv vector + // spawned with {shell:false}. On Windows that is the boundary + // keeping the command bytes off the host shell; on Linux it is + // the bwrap invocation itself, one element per word, so a large + // mount profile is never squeezed into a single `sh -c` argument + // (the kernel caps each argv element at MAX_ARG_STRLEN). env + // carries the proxy vars the sandboxed child must inherit. + const { argv, env } = + await SandboxManager.wrapWithSandboxArgv(command) + const child = spawn(argv[0], argv.slice(1), { + shell: false, + stdio: 'inherit', + env, + }) // Handle process exit child.on('exit', (code, signal) => { diff --git a/src/index.ts b/src/index.ts index 7e9335f28..6db0339b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,13 @@ export type { // Platform-specific utilities export type { SandboxViolationEvent } from './sandbox/macos-sandbox-utils.js' export { type SandboxDependencyCheck } from './sandbox/linux-sandbox-utils.js' +export { + type BwrapArgvSummary, + type BwrapArgvTerm, + describeBwrapArgv, + describeBwrapStringOverflow, + LINUX_MAX_ARG_STRLEN, +} from './sandbox/bwrap-argv.js' // Windows install/status API export { diff --git a/src/sandbox/bwrap-argv.ts b/src/sandbox/bwrap-argv.ts new file mode 100644 index 000000000..61784ad11 --- /dev/null +++ b/src/sandbox/bwrap-argv.ts @@ -0,0 +1,148 @@ +/** + * Size diagnostics for a bwrap argv, for an embedder that hits E2BIG or wants + * to warn before it does. Pure; never touches the filesystem. + */ + +/** Mount/env categories {@link describeBwrapArgv} breaks a bwrap argv into. */ +export type BwrapArgvTerm = + | 'roBindSelf' + | 'roBindDevNull' + | 'roBindOther' + | 'bind' + | 'tmpfs' + | 'setenv' + | 'other' + +/** + * Size breakdown of a bwrap argv; see {@link describeBwrapArgv}. Every byte + * count is what execve() charges: UTF-8 length + 1 (the NUL) per element. + */ +export interface BwrapArgvSummary { + /** The whole vector. */ + totalBytes: number + /** The largest single element: the number to compare against Linux's + * per-argument MAX_ARG_STRLEN (128 KiB on 4 KiB-page kernels). */ + largestArgBytes: number + /** The inner shell script: the last element after `--`, or after the + * `-c` of the `[shell, '-c', script]` vector wrapWithSandboxArgv returns + * when no sandbox applies; 0 without either trailer. */ + innerCommandBytes: number + /** Per term, its occurrences and the bytes of its words. A mount/env + * option and its operands count once; under `other` every remaining + * element counts on its own. The terms partition the vector. */ + terms: Record +} + +type BwrapOptionSpec = { arity: number; term: BwrapArgvTerm } + +/** + * The bwrap options that get a term of their own: operand count and term. + * Every other `--option` is a bare flag under `other` and its operands are + * re-read as bare words (`--dev /dev` is two `other` elements) — the same + * accounting, unless an operand is itself spelled like one of these options + * or `--`, which nothing this package emits does. A Map, so an operand + * spelled like an Object.prototype member (`constructor`) cannot resolve to + * one. + */ +const BWRAP_OPTIONS: ReadonlyMap = new Map< + string, + BwrapOptionSpec +>([ + ['--ro-bind', { arity: 2, term: 'roBindOther' }], // refined by its operands + ['--bind', { arity: 2, term: 'bind' }], + ['--tmpfs', { arity: 1, term: 'tmpfs' }], + ['--setenv', { arity: 2, term: 'setenv' }], +]) +const BARE_FLAG: BwrapOptionSpec = { arity: 0, term: 'other' } + +/** Linux's per-argument cap (MAX_ARG_STRLEN, 32 pages) on 4 KiB-page kernels. */ +export const LINUX_MAX_ARG_STRLEN = 128 * 1024 + +/** + * Break a bwrap argv (as returned by wrapCommandWithSandboxLinuxArgv) down + * by mount/env term with execve()-style byte accounting. + */ +export function describeBwrapArgv(argv: readonly string[]): BwrapArgvSummary { + const argBytes = (s: string): number => Buffer.byteLength(s, 'utf8') + 1 + const terms: BwrapArgvSummary['terms'] = { + roBindSelf: { count: 0, bytes: 0 }, + roBindDevNull: { count: 0, bytes: 0 }, + roBindOther: { count: 0, bytes: 0 }, + bind: { count: 0, bytes: 0 }, + tmpfs: { count: 0, bytes: 0 }, + setenv: { count: 0, bytes: 0 }, + other: { count: 0, bytes: 0 }, + } + let totalBytes = 0 + let largestArgBytes = 0 + + // Every element is accounted exactly once, so the totals ride along. A + // mount/env option and its operands count once; `other` counts per element. + const account = (term: BwrapArgvTerm, from: number, to: number): void => { + terms[term].count += term === 'other' ? to - from : 1 + for (let k = from; k < to; k++) { + const byteCount = argBytes(argv[k]!) + terms[term].bytes += byteCount + totalBytes += byteCount + if (byteCount > largestArgBytes) largestArgBytes = byteCount + } + } + + let innerCommandBytes = 0 + let i = 0 // argv[0], the executable, falls through to BARE_FLAG + while (i < argv.length) { + const option = argv[i]! + // bwrap has no `-c` option, so one right after argv[0] marks the + // `[shell, '-c', script]` form (no sandbox needed), not a bwrap vector. + if (option === '--' || (i === 1 && option === '-c')) { + // Trailer: shell, '-c', inner script. + account('other', i, argv.length) + if (argv.length - 1 > i) { + innerCommandBytes = argBytes(argv[argv.length - 1]!) + } + break + } + const spec = BWRAP_OPTIONS.get(option) ?? BARE_FLAG + const end = Math.min(i + 1 + spec.arity, argv.length) + let term = spec.term + if (option === '--ro-bind') { + const src = argv[i + 1] + const dest = argv[i + 2] + term = + src === '/dev/null' + ? 'roBindDevNull' + : src !== undefined && src === dest + ? 'roBindSelf' + : 'roBindOther' + } + account(term, i, end) + i = end + } + + return { totalBytes, largestArgBytes, innerCommandBytes, terms } +} + +/** + * The warning to raise when `wrapped` — `argv` rendered for `sh -c` — would + * exceed Linux's per-argument cap as that one argument, or undefined when it + * fits. A warning rather than a refusal: 16 KiB-page kernels allow 512 KiB, + * and the kernel already fails such a spawn loudly (E2BIG); the package's + * job is to say which mounts did it and which output form avoids it. + */ +export function describeBwrapStringOverflow( + argv: readonly string[], + wrapped: string, +): string | undefined { + const bytes = Buffer.byteLength(wrapped, 'utf8') + 1 + if (bytes <= LINUX_MAX_ARG_STRLEN) return undefined + const { terms, innerCommandBytes } = describeBwrapArgv(argv) + const t = (term: BwrapArgvTerm): string => + `${terms[term].count} (${terms[term].bytes} B)` + return ( + `[sandbox-runtime] WARNING: the bwrap command line is ${bytes} bytes as a single sh -c argument, ` + + `over Linux MAX_ARG_STRLEN (${LINUX_MAX_ARG_STRLEN} on 4 KiB-page kernels); spawn will fail with E2BIG. ` + + `/dev/null masks ${t('roBindDevNull')}, tmpfs ${t('tmpfs')}, other ro-binds ${t('roBindOther')}, ` + + `self ro-binds ${t('roBindSelf')}, binds ${t('bind')}, setenv ${t('setenv')}, inner script ${innerCommandBytes} B. ` + + `Use SandboxManager.wrapWithSandboxArgv() (one element per word) or deny enclosing directories instead of file globs.` + ) +} diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e31685cfe..181d468f1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'node:os' import path, { join } from 'node:path' import { ripGrep } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' +import { describeBwrapStringOverflow } from './bwrap-argv.js' import { generateProxyEnvVars, buildPosixGitSafeDirEnv, @@ -18,6 +19,7 @@ import { encodeSandboxedCommand, DANGEROUS_FILES, getDangerousDirectories, + isAtOrUnder, } from './sandbox-utils.js' import type { FsReadRestrictionConfig, @@ -927,7 +929,9 @@ async function generateFilesystemArgs( // path whose deepest existing ancestor lies within one of these is already // uncreatable, and must not get a /dev/null stub: bwrap would have to // creat() the mount point inside that read-only mount and abort ("Can't - // create file at : Read-only file system"). The spellings matter + // create file at : Read-only file system"). An EXISTING deny path + // strictly beneath one is likewise already unwritable and its own + // --ro-bind

is skipped as redundant. The spellings matter // because the emission filter and the denyRead re-application compare raw // spellings as well as the resolved dest, so the stub-skip guard tests a // covering directory in its canonical form AND every recorded spelling. @@ -1005,10 +1009,11 @@ async function generateFilesystemArgs( allowedWritePaths.push(normalizedPath) } - // Inputs for the stub-skip guard's vetoes, computed at most once and only - // when an absent deny path actually has a covering read-only deny dir (an - // uncommon configuration) — ordinary commands skip the extra - // stat/realpath/readdir syscalls entirely. Lazy evaluation also means the + // Inputs for the covering-dir vetoes, computed at most once and only + // when a deny path (absent, or existing and strictly beneath) actually + // has a covering read-only deny dir; a command with no directory deny + // inside its write allowlist skips the extra stat/realpath/readdir + // syscalls entirely. Lazy evaluation also means the // derivation runs from inside the deny loop, AFTER the (unbounded) // mandatory-deny ripgrep await below, keeping the snapshot as close as // possible to the denyRead loop that later acts on the real filesystem. @@ -1024,7 +1029,10 @@ async function generateFilesystemArgs( // domains. // // prospectiveReadDenyTmpfsDirsBothForms: the tmpfs targets the denyRead - // loop below will actually mount, derived the way that loop derives them + // loop below will mount — a superset: the loop also drops a tmpfs + // already hidden by an emitted ancestor tmpfs, and an over-predicted + // tmpfs only makes the vetoes more conservative — derived the way that + // loop derives them // — expand a '/' entry into the root's children (minus proc/dev/sys), add // /etc/ssh/ssh_config.d when present, and keep only entries that exist as // directories (the loop skips absent entries and gives file entries a @@ -1254,6 +1262,20 @@ async function generateFilesystemArgs( // Materialized once: the pre-pass above fully populates the map and the // deny loop never mutates it. const readOnlyDenyDirs = [...readOnlyDenyDirSpellings.keys()] + // Is `candidate` already unwritable in the sandbox: strictly under a + // recorded read-only deny directory that survives every + // coveringDirIsUnsafe veto? Strictly, because a deny equal to a recorded + // directory IS that covering bind and must be emitted (an absent path + // never equals one). Stops at the first vetoed covering directory. + const coveredBySafeReadOnlyDenyDir = (candidate: string): boolean => { + let covered = false + for (const denyDir of readOnlyDenyDirs) { + if (candidate === denyDir || !isAtOrUnder(candidate, denyDir)) continue + if (coveringDirIsUnsafe(denyDir)) return false + covered = true + } + return covered + } for (const pathPattern of denyPaths) { const rawPath = normalizePathForSandbox(pathPattern) @@ -1371,13 +1393,11 @@ async function generateFilesystemArgs( // 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). - const coveringReadOnlyDenyDirs = readOnlyDenyDirs.filter( - denyDir => - ancestorPath === denyDir || ancestorPath.startsWith(denyDir + '/'), - ) + // (Tested on the absent path itself: a recorded directory that + // covers it is at-or-above its deepest existing ancestor, since + // recorded directories exist.) const ancestorIsWithinReadOnlyDeny = - coveringReadOnlyDenyDirs.length > 0 && - !coveringReadOnlyDenyDirs.some(coveringDirIsUnsafe) + coveredBySafeReadOnlyDenyDir(normalizedPath) if (ancestorIsWithinAllowedPath && !ancestorIsWithinReadOnlyDeny) { const firstNonExistent = findFirstNonExistentComponent(normalizedPath) @@ -1423,6 +1443,20 @@ async function generateFilesystemArgs( const isWithinAllowedPath = isWithinAnyAllowedWritePath(normalizedPath) if (isWithinAllowedPath) { + // Already unwritable under a read-only denied directory (the + // existing-path twin of the stub skip above). Veto (iii) keeps the + // covering bind through the emission filter; a symlinked spelling + // keeps its own bind because the re-application passes below key + // off emitted raw spellings. + if ( + rawPath === normalizedPath && + coveredBySafeReadOnlyDenyDir(normalizedPath) + ) { + logForDebugging( + `[Sandbox Linux] Skipping deny path already under read-only denied directory: ${normalizedPath}`, + ) + continue + } denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } else { @@ -1489,6 +1523,25 @@ async function generateFilesystemArgs( .map(p => normalizePathForSandbox(p)) .sort((a, b) => a.split('/').length - b.split('/').length) + // A read-deny dest at-or-under a tmpfs this loop already emitted (the + // shallow-first order above visits the covering directory first) is + // hidden by it unless an allowRead/allowWrite path at-or-under that tmpfs + // and at-or-above the dest is re-bound over it by pushReadDenyDirMounts. + // A mount there would be created inside the tmpfs and change nothing, and + // overlapping entries (a directory plus a glob beneath it) would otherwise + // cost one bwrap mount per file — enough to breach the kernel's + // argument-size limits. The same question isHiddenByTmpfs below asks of + // the buffered denyWrite binds. + const readDenyReExposers = [...allowedWritePaths, ...readAllowPaths] + const hiddenByEmittedTmpfs = (dest: string): boolean => + tmpfsDirs.some( + tmpfsDir => + isAtOrUnder(dest, tmpfsDir) && + !readDenyReExposers.some( + p => isAtOrUnder(p, tmpfsDir) && isAtOrUnder(dest, p), + ), + ) + for (const normalizedPath of normalizedDenyPaths) { if (!fs.existsSync(normalizedPath)) { logForDebugging( @@ -1499,6 +1552,12 @@ async function generateFilesystemArgs( const readDenyStat = fs.statSync(normalizedPath) if (readDenyStat.isDirectory()) { + if (hiddenByEmittedTmpfs(normalizedPath)) { + logForDebugging( + `[Sandbox Linux] Skipping read deny directory already hidden by a denyRead tmpfs: ${normalizedPath}`, + ) + continue + } tmpfsDirs.push(normalizedPath) pushReadDenyDirMounts( args, @@ -1520,6 +1579,12 @@ async function generateFilesystemArgs( // For files, bind /dev/null instead of tmpfs. bwrap rejects symlink // bind destinations, so the deny bind lands on the resolved target. const denyDest = resolveSymlinkDenyDest(normalizedPath) + if (hiddenByEmittedTmpfs(denyDest)) { + logForDebugging( + `[Sandbox Linux] Skipping read deny file already hidden by a denyRead tmpfs: ${denyDest}`, + ) + continue + } args.push('--ro-bind', '/dev/null', denyDest) maskedFiles.set(denyDest, '/dev/null') maskedFiles.set(normalizedPath, '/dev/null') @@ -1682,10 +1747,18 @@ async function generateFilesystemArgs( * - To use sandboxing without Unix socket blocking on unsupported architectures, * set allowAllUnixSockets: true in your configuration * Dependencies are checked by checkLinuxDependencies() before enabling the sandbox. + * + * RETURN SHAPE: + * The bwrap invocation as an argv vector, `[bwrap, ...options, '--', shell, + * '-c', innerScript]`, for `spawn(argv[0], argv.slice(1), {shell: false})`; + * `null` when the params call for no sandboxing (run `command` as-is). The + * vector matters because Linux caps each argv element at MAX_ARG_STRLEN + * (128 KiB on 4 KiB-page kernels): the string form puts the whole mount + * profile into one element, the vector only `innerScript`. */ -export async function wrapCommandWithSandboxLinux( +export async function wrapCommandWithSandboxLinuxArgv( params: LinuxSandboxParams, -): Promise { +): Promise { const { command, commandId, @@ -1737,7 +1810,7 @@ export async function wrapCommandWithSandboxLinux( !hasEnvRestrictions && !hasGitConfig ) { - return command + return null } // Mark this sandbox invocation as active. cleanupBwrapMountPoints() will @@ -2001,7 +2074,12 @@ export async function wrapCommandWithSandboxLinux( bwrapArgs.push(command) } - const wrappedCommand = quote([bwrapPath ?? 'bwrap', ...bwrapArgs]) + // The vector is spawned without a shell, so a bare `bwrap` would be + // looked up against whatever PATH the caller's spawn env carries; + // resolve it here, against the PATH checkLinuxDependencies validated, + // the way the shell is resolved above. (Absent, the bare word is kept: + // the dependency check has already reported it.) + const argv = [bwrapPath ?? whichSync('bwrap') ?? 'bwrap', ...bwrapArgs] const restrictions = [] if (needsNetworkRestriction) restrictions.push('network') @@ -2014,7 +2092,7 @@ export async function wrapCommandWithSandboxLinux( `[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`, ) - return wrappedCommand + return argv } catch (error) { // Undo the activeSandboxCount increment — the caller won't call // cleanupBwrapMountPoints() for a wrap that threw. @@ -2024,3 +2102,24 @@ export async function wrapCommandWithSandboxLinux( throw error } } + +/** + * {@link wrapCommandWithSandboxLinuxArgv} rendered through {@link quote} for + * `sh -c`, or `command` unchanged when it returns null. Same side effects. + */ +export async function wrapCommandWithSandboxLinux( + params: LinuxSandboxParams, +): Promise { + const argv = await wrapCommandWithSandboxLinuxArgv(params) + if (argv === null) return params.command + const wrapped = quote(argv) + // The one place Linux's per-argument cap bites: the whole profile as a + // single `sh -c` element. Loud, like the credential-mask warnings, since + // spawn otherwise reports only an opaque E2BIG. + const overflow = describeBwrapStringOverflow(argv, wrapped) + if (overflow !== undefined) { + console.warn(overflow) + logForDebugging(overflow, { level: 'warn' }) + } + return wrapped +} diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts new file mode 100644 index 000000000..75c9c8c81 --- /dev/null +++ b/src/sandbox/read-deny-glob.ts @@ -0,0 +1,193 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { logForDebugging } from '../utils/debug.js' +import { + isAtOrUnder, + normalizePathForSandbox, + removeTrailingGlobSuffix, + walkGlobPattern, +} from './sandbox-utils.js' + +/** + * A read-deny glob still needing more than this many mounts after collapsing + * is logged at warn level (SRT_DEBUG) as a hint that the pattern is broad. + * The expansion is never truncated, which would silently un-deny paths. This + * is not the argument-size guard — that is byte-based, over the whole + * rendered command line, in describeBwrapStringOverflow. + */ +export const READ_DENY_GLOB_MOUNT_WARN_THRESHOLD = 256 + +/** + * Reduce a read-deny glob's matches to the mounts that change what the + * sandbox can read; ancestors precede descendants in the result. A match is + * dropped only when a kept proper ancestor's tmpfs already hides it — which + * is why every match must name the inode it hides (see + * {@link canonicalizeThroughSymlinks}): the denyRead loop emits the + * ancestor's tmpfs first, so a mount under a symlink spelling beneath it is + * created inside that tmpfs and never reaches the link's target. + */ +export function collapseReadDenyMounts({ + matches, + reExposedPaths, +}: { + /** Absolute, normalized, trailing-slash-free, symlink-free paths. */ + matches: readonly string[] + /** allowRead/allowWrite paths (same spelling) the denyRead loop re-binds + * over a tmpfs; one between a match and its ancestor, inclusive, keeps + * the match's own mount. */ + reExposedPaths: readonly string[] +}): string[] { + const reExposed = new Set(reExposedPaths) + // A proper ancestor is a proper string prefix, so lexicographic order + // visits every ancestor before its descendants. + const sorted = [...new Set(matches)].sort() + const kept = new Set() + for (const candidate of sorted) { + // Walk the candidate's prefixes once, longest first, up to the nearest + // kept ancestor (proper prefixes only: slash > 0 skips the candidate + // itself and the root, which the walk never yields). A re-exposer at + // any prefix from the candidate down to that ancestor, both inclusive, + // keeps the candidate's own mount. + let ancestor: string | undefined + let reExposedBetween = reExposed.has(candidate) + for ( + let slash = candidate.lastIndexOf('/'); + slash > 0; + slash = candidate.lastIndexOf('/', slash - 1) + ) { + const prefix = candidate.slice(0, slash) + if (reExposed.has(prefix)) reExposedBetween = true + if (kept.has(prefix)) { + ancestor = prefix + break + } + } + if (ancestor === undefined || reExposedBetween) kept.add(candidate) + } + return [...kept] +} + +/** + * Rewrite every path that is, or lies beneath, a symlink the walk recorded + * to the path it really names, and dedup. A mount kept under a link spelling + * denies nothing once a covering directory collapses it: the denyRead loop + * stats through the link (a directory symlink takes the --tmpfs branch, + * never resolveSymlinkDenyDest) and, shallow-first, emits the covering + * directory's tmpfs BEFORE the link's own mount — which bwrap then creates + * as a fresh empty directory inside that tmpfs, leaving the target readable. + * A dangling or vanished link keeps its spelling; the loop's existence check + * skips it, as it always did. + */ +function canonicalizeThroughSymlinks( + paths: readonly string[], + symlinks: ReadonlySet, + globPattern: string, +): string[] { + if (symlinks.size === 0) return [...new Set(paths)] + // The static directory prefix the glob walks, in its normalized spelling + // (the same one walkGlobPattern derives), so an escaping target can be + // told from one under the tree. + const normalizedPattern = normalizePathForSandbox(globPattern) + const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] ?? '' + const baseDir = staticPrefix.endsWith('/') + ? staticPrefix.slice(0, -1) + : path.dirname(staticPrefix) + const reachedThroughSymlink = (candidate: string): boolean => { + if (symlinks.has(candidate)) return true + for ( + let slash = candidate.lastIndexOf('/'); + slash > 0; + slash = candidate.lastIndexOf('/', slash - 1) + ) { + if (symlinks.has(candidate.slice(0, slash))) return true + } + return false + } + const canonical = new Set() + for (const p of paths) { + if (!reachedThroughSymlink(p)) { + canonical.add(p) + continue + } + let target: string + try { + target = fs.realpathSync(p) + } catch { + canonical.add(p) + continue + } + canonical.add(target) + // Denying through a link that escapes the tree is what the glob asks for + // on Linux (a bind mount covers an inode, whatever its spelling), but a + // link to an ancestor or to a top-level directory turns the pattern into + // a tmpfs over far more than the user pictured — the project, or /usr — + // so say so, once per link, outside SRT_DEBUG. + if (isAtOrUnder(baseDir, target) || target.split('/').length <= 2) { + const key = `${p} -> ${target}` + if (!warnedEscapingLinks.has(key)) { + warnedEscapingLinks.add(key) + console.warn( + `[sandbox-runtime] WARNING: denyRead glob "${globPattern}" reaches ${p}, a symlink to ${target}; ` + + `that whole directory will be read-denied inside the sandbox. Deny a narrower path, or exclude the link.`, + ) + } + } + } + return [...canonical] +} + +/** Escaping links already warned about, so a per-command wrap warns once. */ +const warnedEscapingLinks = new Set() + +/** + * Expand a read-deny glob into the paths bwrap should mount over, collapsed + * with {@link collapseReadDenyMounts} against `reExposedPaths` (the caller's + * allowRead and allowWrite entries, already put through + * normalizePathForSandbox so they compare by prefix exactly as the denyRead + * loop's do). A pattern ending in `/**` also takes its directory form, so + * `**\/build/**` yields one mount per `build/` directory. An entry reached + * through a symlink is mounted at the path the link resolves to. + */ +export function expandReadDenyGlobLinux( + globPattern: string, + reExposedPaths: readonly string[], +): string[] { + const directoryForm = removeTrailingGlobSuffix(globPattern) + const walk = walkGlobPattern(globPattern, { + directoryPattern: directoryForm === globPattern ? undefined : directoryForm, + }) + const matches = canonicalizeThroughSymlinks( + walk.matches, + walk.symlinks, + globPattern, + ) + if (walk.directoryMatches.length > 0) { + // Everything beneath a directory-form match is itself a match (the + // pattern ends in /**), so a directory with something to deny is some + // match's parent. An empty one gets no mount: it has nothing to deny, + // and as a tmpfs it would swallow later writes. + const parents = new Set(matches.map(m => m.slice(0, m.lastIndexOf('/')))) + for (const dir of canonicalizeThroughSymlinks( + walk.directoryMatches, + walk.symlinks, + globPattern, + )) { + if (parents.has(dir)) matches.push(dir) + } + } + + const mounts = collapseReadDenyMounts({ matches, reExposedPaths }) + + logForDebugging( + `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.length} mounts`, + ) + if (mounts.length > READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) { + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}" still needs ${mounts.length} mounts after collapsing ` + + `(threshold ${READ_DENY_GLOB_MOUNT_WARN_THRESHOLD}); each is a separate bwrap bind and a very ` + + `large set can exceed the kernel argument-size limits. Prefer denying the enclosing directories.`, + { level: 'warn' }, + ) + } + return mounts +} diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index a7d573193..ec1d8a18e 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -41,15 +41,19 @@ import type { } from './sandbox-schemas.js' import { wrapCommandWithSandboxLinux, + wrapCommandWithSandboxLinuxArgv, initializeLinuxNetworkBridge, type LinuxNetworkBridgeContext, + type LinuxSandboxParams, checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js' +import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, + type MacOSSandboxParams, } from './macos-sandbox-utils.js' import { startLinuxSandboxViolationMonitor, @@ -80,6 +84,7 @@ import { containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, + normalizePathForSandbox, decodeSandboxedCommand, encodeSandboxedCommand, } from './sandbox-utils.js' @@ -1165,6 +1170,51 @@ function unionDenyReadPaths( return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])] } +/** + * Strip a trailing `/**` from each read-path entry and, on Linux, expand + * any remaining glob (bubblewrap takes concrete paths only); other + * platforms match globs natively and keep the stripped spelling. An + * allowRead entry expands to its matches. A denyRead entry — the call that + * passes `denyReExposers`, the allowRead + allowWrite paths whose re-binds + * can re-expose contents under a denied directory — is collapsed against + * them by expandReadDenyGlobLinux; they are derived and normalized once, on + * the first Linux glob, so a glob-free config pays nothing for them. + */ +function resolveReadPathEntries( + paths: readonly string[], + denyReExposers?: () => readonly string[], +): string[] { + let reExposers: readonly string[] | undefined + const out: string[] = [] + for (const p of paths) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() !== 'linux' || !containsGlobChars(stripped)) { + out.push(stripped) + } else if (denyReExposers === undefined) { + const expanded = expandGlobPattern(p) + logForDebugging( + `[Sandbox] Expanded allowRead glob pattern "${p}" to ${expanded.length} paths on Linux`, + ) + out.push(...expanded) + } else { + reExposers ??= denyReExposers().map(q => normalizePathForSandbox(q)) + out.push(...expandReadDenyGlobLinux(p, reExposers)) + } + } + return out +} + +/** + * The read policy of the initialized config, for inspection and display. + * On Linux, denyRead globs are expanded and collapsed to covering directory + * mounts against THIS config's allowRead and {@link getFsWriteConfig}'s + * allowOnly, so `denyOnly` is not a self-contained list of denied entries: + * it is only sound alongside that write config and must not be handed to + * wrapCommandWithSandboxLinux with a different one. Per-call customConfig + * overrides and the TLS CA / trust bundle / Java agent re-exposers apply + * only inside wrapWithSandbox and wrapWithSandboxArgv, which recompute the + * mount set. + */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { return { denyOnly: [], allowWithinDeny: [] } @@ -1180,35 +1230,18 @@ function getFsReadConfig(): FsReadRestrictionConfig { ), ) - const denyPaths: string[] = [] - for (const p of rawDenyRead) { - const stripped = removeTrailingGlobSuffix(p) - if (getPlatform() === 'linux' && containsGlobChars(stripped)) { - // Expand glob to concrete paths on Linux (bubblewrap doesn't support globs) - const expanded = expandGlobPattern(p) - logForDebugging( - `[Sandbox] Expanded glob pattern "${p}" to ${expanded.length} paths on Linux`, - ) - denyPaths.push(...expanded) - } else { - denyPaths.push(stripped) - } - } + // Process allowRead paths (re-allow within denied regions). Resolved + // before denyRead: the Linux glob expansion below collapses against them. + const allowPaths = resolveReadPathEntries(config.filesystem.allowRead ?? []) - // Process allowRead paths (re-allow within denied regions) - const allowPaths: string[] = [] - for (const p of config.filesystem.allowRead ?? []) { - const stripped = removeTrailingGlobSuffix(p) - if (getPlatform() === 'linux' && containsGlobChars(stripped)) { - const expanded = expandGlobPattern(p) - logForDebugging( - `[Sandbox] Expanded allowRead glob pattern "${p}" to ${expanded.length} paths on Linux`, - ) - allowPaths.push(...expanded) - } else { - allowPaths.push(stripped) - } - } + // On Linux a denyRead glob's expansion is collapsed to fewer mounts that + // deny the same set, keeping a mount wherever an allowRead/allowWrite + // re-bind would otherwise re-expose it, so the result is only sound + // alongside THIS write config. + const denyPaths = resolveReadPathEntries(rawDenyRead, () => [ + ...allowPaths, + ...getFsWriteConfig().allowOnly, + ]) return { denyOnly: denyPaths, @@ -1516,13 +1549,19 @@ export type WrapWithSandboxOptions = { commandText?: string } -async function wrapWithSandbox( +/** Wrapper inputs derived once by {@link preparePosixSandboxParams} for both + * output forms (shell string, argv). */ +type PosixSandboxParams = + | { platform: 'macos'; params: MacOSSandboxParams } + | { platform: 'linux'; params: LinuxSandboxParams } + +async function preparePosixSandboxParams( command: string, - binShell?: string, - customConfig?: Partial, - abortSignal?: AbortSignal, - options?: WrapWithSandboxOptions, -): Promise { + binShell: string | undefined, + customConfig: Partial | undefined, + abortSignal: AbortSignal | undefined, + options: WrapWithSandboxOptions | undefined, +): Promise { const platform = getPlatform() const commandId = options?.commandId registerCommandText(command, options) @@ -1591,26 +1630,12 @@ async function wrapWithSandbox( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ) - const expandedDenyRead: string[] = [] - for (const p of rawDenyRead) { - const stripped = removeTrailingGlobSuffix(p) - if (getPlatform() === 'linux' && containsGlobChars(stripped)) { - expandedDenyRead.push(...expandGlobPattern(p)) - } else { - expandedDenyRead.push(stripped) - } - } - const rawAllowRead = - customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [] - const expandedAllowRead: string[] = [] - for (const p of rawAllowRead) { - const stripped = removeTrailingGlobSuffix(p) - if (getPlatform() === 'linux' && containsGlobChars(stripped)) { - expandedAllowRead.push(...expandGlobPattern(p)) - } else { - expandedAllowRead.push(stripped) - } - } + // allowRead is resolved first: on Linux a denyRead glob's expansion is + // collapsed against the paths that re-expose contents under a denied + // directory (allowRead + allowWrite), so both must be final here. + const expandedAllowRead = resolveReadPathEntries( + customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], + ) // The TLS-termination CA cert and the trust bundle the env vars point at // (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their // paths fall under a user-configured denyRead. @@ -1621,6 +1646,11 @@ async function wrapWithSandbox( if (javaAgentJarPath) { expandedAllowRead.push(javaAgentJarPath) } + const writeAllowOnly = writeConfig.allowOnly + const expandedDenyRead = resolveReadPathEntries(rawDenyRead, () => [ + ...expandedAllowRead, + ...writeAllowOnly, + ]) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, @@ -1659,80 +1689,87 @@ async function wrapWithSandbox( switch (platform) { case 'macos': // macOS sandbox profile supports glob patterns directly, no ripgrep needed - return wrapCommandWithSandboxMacOS({ - command, - commandId, - needsNetworkRestriction, - // Only pass proxy ports if proxy is running (when there are domains to filter) - httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined, - socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined, - proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined, - caCertPath: mitmCA?.trustBundlePath, - javaAgentJarPath: needsNetworkProxy ? javaAgentJarPath : undefined, - readConfig, - writeConfig, - unsetEnvVars: credentialRestrictions.unsetEnvVars, - setEnvVars: credentialRestrictions.setEnvVars, - maskedFileBinds: credentialRestrictions.maskedFileBinds, - allowUnixSockets: getAllowUnixSockets(), - allowAllUnixSockets: getAllowAllUnixSockets(), - allowLocalBinding: getAllowLocalBinding(), - allowMachLookup: getAllowMachLookup(), - ignoreViolations: getIgnoreViolations(), - allowPty, - allowGitConfig: getAllowGitConfig(), - gitSafeDirectories, - enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(), - allowAppleEvents: getAllowAppleEvents(), - binShell, - }) + return { + platform, + params: { + command, + commandId, + needsNetworkRestriction, + // Only pass proxy ports if proxy is running (when there are domains to filter) + httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined, + socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined, + proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined, + caCertPath: mitmCA?.trustBundlePath, + javaAgentJarPath: needsNetworkProxy ? javaAgentJarPath : undefined, + readConfig, + writeConfig, + unsetEnvVars: credentialRestrictions.unsetEnvVars, + setEnvVars: credentialRestrictions.setEnvVars, + maskedFileBinds: credentialRestrictions.maskedFileBinds, + allowUnixSockets: getAllowUnixSockets(), + allowAllUnixSockets: getAllowAllUnixSockets(), + allowLocalBinding: getAllowLocalBinding(), + allowMachLookup: getAllowMachLookup(), + ignoreViolations: getIgnoreViolations(), + allowPty, + allowGitConfig: getAllowGitConfig(), + gitSafeDirectories, + enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(), + allowAppleEvents: getAllowAppleEvents(), + binShell, + }, + } case 'linux': - return wrapCommandWithSandboxLinux({ - command, - commandId, - needsNetworkRestriction, - // Only pass socket paths if proxy is running (when there are domains to filter) - httpSocketPath: needsNetworkProxy - ? getLinuxHttpSocketPath() - : undefined, - socksSocketPath: needsNetworkProxy - ? getLinuxSocksSocketPath() - : undefined, - httpProxyPort: needsNetworkProxy - ? managerContext?.httpProxyPort - : undefined, - socksProxyPort: needsNetworkProxy - ? managerContext?.socksProxyPort - : undefined, - proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined, - caCertPath: mitmCA?.trustBundlePath, - javaAgentJarPath: needsNetworkProxy ? javaAgentJarPath : undefined, - readConfig, - writeConfig, - unsetEnvVars: credentialRestrictions.unsetEnvVars, - setEnvVars: credentialRestrictions.setEnvVars, - maskedFileBinds: credentialRestrictions.maskedFileBinds, - maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir, - enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(), - allowAllUnixSockets: getAllowAllUnixSockets(), - binShell, - ripgrepConfig: getRipgrepConfig(), - mandatoryDenySearchDepth: getMandatoryDenySearchDepth(), - allowGitConfig: getAllowGitConfig(), - gitSafeDirectories, - seccompConfig: getSeccompConfig(), - bwrapPath: config?.bwrapPath, - socatPath: config?.socatPath, - observeSocketPath: linuxMonitor?.observeSocketPath, - abortSignal, - }) + return { + platform, + params: { + command, + commandId, + needsNetworkRestriction, + // Only pass socket paths if proxy is running (when there are domains to filter) + httpSocketPath: needsNetworkProxy + ? getLinuxHttpSocketPath() + : undefined, + socksSocketPath: needsNetworkProxy + ? getLinuxSocksSocketPath() + : undefined, + httpProxyPort: needsNetworkProxy + ? managerContext?.httpProxyPort + : undefined, + socksProxyPort: needsNetworkProxy + ? managerContext?.socksProxyPort + : undefined, + proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined, + caCertPath: mitmCA?.trustBundlePath, + javaAgentJarPath: needsNetworkProxy ? javaAgentJarPath : undefined, + readConfig, + writeConfig, + unsetEnvVars: credentialRestrictions.unsetEnvVars, + setEnvVars: credentialRestrictions.setEnvVars, + maskedFileBinds: credentialRestrictions.maskedFileBinds, + maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir, + enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(), + allowAllUnixSockets: getAllowAllUnixSockets(), + binShell, + ripgrepConfig: getRipgrepConfig(), + mandatoryDenySearchDepth: getMandatoryDenySearchDepth(), + allowGitConfig: getAllowGitConfig(), + gitSafeDirectories, + seccompConfig: getSeccompConfig(), + bwrapPath: config?.bwrapPath, + socatPath: config?.socatPath, + observeSocketPath: linuxMonitor?.observeSocketPath, + abortSignal, + }, + } case 'windows': // Windows wraps to an argv array, not a shell string. Forcing // callers through wrapWithSandboxArgv() means they spawn with // {shell:false}, which is the security boundary that keeps the - // user's command bytes off the HOST shell. + // user's command bytes off the HOST shell. (wrapWithSandboxArgv + // handles Windows itself and never reaches here.) throw new Error( 'wrapWithSandbox() returns a shell string and is not supported ' + 'on Windows. Use SandboxManager.wrapWithSandboxArgv() and ' + @@ -1747,6 +1784,28 @@ async function wrapWithSandbox( } } +async function wrapWithSandbox( + command: string, + binShell?: string, + customConfig?: Partial, + abortSignal?: AbortSignal, + options?: WrapWithSandboxOptions, +): Promise { + const prepared = await preparePosixSandboxParams( + command, + binShell, + customConfig, + abortSignal, + options, + ) + switch (prepared.platform) { + case 'macos': + return wrapCommandWithSandboxMacOS(prepared.params) + case 'linux': + return wrapCommandWithSandboxLinux(prepared.params) + } +} + /** * Wrap `command` for the sandbox and return a spawn descriptor: * `{ argv, env }`, suitable for @@ -1756,10 +1815,14 @@ async function wrapWithSandbox( * {@link wrapWithSandbox}); `env` is the broker process's spawn env * — the sandboxed child gets a fresh `srt-sandbox` profile env with * only the `--env` overlay baked into `argv` (see - * {@link wrapCommandWithSandboxWindows}). On - * macOS/Linux `argv` is `[binShell, '-c', ]` - * (proxy env is baked into that command) and `env` is the unchanged - * `process.env`, so callers can spawn uniformly across platforms. + * {@link wrapCommandWithSandboxWindows}). On Linux `argv` is the + * bwrap invocation itself (`['bwrap', ...options, '--', shell, '-c', + * innerScript]`, proxy env baked in as `--setenv`; RETURN SHAPE on + * {@link wrapCommandWithSandboxLinuxArgv} says why), or `[binShell, + * '-c', command]` when the effective config needs no sandbox. + * On macOS `argv` is `[binShell, '-c', ]`. + * On both, `env` is the unchanged `process.env`, so callers can spawn + * uniformly across platforms. * * @param cwd the working directory the caller will spawn the result * with. On Windows the child's cwd is whatever the caller passes @@ -1878,15 +1941,13 @@ async function wrapWithSandboxArgv( }) } - // macOS/Linux: delegate to the existing string wrapper, then put - // the result behind ` -c` so the caller's argv-spawn works. if (typeof binShell === 'object') { throw new Error( 'binShell object form is Windows-only; pass a shell path string ' + 'on macOS/Linux', ) } - const wrapped = await wrapWithSandbox( + const prepared = await preparePosixSandboxParams( command, binShell, customConfig, @@ -1894,7 +1955,19 @@ async function wrapWithSandboxArgv( options, ) const shell = binShell ?? '/bin/bash' - return { argv: [shell, '-c', wrapped], env: process.env } + switch (prepared.platform) { + case 'linux': { + const argv = await wrapCommandWithSandboxLinuxArgv(prepared.params) + return { argv: argv ?? [shell, '-c', command], env: process.env } + } + case 'macos': { + // Darwin has no per-element argv cap (only the ~1 MiB ARG_MAX total, + // env included), so the shell-string form carries no E2BIG hazard of + // its own there; keep delegating to it behind ` -c`. + const wrapped = wrapCommandWithSandboxMacOS(prepared.params) + return { argv: [shell, '-c', wrapped], env: process.env } + } + } } /** diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 933585175..e7e5c5161 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -52,6 +52,14 @@ export function normalizeCaseForComparison(pathStr: string): string { return pathStr.toLowerCase() } +/** + * `p` is `dir` itself or lies beneath it, by path segment ('/x' is not under + * '/xy'); root-aware, since '/' + '/' is a prefix of nothing. + */ +export function isAtOrUnder(p: string, dir: string): boolean { + return p === dir || p.startsWith(dir === '/' ? '/' : dir + '/') +} + /** * Check if a path pattern contains glob characters */ @@ -873,6 +881,19 @@ export interface ExpandGlobOptions { caseInsensitive?: boolean } +/** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */ +export interface GlobWalk { + /** Absolute paths matching the pattern. */ + matches: string[] + /** Directories (a symlink to one included) matching `directoryPattern` + * over the same listing; empty without one. */ + directoryMatches: string[] + /** Every visited entry that is a symbolic link, by full path. Recursive + * readdir descends into symlinked directories, so a match beneath one + * really lives outside the tree it was found in. */ + symlinks: Set +} + /** * Expand a glob pattern into concrete file paths. * @@ -888,6 +909,24 @@ export function expandGlobPattern( globPath: string, opts: ExpandGlobOptions = {}, ): string[] { + return walkGlobPattern(globPath, opts).matches +} + +/** + * The walk behind {@link expandGlobPattern}: one recursive listing of the + * static prefix, filtered by `globPath` and, when given, `directoryPattern` + * (which must share that prefix), with the symlinks seen recorded. + */ +export function walkGlobPattern( + globPath: string, + opts: ExpandGlobOptions & { directoryPattern?: string } = {}, +): GlobWalk { + const walk: GlobWalk = { + matches: [], + directoryMatches: [], + symlinks: new Set(), + } + // Normalize to `/` separators throughout so {@link globToRegex} // (which treats `/` as the segment boundary) and the static-prefix // split work on Windows paths. Gated to win32: `\` is a valid @@ -901,7 +940,7 @@ export function expandGlobPattern( const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] if (!staticPrefix || staticPrefix === '/') { logForDebugging(`[Sandbox] Glob pattern too broad, skipping: ${globPath}`) - return [] + return walk } // Get the base directory from the static prefix @@ -913,42 +952,106 @@ export function expandGlobPattern( logForDebugging( `[Sandbox] Base directory for glob does not exist: ${baseDir}`, ) - return [] + return walk } - // Build regex from the normalized glob pattern - const regex = new RegExp( - globToRegex(normalizedPattern), - opts.caseInsensitive ? 'i' : '', - ) - - // List all entries recursively under the base directory - const results: string[] = [] + const flags = opts.caseInsensitive ? 'i' : '' + const regex = new RegExp(globToRegex(normalizedPattern), flags) + const directoryRegex = + opts.directoryPattern === undefined + ? undefined + : new RegExp( + globToRegex(toFwd(normalizePathForSandbox(opts.directoryPattern))), + flags, + ) + + // Walk explicitly, one readdir per directory, rather than through + // readdirSync's `recursive` option: that listing is all-or-nothing, so + // one unreadable subtree — or a symlink cycle, which makes it throw ELOOP + // under Bun and expand without bound under Node — would void the whole + // pattern, and a read-deny glob would silently deny nothing. Symlinked + // directories are descended like any other (a match beneath one names an + // inode outside the tree; see GlobWalk.symlinks) — every spelling the + // sandboxed command could read through must be listed, so a target + // reached twice is listed twice, never skipped — except a link back into + // its own ancestry, which is the one shape that never terminates: a + // symlink is not followed when its target is at or above any directory on + // the current descent (the real directory each earlier link was taken + // from, and this one). Depth-first, so a directory's entries stay + // together. + type Frame = { + dir: string + /** `dir` with every symlink resolved. */ + real: string + /** The real directory each symlink on the way here was taken from. */ + linkedFrom: readonly string[] + } + let baseReal = baseDir try { - const entries = fs.readdirSync(baseDir, { - recursive: true, - withFileTypes: true, - }) - + baseReal = fs.realpathSync(baseDir) + } catch { + // Vanished between the existence check and here: list what remains. + } + const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] + while (pending.length > 0) { + const { dir, real, linkedFrom } = pending.pop()! + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + logForDebugging( + `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, + ) + continue + } for (const entry of entries) { - // Build the full path for this entry - // entry.parentPath is the directory containing this entry (available in Node 20+/Bun) - // For compatibility, fall back to entry.path if parentPath is not available - const parentDir = - (entry as { parentPath?: string }).parentPath ?? - (entry as { path?: string }).path ?? - baseDir - const fullPath = path.join(parentDir, entry.name) - - if (regex.test(toFwd(fullPath))) { - results.push(fullPath) + const fullPath = path.join(dir, entry.name) + const candidate = toFwd(fullPath) + if (regex.test(candidate)) { + walk.matches.push(fullPath) + } + if (entry.isDirectory()) { + if (directoryRegex?.test(candidate)) { + walk.directoryMatches.push(fullPath) + } + pending.push({ + dir: fullPath, + real: path.join(real, entry.name), + linkedFrom, + }) + continue + } + if (!entry.isSymbolicLink()) continue + walk.symlinks.add(fullPath) + // A link pays a stat and, when it leads to a directory, a realpath. + let target: string | undefined + try { + if (fs.statSync(fullPath).isDirectory()) { + target = fs.realpathSync(fullPath) + } + } catch { + // Dangling, or vanished: nothing to descend into. + } + if (target === undefined) continue + if (directoryRegex?.test(candidate)) { + walk.directoryMatches.push(fullPath) } + const cycle = [...linkedFrom, real].some(from => + isAtOrUnder(from, target), + ) + if (cycle) { + logForDebugging( + `[Sandbox] Not following symlink ${fullPath} -> ${target} for glob pattern ${globPath}: it leads back into its own ancestry`, + ) + continue + } + pending.push({ + dir: fullPath, + real: target, + linkedFrom: [...linkedFrom, real], + }) } - } catch (err) { - logForDebugging( - `[Sandbox] Error expanding glob pattern ${globPath}: ${err}`, - ) } - return results + return walk } diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 00e45aca8..1ef949ad6 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -8,6 +8,27 @@ import { type Writable } from 'stream' // Get the path to the built CLI const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js') +// srt is expected to exit on its own shortly after the wrapped command +// (which runs for well under a second) finishes; a hang is a failure, not +// something to wait out. +function waitForExit(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject(new Error('srt did not exit within 2s of the wrapped command')), + 2000, + ) + child.on('exit', code => { + clearTimeout(timer) + resolve(code) + }) + child.on('error', err => { + clearTimeout(timer) + reject(err) + }) + }) +} + describe('--control-fd', () => { let tmpDir: string let child: ChildProcess | null = null @@ -21,14 +42,12 @@ describe('--control-fd', () => { child.kill('SIGKILL') } fs.rmSync(tmpDir, { recursive: true, force: true }) - // Bun's node:child_process shim implements extra stdio 'pipe' entries - // (fd 3 here) via a unix socket, and tears that socket down - // asynchronously after the child exits. The tests above all hit the - // 2000ms safety timeout and SIGKILL their child, so on a fast runner - // the next test's spawn can race that teardown and Bun's - // #createStdioObject throws `Failed to connect` (connect ENOENT) — - // observed on linux/arm64. Yield briefly so the prior child's stdio - // cleanup settles before the next spawn. + // Every test waits for srt to exit on its own; the SIGKILL above only + // runs when one has already failed. Bun's node:child_process shim + // implements extra stdio 'pipe' entries (fd 3 here) via a unix socket + // torn down asynchronously after the child exits, and a spawn that + // races that teardown throws `Failed to connect` (connect ENOENT), so + // yield briefly before the next test's spawn either way. await new Promise(r => setTimeout(r, 50)) }) @@ -71,12 +90,10 @@ describe('--control-fd', () => { }) controlFd.write(configUpdate + '\n') - // Wait for process to complete - await new Promise((resolve, reject) => { - child!.on('exit', () => resolve()) - child!.on('error', reject) - setTimeout(() => resolve(), 2000) // Timeout safety - }) + // srt must exit by itself once the wrapped command finishes, with the + // control fd still open on our side. + const exitCode = await waitForExit(child) + expect(exitCode).toBe(0) // Check that config was updated - look for debug output const allStderr = stderr.join('') @@ -110,12 +127,10 @@ describe('--control-fd', () => { const controlFd = child.stdio[3] as Writable controlFd.write('{ invalid json }\n') - // Wait for process to complete - await new Promise((resolve, reject) => { - child!.on('exit', () => resolve()) - child!.on('error', reject) - setTimeout(() => resolve(), 2000) // Timeout safety - }) + // srt must exit by itself once the wrapped command finishes, with the + // control fd still open on our side. + const exitCode = await waitForExit(child) + expect(exitCode).toBe(0) // Process should still complete successfully const allStdout = stdout.join('') @@ -146,12 +161,10 @@ describe('--control-fd', () => { controlFd.write(' \n') controlFd.write('\t\n') - // Wait for process to complete - await new Promise((resolve, reject) => { - child!.on('exit', () => resolve()) - child!.on('error', reject) - setTimeout(() => resolve(), 2000) // Timeout safety - }) + // srt must exit by itself once the wrapped command finishes, with the + // control fd still open on our side. + const exitCode = await waitForExit(child) + expect(exitCode).toBe(0) // Process should still complete successfully const allStdout = stdout.join('') @@ -175,12 +188,7 @@ describe('--control-fd', () => { stdout.push(data.toString()) }) - // Wait for process to complete - const exitCode = await new Promise((resolve, reject) => { - child!.on('exit', code => resolve(code)) - child!.on('error', reject) - setTimeout(() => resolve(null), 2000) // Timeout safety - }) + const exitCode = await waitForExit(child) expect(exitCode).toBe(0) const allStdout = stdout.join('') @@ -211,12 +219,10 @@ describe('--control-fd', () => { const stdin = child.stdin as Writable stdin.write('hello from stdin\n') - // Wait for process to complete - await new Promise((resolve, reject) => { - child!.on('exit', () => resolve()) - child!.on('error', reject) - setTimeout(() => resolve(), 2000) // Timeout safety - }) + // srt must exit by itself once the wrapped command finishes, with the + // control fd still open on our side. + const exitCode = await waitForExit(child) + expect(exitCode).toBe(0) const allStdout = stdout.join('') expect(allStdout).toContain('GOT: hello from stdin') diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 618328f04..eca1a7217 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -5,6 +5,7 @@ import { rmSync, existsSync, realpathSync, + symlinkSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,6 +13,7 @@ import { expandGlobPattern, expandTilde, globToRegex, + walkGlobPattern, } from '../../src/sandbox/sandbox-utils.js' import { containsGlobCharsWin, @@ -173,6 +175,67 @@ describe('expandGlobPattern', () => { ) }) +describe.if(!isWindows)('walkGlobPattern', () => { + const RAW_BASE = join(tmpdir(), 'glob-walk-test-' + Date.now()) + + beforeAll(() => { + mkdirSync(join(RAW_BASE, 'a', 'build'), { recursive: true }) + writeFileSync(join(RAW_BASE, 'a', 'build', '1.out'), '') + mkdirSync(join(RAW_BASE, 'elsewhere')) + symlinkSync( + join(RAW_BASE, 'elsewhere'), + join(RAW_BASE, 'a', 'build', 'link'), + ) + }) + + afterAll(() => { + rmSync(RAW_BASE, { recursive: true, force: true }) + }) + + it('evaluates the directory pattern over the same listing and records symlinks', () => { + const BASE = realPath(RAW_BASE) + const pattern = join(RAW_BASE, '**/build/**') + const walk = walkGlobPattern(pattern, { + directoryPattern: join(RAW_BASE, '**/build'), + }) + + expect(walk.matches).toContain(join(BASE, 'a', 'build', '1.out')) + expect(walk.directoryMatches).toEqual([join(BASE, 'a', 'build')]) + expect([...walk.symlinks]).toEqual([join(BASE, 'a', 'build', 'link')]) + }) + + it('terminates on a symlink cycle and still lists the tree', () => { + // build/up -> .. : a recursive readdir throws ELOOP (Bun) or expands + // without bound (Node); the walk must survive it, or a denyRead glob + // over this tree would silently deny nothing. + const BASE = realPath(RAW_BASE) + mkdirSync(join(RAW_BASE, 'cyc', 'build'), { recursive: true }) + writeFileSync(join(RAW_BASE, 'cyc', 'build', '1.out'), '') + symlinkSync('..', join(RAW_BASE, 'cyc', 'build', 'up')) + + const walk = walkGlobPattern(join(RAW_BASE, 'cyc', '**/build/**'), { + directoryPattern: join(RAW_BASE, 'cyc', '**/build'), + }) + + expect(walk.matches).toContain(join(BASE, 'cyc', 'build', '1.out')) + expect(walk.directoryMatches).toEqual([join(BASE, 'cyc', 'build')]) + expect(walk.symlinks.has(join(BASE, 'cyc', 'build', 'up'))).toBe(true) + // The cycle is not re-entered: nothing appears twice. + expect(new Set(walk.matches).size).toBe(walk.matches.length) + }) + + it('returns empty results for a missing base', () => { + const walk = walkGlobPattern(join(RAW_BASE, 'nope', '*.env'), { + directoryPattern: join(RAW_BASE, 'nope', '*'), + }) + expect(walk).toEqual({ + matches: [], + directoryMatches: [], + symlinks: new Set(), + }) + }) +}) + // ============================================================================ // expandTilde — `~\` form is Windows-only // ============================================================================ diff --git a/test/sandbox/linux-bwrap-argv.test.ts b/test/sandbox/linux-bwrap-argv.test.ts new file mode 100644 index 000000000..01bec87a4 --- /dev/null +++ b/test/sandbox/linux-bwrap-argv.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + wrapCommandWithSandboxLinuxArgv, + cleanupBwrapMountPoints, + type LinuxSandboxParams, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + describeBwrapArgv, + describeBwrapStringOverflow, + type BwrapArgvSummary, +} from '../../src/sandbox/bwrap-argv.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { quote } from '../../src/utils/shell-quote.js' +import { whichSync } from '../../src/utils/which.js' +import { isLinux } from '../helpers/platform.js' + +/** + * The bwrap invocation as a real argv vector, spawnable with {shell:false}: + * one element per bwrap word, and the same invocation the string form runs. + */ +describe.if(isLinux)('wrapCommandWithSandboxLinuxArgv', () => { + let BASE: string + let AREA: string + let SECRETS: string + const savedCwd = process.cwd() + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'bwrap-argv-'))) + AREA = join(BASE, 'area') + SECRETS = join(BASE, 'secrets') + mkdirSync(join(AREA, 'sub'), { recursive: true }) + mkdirSync(SECRETS) + writeFileSync(join(AREA, 'sub', 'locked.txt'), 'x\n') + writeFileSync(join(AREA, '.env'), 'SECRET=1\n') + writeFileSync(join(SECRETS, 'token'), 't\n') + // cwd OUTSIDE the write allowlist keeps the mandatory-deny scan from + // contributing creation-blocking stubs (their empty-dir sources are + // mkdtemp'd, which would make two wraps of the same params differ). + process.chdir(BASE) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + function params( + command = `echo 'hello world' && ls "$HOME"`, + ): LinuxSandboxParams { + return { + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [SECRETS, join(AREA, '.env')] }, + writeConfig: { + allowOnly: [AREA], + denyWithinAllow: [join(AREA, 'sub', 'locked.txt')], + }, + setEnvVars: { SRT_TEST_VAR: 'value with spaces' }, + } + } + + it('returns null when the params call for no sandbox at all', async () => { + const argv = await wrapCommandWithSandboxLinuxArgv({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: undefined, + }) + expect(argv).toBeNull() + // The string form hands the command back untouched as well. + expect( + await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: undefined, + }), + ).toBe('echo hi') + }) + + it('is one element per bwrap word with the shell trailer last', async () => { + // No single quotes: the inner script may re-quote the command (the + // apply-seccomp shim wraps it in another `bash -c '...'`), and a + // single-quote-free command survives that as a verbatim substring. + const command = `printf "%s\\n" word && true` + const argv = (await wrapCommandWithSandboxLinuxArgv(params(command)))! + + expect(argv[0]).toBe(whichSync('bwrap') ?? 'bwrap') + const separator = argv.indexOf('--') + expect(separator).toBeGreaterThan(0) + // Trailer: -c , and nothing after it. + expect(argv.slice(separator + 1, separator + 3)).toEqual([ + whichSync('bash')!, + '-c', + ]) + expect(argv.length).toBe(separator + 4) + // The user command lives only in the inner script (possibly behind the + // apply-seccomp shim), never folded into an option word. + expect(argv[separator + 3]).toContain(command) + expect(argv.slice(0, separator).some(a => a.includes(command))).toBe(false) + // Mount options are their own elements: no element other than the + // option word itself mentions --ro-bind / --tmpfs. + for (const word of ['--ro-bind', '--tmpfs', '--bind', '--setenv']) { + expect(argv.filter(a => a.includes(word)).every(a => a === word)).toBe( + true, + ) + } + // The configured policy made it in as discrete operands. + expect(argv).toContain(SECRETS) // --tmpfs SECRETS + expect(argv).toContain(join(AREA, 'sub', 'locked.txt')) // --ro-bind p p + const setenvAt = argv.indexOf('SRT_TEST_VAR') + expect(argv[setenvAt - 1]).toBe('--setenv') + expect(argv[setenvAt + 1]).toBe('value with spaces') + }) + + it('honours bwrapPath as argv[0]', async () => { + const argv = (await wrapCommandWithSandboxLinuxArgv({ + ...params(), + bwrapPath: '/opt/custom/bin/bwrap', + }))! + expect(argv[0]).toBe('/opt/custom/bin/bwrap') + }) + + it('SandboxManager.wrapWithSandboxArgv returns the bwrap vector, not [shell, -c, string]', async () => { + // Long enough that the inner script is unambiguously the largest word. + const command = `echo from-manager ${'x'.repeat(256)}` + const customConfig = { + filesystem: { + denyRead: [SECRETS], + allowWrite: [AREA], + denyWrite: [join(AREA, 'sub', 'locked.txt')], + }, + } + try { + const { argv, env } = await SandboxManager.wrapWithSandboxArgv( + command, + undefined, + customConfig, + ) + const wrapped = await SandboxManager.wrapWithSandbox( + command, + undefined, + customConfig, + ) + + expect(env).toBe(process.env) + expect(argv[0]).toBe(whichSync('bwrap') ?? 'bwrap') + expect(argv).toContain('--') + expect(quote(argv)).toBe(wrapped) + // The point of the vector: the largest single element is the inner + // script, not the whole profile. + const summary = describeBwrapArgv(argv) + expect(summary.largestArgBytes).toBe(summary.innerCommandBytes) + expect(summary.largestArgBytes).toBeLessThan( + Buffer.byteLength(wrapped) + 1, + ) + } finally { + await SandboxManager.reset() + } + }) +}) + +describe('describeBwrapArgv', () => { + const nul = (s: string): number => Buffer.byteLength(s, 'utf8') + 1 + const countsOf = (summary: BwrapArgvSummary): Record => + Object.fromEntries( + Object.entries(summary.terms).map(([term, t]) => [term, t.count]), + ) + const bytesAcrossTerms = (summary: BwrapArgvSummary): number => + Object.values(summary.terms).reduce((sum, t) => sum + t.bytes, 0) + + it('breaks a vector down by term with execve-style byte accounting', () => { + const inner = `echo 'héllo'` // multi-byte on purpose + const argv = [ + 'bwrap', + '--new-session', + '--die-with-parent', + '--setenv', + 'HTTP_PROXY', + 'http://localhost:3128', + '--ro-bind', + '/', + '/', + '--bind', + '/work', + '/work', + '--tmpfs', + '/home/u/.ssh', + '--ro-bind', + '/dev/null', + '/work/.env', + '--ro-bind', + '/tmp/claude-empty-123', + '/work/.claude', + '--ro-bind', + '/work/.git/hooks', + '/work/.git/hooks', + '--dev', + '/dev', + '--unshare-pid', + '--', + '/usr/bin/bash', + '-c', + inner, + ] + + const summary = describeBwrapArgv(argv) + + expect(countsOf(summary)).toEqual({ + roBindSelf: 2, // '/' '/' and the hooks dir + roBindDevNull: 1, + roBindOther: 1, // the empty-dir stub + bind: 1, + tmpfs: 1, + setenv: 1, + // bwrap, --new-session, --die-with-parent, --dev, /dev, --unshare-pid, + // --, /usr/bin/bash, -c, inner + other: 10, + }) + expect(summary.terms.setenv.bytes).toBe( + nul('--setenv') + nul('HTTP_PROXY') + nul('http://localhost:3128'), + ) + expect(summary.terms.tmpfs.bytes).toBe(nul('--tmpfs') + nul('/home/u/.ssh')) + expect(summary.terms.roBindDevNull.bytes).toBe( + nul('--ro-bind') + nul('/dev/null') + nul('/work/.env'), + ) + expect(summary.innerCommandBytes).toBe(nul(inner)) + expect(summary.totalBytes).toBe( + argv.reduce((sum, arg) => sum + nul(arg), 0), + ) + // The terms partition the vector. + expect(bytesAcrossTerms(summary)).toBe(summary.totalBytes) + expect(summary.largestArgBytes).toBe(nul('/tmp/claude-empty-123')) + }) + + it('treats a [shell, -c, script] vector as its own trailer', () => { + const summary = describeBwrapArgv(['/bin/bash', '-c', 'echo hi']) + expect(summary.innerCommandBytes).toBe(nul('echo hi')) + expect(summary.largestArgBytes).toBe(nul('/bin/bash')) + expect(summary.terms.other.count).toBe(3) + expect(bytesAcrossTerms(summary)).toBe(summary.totalBytes) + }) + + it('describeBwrapStringOverflow warns only past the per-argument cap', () => { + // ~140 bytes per mask, the shape of a monorepo node_modules path. + const mask = (i: number): string[] => [ + '--ro-bind', + '/dev/null', + `/home/user/monorepo/packages/service-${i}/node_modules/@scope/pkg/dist/esm/internal/generated/schema/types/index.js`, + ] + const vector = (masks: number): string[] => [ + 'bwrap', + ...Array.from({ length: masks }, (_, i) => mask(i)).flat(), + '--', + '/bin/bash', + '-c', + 'echo', + ] + const under = vector(200) + expect(describeBwrapStringOverflow(under, quote(under))).toBeUndefined() + const over = vector(1200) + const warning = describeBwrapStringOverflow(over, quote(over)) + expect(warning).toContain('E2BIG') + expect(warning).toContain('/dev/null masks 1200') + expect(warning).toContain('wrapWithSandboxArgv') + }) + + it('reports zero inner-command bytes for a vector without a -- trailer', () => { + const summary = describeBwrapArgv(['bwrap', '--tmpfs', '/x']) + expect(summary.innerCommandBytes).toBe(0) + expect(summary.terms.tmpfs.count).toBe(1) + expect(summary.terms.other.count).toBe(1) + }) + + it('tolerates an empty vector and a truncated trailing option', () => { + expect(describeBwrapArgv([]).totalBytes).toBe(0) + const truncated = describeBwrapArgv(['bwrap', '--ro-bind', '/dev/null']) + expect(truncated.terms.roBindDevNull.count).toBe(1) + expect(truncated.terms.roBindDevNull.bytes).toBe( + nul('--ro-bind') + nul('/dev/null'), + ) + expect(bytesAcrossTerms(truncated)).toBe(truncated.totalBytes) + }) + + it('is not fooled by an operand spelled like an Object.prototype member, nor by a bare --ro-bind', () => { + // An option the table does not know leaves its operand in option + // position; `constructor` must read as a bare word, not as a function. + const summary = describeBwrapArgv([ + 'bwrap', + '--hostname', + 'constructor', + '--tmpfs', + '/x', + '--', + '/bin/bash', + '-c', + 'echo', + ]) + expect(summary.terms.tmpfs.count).toBe(1) + expect(summary.innerCommandBytes).toBe(nul('echo')) + expect(bytesAcrossTerms(summary)).toBe(summary.totalBytes) + // No operands at all is not a self-bind. + expect( + describeBwrapArgv(['bwrap', '--ro-bind']).terms.roBindSelf.count, + ).toBe(0) + }) +}) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts new file mode 100644 index 000000000..fe900546e --- /dev/null +++ b/test/sandbox/read-deny-glob.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + collapseReadDenyMounts, + expandReadDenyGlobLinux, + READ_DENY_GLOB_MOUNT_WARN_THRESHOLD, +} from '../../src/sandbox/read-deny-glob.js' +import { expandGlobPattern } from '../../src/sandbox/sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Invariant pinned here: a denyRead glob match beneath a kept covering + * directory gets no mount of its own (the directory's tmpfs already hides + * it) unless an allowRead / allowWrite re-bind between the two would leave + * it readable. A match reached through a symlink is first resolved to the + * inode it names: a mount under the link spelling would be created inside + * the covering tmpfs and hide nothing. + */ + +describe('collapseReadDenyMounts (pure)', () => { + it('drops matches beneath a matched directory and dedups', () => { + const kept = collapseReadDenyMounts({ + matches: [ + '/r/pkg/a/build/1.out', + '/r/pkg/a/build', + '/r/pkg/a/build/sub/2.out', + '/r/pkg/a/build/sub', + '/r/pkg/b/build/1.out', + '/r/pkg/b/build', + '/r/pkg/b/build', + '/r/top.log', + ], + reExposedPaths: [], + }) + expect(kept).toEqual(['/r/pkg/a/build', '/r/pkg/b/build', '/r/top.log']) + }) + + it('does not treat a string-prefix sibling as an ancestor', () => { + // '/r/build' must not swallow '/r/build-cache/x'. + const kept = collapseReadDenyMounts({ + matches: ['/r/build', '/r/build-cache/x', '/r/build/y'], + reExposedPaths: [], + }) + expect(kept).toEqual(['/r/build', '/r/build-cache/x']) + }) + + it('keeps a descendant that an allowRead/allowWrite re-bind between it and the covering dir would re-expose', () => { + const kept = collapseReadDenyMounts({ + matches: [ + '/r/secrets', + '/r/secrets/public/key', // under the re-exposed /r/secrets/public + '/r/secrets/private/key', // no re-exposer in between + '/r/secrets/public', // AT the re-exposer: the loop re-binds it anyway + ], + reExposedPaths: ['/r/secrets/public', '/elsewhere'], + }) + expect(kept).toEqual([ + '/r/secrets', + '/r/secrets/public', + '/r/secrets/public/key', + ]) + }) + + it('treats a re-exposer AT the covering dir as re-exposing everything beneath it', () => { + // denyRead and allowRead naming the same dir: the tmpfs is immediately + // re-bound, so descendants need their own mounts exactly as before. + const kept = collapseReadDenyMounts({ + matches: ['/r/d', '/r/d/a', '/r/d/b/c'], + reExposedPaths: ['/r/d'], + }) + expect(kept).toEqual(['/r/d', '/r/d/a', '/r/d/b/c']) + }) + + it('ignores re-exposers that are below the candidate or unrelated', () => { + const kept = collapseReadDenyMounts({ + matches: ['/r/d', '/r/d/a'], + reExposedPaths: ['/r/d/a/deeper', '/r/dx', '/q'], + }) + expect(kept).toEqual(['/r/d']) + }) + + it('is a no-op for a flat list of files', () => { + const files = ['/r/a.log', '/r/x/b.log', '/r/x/y/c.log'] + expect( + collapseReadDenyMounts({ + matches: files, + reExposedPaths: [], + }), + ).toEqual(files) + }) +}) + +describe.if(!isWindows)('expandReadDenyGlobLinux (warn threshold)', () => { + let ROOT: string + const savedDebug = process.env.SRT_DEBUG + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-warn-'))) + // logForDebugging only speaks under SRT_DEBUG. + process.env.SRT_DEBUG = '1' + }) + + afterAll(() => { + if (savedDebug === undefined) delete process.env.SRT_DEBUG + else process.env.SRT_DEBUG = savedDebug + rmSync(ROOT, { recursive: true, force: true }) + }) + + // A flat directory of `count` files: nothing collapses into anything. + function flatDir(name: string, count: number): string { + const dir = join(ROOT, name) + mkdirSync(dir) + for (let i = 0; i < count; i++) writeFileSync(join(dir, `${i}.log`), '') + return dir + } + + function warningsWhile(run: () => string[]): { + mounts: string[] + warnings: string[] + } { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const mounts = run() + return { + mounts, + warnings: warn.mock.calls.map(call => String(call[0])), + } + } finally { + warn.mockRestore() + } + } + + it('warns when a glob still needs more mounts than the threshold after collapsing', () => { + const dir = flatDir('over', READ_DENY_GLOB_MOUNT_WARN_THRESHOLD + 1) + const { mounts, warnings } = warningsWhile(() => + expandReadDenyGlobLinux(join(dir, '*.log'), []), + ) + expect(mounts.length).toBe(READ_DENY_GLOB_MOUNT_WARN_THRESHOLD + 1) + expect( + warnings.some(line => + line.includes(`still needs ${mounts.length} mounts`), + ), + ).toBe(true) + }) + + it('stays quiet at the threshold', () => { + const dir = flatDir('at', READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) + const { mounts, warnings } = warningsWhile(() => + expandReadDenyGlobLinux(join(dir, '*.log'), []), + ) + expect(mounts.length).toBe(READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) + expect(warnings).toEqual([]) + }) +}) + +describe.if(!isWindows)( + 'expandReadDenyGlobLinux (symlinks and empty directories)', + () => { + let ROOT: string + let OUTSIDE: string + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-symlink-'))) + OUTSIDE = join(ROOT, 'outside') + mkdirSync(OUTSIDE) + writeFileSync(join(OUTSIDE, 'secret.txt'), '') + writeFileSync(join(OUTSIDE, 'key.pem'), '') + // pkg/a/build: a real file plus a directory symlink and a file symlink + // that both point outside the tree. + mkdirSync(join(ROOT, 'pkg', 'a', 'build'), { recursive: true }) + writeFileSync(join(ROOT, 'pkg', 'a', 'build', '1.out'), '') + symlinkSync(OUTSIDE, join(ROOT, 'pkg', 'a', 'build', 'link')) + symlinkSync( + join(OUTSIDE, 'key.pem'), + join(ROOT, 'pkg', 'a', 'build', 'key.pem'), + ) + // pkg/c/build/rel: the same target through a RELATIVE link. + mkdirSync(join(ROOT, 'pkg', 'c', 'build'), { recursive: true }) + writeFileSync(join(ROOT, 'pkg', 'c', 'build', '1.out'), '') + symlinkSync( + join('..', '..', '..', 'outside'), + join(ROOT, 'pkg', 'c', 'build', 'rel'), + ) + // pkg/empty/build: exists but holds nothing. + mkdirSync(join(ROOT, 'pkg', 'empty', 'build'), { recursive: true }) + // pkg/linked/build: a symlink NAMED build, to a real build dir. + mkdirSync(join(ROOT, 'pkg', 'linked')) + symlinkSync( + join(ROOT, 'pkg', 'a', 'build'), + join(ROOT, 'pkg', 'linked', 'build'), + ) + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + it('resolves a symlink inside a collapsed directory to its target', () => { + // The denyRead loop emits the covering directory's tmpfs first, which + // replaces the link with an empty directory inside the sandbox, so a + // mount kept under the link spelling would land there and hide + // nothing. The mount goes on the inode the link names instead. + const build = join(ROOT, 'pkg', 'a', 'build') + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + + expect(mounts).toContain(build) + expect(mounts).not.toContain(join(build, '1.out')) + // Directory symlink: its target is the mount, and what the listing + // found beneath the link collapses under it. + expect(mounts).toContain(OUTSIDE) + expect(mounts).not.toContain(join(build, 'link')) + expect(mounts).not.toContain(join(build, 'link', 'secret.txt')) + // File symlink: its target, already under the resolved directory. + expect(mounts).not.toContain(join(build, 'key.pem')) + expect(mounts).not.toContain(join(OUTSIDE, 'key.pem')) + }) + + it('resolves a relative directory symlink the same way', () => { + const build = join(ROOT, 'pkg', 'c', 'build') + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + + expect(mounts).toContain(build) + expect(mounts).toContain(OUTSIDE) + expect(mounts).not.toContain(join(build, 'rel')) + expect(mounts).not.toContain(join(build, 'rel', 'secret.txt')) + }) + + it('gives an empty matched directory no mount', () => { + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + expect(mounts).not.toContain(join(ROOT, 'pkg', 'empty', 'build')) + }) + + it('follows a link named like the pattern segment to a target listed earlier', () => { + // proj/config/secrets -> ../vault: the target is a real directory the + // walk reaches first by its own name, which matches nothing; the link + // is the only spelling the pattern matches, so the walk must list + // through it (a global visited set would not) and the mount must land + // on the target. + const shal = join(ROOT, 'shal') + mkdirSync(join(shal, 'proj', 'vault'), { recursive: true }) + writeFileSync(join(shal, 'proj', 'vault', 'secret.out'), '') + mkdirSync(join(shal, 'proj', 'config')) + symlinkSync(join('..', 'vault'), join(shal, 'proj', 'config', 'secrets')) + + const mounts = expandReadDenyGlobLinux(join(shal, '**/secrets/**'), []) + + expect(mounts).toEqual([join(shal, 'proj', 'vault')]) + }) + + it('denies through a link back to the tree and warns about it', () => { + // build/up -> ..: a bind mount covers the inode, so the pattern denies + // the whole tree the link reaches (as it did before the collapse); the + // walk stops at the link, and the surprise is said out loud. + const esc = join(ROOT, 'esc') + mkdirSync(join(esc, 'build'), { recursive: true }) + writeFileSync(join(esc, 'build', '1.out'), '') + symlinkSync('..', join(esc, 'build', 'up')) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const mounts = expandReadDenyGlobLinux(join(esc, '**/build/**'), []) + + expect(mounts).toEqual([esc]) + const warnings = warn.mock.calls.map(call => String(call[0])) + expect( + warnings.some( + line => + line.includes('[sandbox-runtime] WARNING') && + line.includes(join(esc, 'build', 'up')) && + line.includes(`symlink to ${esc}`), + ), + ).toBe(true) + } finally { + warn.mockRestore() + } + }) + + it('resolves a symlink named like the directory form to its target', () => { + const linked = join(ROOT, 'pkg', 'linked', 'build') + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + + expect(mounts).not.toContain(linked) + // Its entries name inodes under the real build directory, whose own + // mount covers them. + expect(mounts).not.toContain(join(linked, '1.out')) + expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) + }) + }, +) + +describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { + let ROOT: string + const PKGS = ['a', 'b', 'c'] + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-collapse-'))) + // pkg/{a,b,c}/build/{1..5}.out plus a nested dir and a source file each + for (const pkg of PKGS) { + const build = join(ROOT, 'pkg', pkg, 'build') + mkdirSync(join(build, 'nested'), { recursive: true }) + for (let i = 1; i <= 5; i++) writeFileSync(join(build, `${i}.out`), '') + writeFileSync(join(build, 'nested', 'deep.out'), '') + writeFileSync(join(ROOT, 'pkg', pkg, 'index.ts'), '') + } + // A FILE named build must not be swept up by the directory form. + writeFileSync(join(ROOT, 'pkg', 'build'), '') + // Something for an allowRead carve-out to re-expose. + mkdirSync(join(ROOT, 'pkg', 'a', 'build', 'public')) + writeFileSync(join(ROOT, 'pkg', 'a', 'build', 'public', 'ok.txt'), '') + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + it('collapses /**/build/** to one mount per build directory', () => { + const pattern = join(ROOT, '**/build/**') + // Baseline: the raw expansion is every entry beneath every build dir. + expect(expandGlobPattern(pattern).length).toBeGreaterThanOrEqual(15) + + const mounts = expandReadDenyGlobLinux(pattern, []) + + expect(mounts).toEqual(PKGS.map(pkg => join(ROOT, 'pkg', pkg, 'build'))) + expect(mounts).not.toContain(join(ROOT, 'pkg', 'build')) + }) + + it('keeps per-entry mounts under an allowRead carve-out inside a collapsed dir', () => { + const pattern = join(ROOT, '**/build/**') + const carveOut = join(ROOT, 'pkg', 'a', 'build', 'public') + + const mounts = expandReadDenyGlobLinux(pattern, [carveOut]) + + // The three build dirs still collapse everything else. + for (const pkg of PKGS) { + expect(mounts).toContain(join(ROOT, 'pkg', pkg, 'build')) + } + expect(mounts).not.toContain(join(ROOT, 'pkg', 'a', 'build', '1.out')) + expect(mounts).not.toContain(join(ROOT, 'pkg', 'b', 'build', 'nested')) + // What the carve-out re-binds keeps its own masks, exactly as before + // the collapse existed. + expect(mounts).toContain(carveOut) + expect(mounts).toContain(join(carveOut, 'ok.txt')) + }) + + it('normalizes an allowRead carve-out spelling before collapsing against it', async () => { + // Re-exposers reach expandReadDenyGlobLinux already normalized; the + // wrapper strips the trailing slash, so the carve-out still keeps the + // file's own mask beneath the collapsed build tmpfs. + const carveOut = join(ROOT, 'pkg', 'a', 'build', 'public') + try { + const wrapped = await SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + { + filesystem: { + denyRead: [join(ROOT, '**/build/**')], + allowRead: [carveOut + '/'], + allowWrite: [], + denyWrite: [], + }, + }, + ) + + expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', 'a', 'build')}`) + expect(wrapped).toContain( + `--ro-bind /dev/null ${join(carveOut, 'ok.txt')}`, + ) + expect(wrapped).not.toContain( + `--ro-bind /dev/null ${join(ROOT, 'pkg', 'b', 'build')}/`, + ) + } finally { + await SandboxManager.reset() + } + }) + + it('leaves a pattern without a trailing /** to collapse only among its own matches', () => { + // **/*.out matches files only: nothing to collapse under. + const pattern = join(ROOT, '**/*.out') + const mounts = expandReadDenyGlobLinux(pattern, []) + expect(mounts.length).toBe(expandGlobPattern(pattern).length) + expect(mounts.length).toBe(PKGS.length * 6) + }) + + it('reaches bwrap as directory tmpfs mounts, and a non-glob deny is untouched', async () => { + const literalFile = join(ROOT, 'pkg', 'a', 'index.ts') + try { + const wrapped = await SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + { + filesystem: { + denyRead: [join(ROOT, '**/build/**'), literalFile], + allowWrite: [], + denyWrite: [], + }, + }, + ) + + for (const pkg of PKGS) { + expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', pkg, 'build')}`) + } + // No per-artefact masks under the collapsed dirs. + for (const pkg of PKGS) { + expect(wrapped).not.toContain( + `--ro-bind /dev/null ${join(ROOT, 'pkg', pkg, 'build')}/`, + ) + } + // The literal entry is passed through as-is: one file mask. + expect(wrapped).toContain(`--ro-bind /dev/null ${literalFile}`) + } finally { + await SandboxManager.reset() + } + }) +}) diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts new file mode 100644 index 000000000..085b511e5 --- /dev/null +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { 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' + +/** + * A deny path strictly beneath a directory that denyWithinAllow re-binds + * read-only gets no --ro-bind of its own, under the same evidence and vetoes + * as the absent-path stub skip (readonly-deny-dir-stubs.test.ts); a deny + * equal to an allowOnly root, or with no covering directory, is still bound. + */ +describe.if(isLinux)('Deny binds under a read-only denied directory', () => { + let BASE: string + let AREA: string // allowed write area + let PROJ: string // project dir inside AREA + let FILE: string // existing file under PROJ/sub + + const savedCwd = process.cwd() + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'ro-deny-bind-'))) + AREA = join(BASE, 'area') + PROJ = join(AREA, 'proj') + FILE = join(PROJ, 'sub', 'settings.json') + mkdirSync(join(PROJ, 'sub'), { recursive: true }) + writeFileSync(FILE, '{}\n') + // Keep cwd outside the allowlist so the mandatory-deny scan adds no + // binds of its own to reason about. + process.chdir(BASE) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + // Same parameter order as readonly-deny-dir-stubs.test.ts, whose fixture + // and covering-directory predicate this suite shares. + async function wrap( + denyPaths: string[], + readDenyPaths: string[] = [], + allowPaths: string[] = [AREA], + ): Promise { + return wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: { denyOnly: readDenyPaths }, + writeConfig: { allowOnly: allowPaths, denyWithinAllow: denyPaths }, + }) + } + + const countOccurrences = (haystack: string, needle: string): number => + haystack.split(needle).length - 1 + + it('binds the denied allow-root once and skips the existing file beneath it', async () => { + // allowOnly=[proj], denyWithinAllow=[proj, proj/sub/file]: the directory + // deny equals the allow root and must still be emitted; the file is a + // strict descendant of that read-only bind and needs nothing. + const command = await wrap([PROJ, FILE], [], [PROJ]) + + expect(countOccurrences(command, `--ro-bind ${PROJ} ${PROJ}`)).toBe(1) + expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('is independent of the order the denies are listed in', async () => { + const command = await wrap([FILE, PROJ], [], [PROJ]) + + expect(countOccurrences(command, `--ro-bind ${PROJ} ${PROJ}`)).toBe(1) + expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('still binds the file when its directory is not itself denied', async () => { + const command = await wrap([FILE], [], [PROJ]) + + expect(command).toContain(`--bind ${PROJ} ${PROJ}`) + expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('collapses a chain of nested directory denies to the outermost bind', async () => { + const sub = join(PROJ, 'sub') + const command = await wrap([PROJ, sub, FILE]) + + expect(countOccurrences(command, `--ro-bind ${PROJ} ${PROJ}`)).toBe(1) + expect(command).not.toContain(`--ro-bind ${sub} ${sub}`) + expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('keeps the descendant bind when an allowed write path sits strictly beneath the covering dir (veto)', async () => { + // Same veto as the stub skip: with an allowWrite under PROJ the denyRead + // re-application machinery could re-open part of the subtree, so the + // covering bind is not trusted and the explicit deny keeps its own. + const nestedAllow = join(PROJ, 'w') + mkdirSync(nestedAllow) + + const command = await wrap([PROJ, FILE], [], [AREA, nestedAllow]) + + expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('keeps the descendant bind when a denyRead tmpfs sits under the covering dir (veto)', async () => { + const readDenied = join(PROJ, 'secrets') + mkdirSync(readDenied) + + const command = await wrap([PROJ, FILE], [readDenied]) + + expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('keeps the bind for a deny reached through a symlinked spelling', async () => { + // The re-application passes key off emitted raw spellings; a dest that + // was reached via a symlink keeps its bind so that breadcrumb survives. + const realSub = join(PROJ, 'sub') + const linkSub = join(PROJ, 'link') + symlinkSync(realSub, linkSub) + const viaLink = join(linkSub, 'settings.json') + + const command = await wrap([PROJ, viaLink]) + + expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + }) +})