From 4ddc2cfbd3d32fb8d789b42f3a9162831c201efd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 28 Aug 2026 22:04:56 -0400 Subject: [PATCH 01/23] fix(linux): collapse a read-deny glob's expansion to its covering directories A denyRead glob such as **/build/** over a monorepo expanded to one bwrap mount per matching file: hundreds of --ro-bind /dev/null masks that deny nothing a single --tmpfs over each build/ directory does not, each paid at sandbox start and all of them squeezed into the one `sh -c` argument Linux caps at 128 KiB. The expansion now collapses to one tmpfs per matched directory, keeping a file's own mount only where the directory's tmpfs would not cover it: a carve-out (allowRead/allowWrite, compared in both the given and the resolved spelling) between the two, or a symlink strictly below the covering directory, whose target is mounted instead since the tmpfs would replace the link with an empty directory. Matches otherwise keep their spelling, as literal directory denies do, so a carve-out written against a link still matches. The denyRead loop also skips an entry a tmpfs it emitted for another entry already hides. The glob walk lists one directory at a time instead of one recursive readdir, so a symlink cycle or an unreadable subtree no longer voids the pattern. --- README.md | 15 +- src/sandbox/linux-sandbox-utils.ts | 31 ++ src/sandbox/read-deny-glob.ts | 201 ++++++++++++ src/sandbox/sandbox-manager.ts | 116 ++++--- src/sandbox/sandbox-utils.ts | 165 ++++++++-- test/sandbox/glob-expand.test.ts | 63 ++++ test/sandbox/read-deny-glob.test.ts | 466 ++++++++++++++++++++++++++++ 7 files changed, 975 insertions(+), 82 deletions(-) create mode 100644 src/sandbox/read-deny-glob.ts create mode 100644 test/sandbox/read-deny-glob.test.ts diff --git a/README.md b/README.md index 14d896861..72948af83 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ 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`. +**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 +226,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 +375,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; an entry reached through a symlink keeps the link's spelling unless the covering directory's mount could not reach it, in which case the link's target is mounted instead. + +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/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e31685cfe..b165d26ef 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -18,6 +18,7 @@ import { encodeSandboxedCommand, DANGEROUS_FILES, getDangerousDirectories, + isAtOrUnder, } from './sandbox-utils.js' import type { FsReadRestrictionConfig, @@ -1489,6 +1490,24 @@ 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. 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 +1518,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 +1545,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') diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts new file mode 100644 index 000000000..dae07d08a --- /dev/null +++ b/src/sandbox/read-deny-glob.ts @@ -0,0 +1,201 @@ +import * as fs from 'node:fs' +import { logForDebugging } from '../utils/debug.js' +import { 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. + */ +export const READ_DENY_GLOB_MOUNT_WARN_THRESHOLD = 256 + +/** The nearest proper prefix of `p` (at a segment boundary) found in `set`. */ +function nearestPrefixIn( + set: ReadonlySet, + p: string, +): string | undefined { + // Proper prefixes only: slash > 0 skips p itself and the root, which the + // walk never yields. + for ( + let slash = p.lastIndexOf('/'); + slash > 0; + slash = p.lastIndexOf('/', slash - 1) + ) { + const prefix = p.slice(0, slash) + if (set.has(prefix)) return prefix + } + return undefined +} + +/** + * 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 and no + * re-exposer sits between the two. + */ +export function collapseReadDenyMounts({ + matches, + reExposedPaths, + canonical, +}: { + /** Absolute, normalized, trailing-slash-free paths, in the spelling the + * denyRead loop will mount (a symlink stays a symlink). */ + matches: readonly string[] + /** allowRead/allowWrite paths the denyRead loop re-binds over a tmpfs, in + * every spelling that can name them; one at or between a match and its + * ancestor keeps the match's own mount. */ + reExposedPaths: readonly string[] + /** The resolved path of each match that is, or lies beneath, a symlink. + * A re-exposer at or above that resolved path keeps the mount too, so a + * carve-out written in either spelling counts. */ + canonical?: ReadonlyMap +}): 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) { + 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 + } + } + const resolved = canonical?.get(candidate) + if (resolved !== undefined && !reExposedBetween) { + // Conservative on purpose: any re-exposer at or above the resolved + // path keeps the mount, at worst one redundant mount. + for ( + let end = resolved.length; + end > 0; + end = resolved.lastIndexOf('/', end - 1) + ) { + if (reExposed.has(resolved.slice(0, end))) { + reExposedBetween = true + break + } + } + } + if (ancestor === undefined || reExposedBetween) kept.add(candidate) + } + return [...kept] +} + +/** + * 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). A pattern ending in `/**` also takes its + * directory form, so `**\/build/**` yields one mount per `build/` directory. + * Matches keep their spelling, symlinks included, so a carve-out written + * against a link still matches; only a match a covering directory's tmpfs + * cannot reach (see below) is mounted at its resolved path instead. + */ +export function expandReadDenyGlobLinux( + globPattern: string, + reExposedPaths: readonly string[], +): string[] { + const directoryForm = removeTrailingGlobSuffix(globPattern) + const walk = walkGlobPattern(globPattern, { + directoryPattern: directoryForm === globPattern ? undefined : directoryForm, + }) + const matchSet = new Set(walk.matches) + 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( + walk.matches.map(m => m.slice(0, m.lastIndexOf('/'))), + ) + for (const dir of walk.directoryMatches) { + if (parents.has(dir)) matchSet.add(dir) + } + } + const matches = [...matchSet] + + const realpathOf = (p: string): string | undefined => { + try { + return fs.realpathSync(p) + } catch { + return undefined // dangling or vanished: keep the spelling + } + } + // Re-exposers in both spellings, as the write-deny pre-pass compares them. + const reExposedBothForms = new Set() + for (const p of reExposedPaths) { + reExposedBothForms.add(p) + const resolved = realpathOf(p) + if (resolved !== undefined) reExposedBothForms.add(resolved) + } + const throughSymlink = (p: string): boolean => + walk.symlinks.has(p) || nearestPrefixIn(walk.symlinks, p) !== undefined + const canonical = new Map() + for (const m of matches) { + if (!throughSymlink(m)) continue + const resolved = realpathOf(m) + if (resolved !== undefined) canonical.set(m, resolved) + } + + let mounts = collapseReadDenyMounts({ + matches, + reExposedPaths: [...reExposedBothForms], + canonical, + }) + + // A dropped match whose spelling passes through a symlink STRICTLY BELOW + // its covering directory names an inode that directory's tmpfs does not + // hide: the denyRead loop emits the covering tmpfs first, which replaces + // the link with an empty directory inside the sandbox. Mount its resolved + // path instead. A link at or above the covering directory is fine, since + // that directory's own mount already resolves through it. + const kept = new Set(mounts) + const resolvedExtras: string[] = [] + for (const m of matches) { + if (kept.has(m)) continue + const ancestor = nearestPrefixIn(kept, m) + if (ancestor === undefined) continue + let linkBetween = false + for ( + let end = m.length; + end > ancestor.length; + end = m.lastIndexOf('/', end - 1) + ) { + if (walk.symlinks.has(m.slice(0, end))) { + linkBetween = true + break + } + } + if (!linkBetween) continue + const resolved = canonical.get(m) + if (resolved !== undefined) resolvedExtras.push(resolved) + } + if (resolvedExtras.length > 0) { + mounts = collapseReadDenyMounts({ + matches: [...mounts, ...resolvedExtras], + reExposedPaths: [...reExposedBothForms], + }) + } + + 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 mount at sandbox start. ` + + `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..129b68098 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -47,6 +47,7 @@ import { type SandboxDependencyCheck, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js' +import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, @@ -80,6 +81,7 @@ import { containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, + normalizePathForSandbox, decodeSandboxedCommand, encodeSandboxedCommand, } from './sandbox-utils.js' @@ -1165,6 +1167,50 @@ 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, which recomputes the mount set. + */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { return { denyOnly: [], allowWithinDeny: [] } @@ -1180,35 +1226,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, @@ -1591,26 +1620,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 +1636,11 @@ async function wrapWithSandbox( if (javaAgentJarPath) { expandedAllowRead.push(javaAgentJarPath) } + const writeAllowOnly = writeConfig.allowOnly + const expandedDenyRead = resolveReadPathEntries(rawDenyRead, () => [ + ...expandedAllowRead, + ...writeAllowOnly, + ]) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, 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/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/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts new file mode 100644 index 000000000..e18048c47 --- /dev/null +++ b/test/sandbox/read-deny-glob.test.ts @@ -0,0 +1,466 @@ +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, or a symlink between the two would leave the covering tmpfs + * short of the inode. + */ + +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) + }) + + it('matches a re-exposer against the resolved spelling of a match reached through a link', () => { + // /r/link -> /real. The carve-out is written in the resolved spelling; + // the match under the link must still keep its own mount. + const kept = collapseReadDenyMounts({ + matches: ['/r/link', '/r/link/public', '/r/link/public/x', '/r/link/y'], + reExposedPaths: ['/real/public'], + canonical: new Map([ + ['/r/link', '/real'], + ['/r/link/public', '/real/public'], + ['/r/link/public/x', '/real/public/x'], + ['/r/link/y', '/real/y'], + ]), + }) + expect(kept).toEqual(['/r/link', '/r/link/public', '/r/link/public/x']) + }) +}) + +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)', () => { + 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('mounts a symlink beneath a collapsed directory at 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('keeps a directory symlink that is itself the covering directory on its spelling', () => { + // pkg/linked/build -> pkg/a/build: nothing above the link is denied, so + // its own tmpfs resolves through it and covers what lies beneath. The + // spelling stays, as it does for a literal directory deny, so carve-outs + // written against the link still match. + const linked = join(ROOT, 'pkg', 'linked', 'build') + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + + expect(mounts).toContain(linked) + expect(mounts).not.toContain(join(linked, '1.out')) + expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) + }) + + it('keeps a link named like the pattern segment on its spelling', () => { + // 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 lands on + // the link, which bwrap resolves. + 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', 'config', 'secrets')]) + }) + + it('denies through a link back to the tree', () => { + // build/up -> ..: the link sits beneath the covering build tmpfs, so its + // target is mounted instead, which is the whole tree the link reaches; + // the walk itself stops at the link. + const esc = join(ROOT, 'esc') + mkdirSync(join(esc, 'build'), { recursive: true }) + writeFileSync(join(esc, 'build', '1.out'), '') + symlinkSync('..', join(esc, 'build', 'up')) + + const mounts = expandReadDenyGlobLinux(join(esc, '**/build/**'), []) + + expect(mounts).toEqual([esc]) + }) + + describe('carve-out through a symlink (pnpm layout)', () => { + // node_modules/foo -> ../.pnpm/foo@1/node_modules/foo, the shape pnpm + // installs; the glob matches both the link and the real tree. + let P: string + let real: string + let link: string + beforeAll(() => { + P = join(ROOT, 'pnpm') + real = join(P, '.pnpm', 'foo@1', 'node_modules', 'foo') + link = join(P, 'node_modules', 'foo') + mkdirSync(join(real, 'public'), { recursive: true }) + writeFileSync(join(real, 'index.js'), '') + writeFileSync(join(real, 'public', 'ok.txt'), '') + mkdirSync(join(P, 'node_modules')) + symlinkSync(join('..', '.pnpm', 'foo@1', 'node_modules', 'foo'), link) + }) + + it('keeps the carve-out written against the link spelling', () => { + const mounts = expandReadDenyGlobLinux( + join(P, '**/node_modules/foo/**'), + [join(link, 'public')], + ) + + expect(mounts).toContain(link) + expect(mounts).not.toContain(join(link, 'index.js')) + expect(mounts).toContain(join(link, 'public')) + expect(mounts).toContain(join(link, 'public', 'ok.txt')) + // The real tree, matched in its own right, keeps its carve-out too. + expect(mounts).toContain(real) + expect(mounts).toContain(join(real, 'public', 'ok.txt')) + }) + + it('keeps the carve-out written against the resolved spelling', () => { + const mounts = expandReadDenyGlobLinux( + join(P, '**/node_modules/foo/**'), + [join(real, 'public')], + ) + + expect(mounts).toContain(link) + expect(mounts).not.toContain(join(link, 'index.js')) + expect(mounts).toContain(join(link, 'public')) + expect(mounts).toContain(join(link, 'public', 'ok.txt')) + }) + }) +}) + +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() + } + }) +}) From e025ea8603b1dbab8102304fcf35f2e39e3ecb0f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 29 Aug 2026 02:36:29 -0400 Subject: [PATCH 02/23] fix(linux): mount a symlinked read-deny directory at its target and match carve-outs in both spellings bubblewrap 0.12 refuses a mount on a symlink destination and earlier releases abort on an absolute link in one, so a directory deny's tmpfs lands on the resolved path. Entries are ordered by where the mount lands, re-exposers are compared in both spellings at emission, the second spelling of a covered inode is skipped, a file carve-out matches in either spelling, an allowRead that is itself a link is bound at its target, and a tmpfs shadowed by a denyWrite bind over its target is re-applied. A glob lists every match reached through a link in its resolved spelling too, leaves a link to / alone, and counts a cycle-blocked directory link as a match; the walk does not descend reparse points on Windows. --- README.md | 10 +- src/sandbox/linux-sandbox-utils.ts | 231 ++++++++++---- src/sandbox/read-deny-glob.ts | 115 +++---- src/sandbox/sandbox-utils.ts | 45 ++- test/sandbox/glob-expand.test.ts | 32 +- test/sandbox/read-deny-glob.test.ts | 373 ++++++++++++++++++++-- test/sandbox/symlinked-deny-paths.test.ts | 12 +- 7 files changed, 627 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index 72948af83..94b1530dc 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ 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`. +**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,10 +226,7 @@ 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 @@ -378,7 +375,8 @@ Examples: 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; an entry reached through a symlink keeps the link's spelling unless the covering directory's mount could not reach it, in which case the link's target is mounted instead. +- `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, except beneath a directory a `denyRead` pattern ending in `/**` matched: such a directory becomes one tmpfs mount rather than one mount per file beneath it, with a literal directory deny's semantics — inside the sandbox it is empty and writable, writes into it stay in the tmpfs and never reach the host, and a file added to it later on the host is hidden too. An `allowRead` beneath it is bound back over the tmpfs; the entries beneath that carve-out which the pattern matched keep their own masks, as they did before. Symlinked directories are descended, and an entry reached through a symlink is denied at the link's target as well (a link back up the tree denies everything the link reaches, as a literal deny of the link would; a link to `/` is left alone). A carve-out beneath a link applies whichever spelling it is written in; an `allowRead` that is itself a symlink is bound at its target. +- A directory `denyRead` whose path is a symlink — literal, or matched by a pattern — is mounted at the link's target (bubblewrap refuses to mount on a symlink), and every other rule treats a directory a `/**` pattern matched exactly as a directory listed in `denyRead` literally. Examples: diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index b165d26ef..660459d0b 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -17,8 +17,8 @@ import { isSymlinkOutsideBoundary, encodeSandboxedCommand, DANGEROUS_FILES, - getDangerousDirectories, isAtOrUnder, + getDangerousDirectories, } from './sandbox-utils.js' import type { FsReadRestrictionConfig, @@ -821,15 +821,15 @@ function buildSandboxCommand( /** * bwrap cannot create a file bind mount point over a destination that is * itself a symlink — `--ro-bind /dev/null ` fails with "Can't create - * file at " and the whole command refuses to start. File read-deny - * binds therefore target the symlink's resolved target instead: reads - * through the symlink resolve to that target inside the mount namespace, so - * the denied content stays covered. This matters for credential dotfiles - * (~/.netrc, ~/.npmrc, …) that are commonly symlinks into a dotfile - * manager's directory. Directory denies (`--tmpfs`) are left on the original - * path: bwrap accepts those, and rewriting them would break allowRead - * carve-outs expressed against the symlink path (e.g. /bin on usr-merged - * systems). + * file at " (0.12 and later: "Can't mount on symlink destination") and + * the whole command refuses to start. File read-deny binds therefore target + * the symlink's resolved target instead: reads through the symlink resolve + * to that target inside the mount namespace, so the denied content stays + * covered. This matters for credential dotfiles (~/.netrc, ~/.npmrc, …) + * that are commonly symlinks into a dotfile manager's directory. A link + * higher up the path is left alone: bwrap resolves it inside the sandbox, + * where a covering tmpfs may have replaced it with a directory an allowRead + * bind recreated, and the mask must land in that spelling too. */ function resolveSymlinkDenyDest(normalizedPath: string): string { try { @@ -843,24 +843,51 @@ function resolveSymlinkDenyDest(normalizedPath: string): string { } /** - * Mount a tmpfs over a read-denied directory, then restore the allowed write - * paths and allowRead paths the tmpfs just wiped. Used by the denyRead loop - * in generateFilesystemArgs and again when a late denyWrite ro-bind re-exposes + * `p` with every symlink resolved: where a directory deny's tmpfs lands + * (bwrap refuses a mount whose destination is a symlink, and releases before + * 0.12 abort on an absolute link anywhere in a destination, which they + * resolve against the new root), and the second spelling every comparison + * between a deny mount and a re-exposing path runs over. A carve-out + * written against a link (allowRead: node_modules/foo/public under a pnpm + * link, /bin/bash on a usr-merged system) still applies for that reason. + * `p` itself when it cannot be resolved, or resolves to the root: a tmpfs + * over / would hide everything, and bwrap refuses the link instead. + */ +function resolveEverySymlink(p: string): string { + try { + const resolved = fs.realpathSync(p) + if (resolved !== '/') return resolved + } catch { + // Dangling symlink or vanished path — keep the original. + } + return p +} + +/** + * Mount a tmpfs over a read-denied directory (at `dest`, its resolved + * path), then restore the allowed write paths and allowRead paths the tmpfs + * just wiped: those at or under the directory in either spelling + * (`covers`), re-bound where `bindDestFor` says — the spelling the config + * named, which bwrap resolves inside the sandbox and recreates where a link + * the tmpfs replaced no longer exists, or the target of a path that is + * itself a still-present symlink. Used by the denyRead loop in + * generateFilesystemArgs and again when a late denyWrite ro-bind re-exposes * a read-denied directory and the tmpfs must be re-applied on top. */ function pushReadDenyDirMounts( args: string[], - normalizedPath: string, + { spelling, dest }: { spelling: string; dest: string }, allowedWritePaths: string[], readAllowPaths: string[], + covers: (p: string, dir: string) => boolean, + bindDestFor: (p: string) => string, ): void { - const denySep = normalizedPath === '/' ? '/' : normalizedPath + '/' - args.push('--tmpfs', normalizedPath) + args.push('--tmpfs', dest) // tmpfs wiped any earlier write binds under this path — restore them. for (const writePath of allowedWritePaths) { - if (writePath.startsWith(denySep) || writePath === normalizedPath) { - args.push('--bind', writePath, writePath) + if (covers(writePath, spelling)) { + args.push('--bind', writePath, bindDestFor(writePath)) logForDebugging( `[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${writePath}`, ) @@ -871,7 +898,7 @@ function pushReadDenyDirMounts( // After mounting tmpfs over the denied dir, bind back the allowed subdirectories // so they are readable again. for (const allowPath of readAllowPaths) { - if (allowPath.startsWith(denySep) || allowPath === normalizedPath) { + if (covers(allowPath, spelling)) { if (!fs.existsSync(allowPath)) { logForDebugging( `[Sandbox Linux] Skipping non-existent read allow path: ${allowPath}`, @@ -883,16 +910,12 @@ function pushReadDenyDirMounts( // re-bound (it wasn't wiped), so allowPath under it still needs // its own ro-bind here. if ( - allowedWritePaths.some( - w => - (w.startsWith(denySep) || w === normalizedPath) && - (allowPath === w || allowPath.startsWith(w + '/')), - ) + allowedWritePaths.some(w => covers(w, spelling) && covers(allowPath, w)) ) { continue } // Bind the allowed path back over the tmpfs so it's readable - args.push('--ro-bind', allowPath, allowPath) + args.push('--ro-bind', allowPath, bindDestFor(allowPath)) logForDebugging( `[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`, ) @@ -1446,16 +1469,55 @@ async function generateFilesystemArgs( const readAllowPaths = (readConfig?.allowWithinDeny || []).map(p => normalizePathForSandbox(p), ) + // A path and, when a symlink sits anywhere in it, its resolved form: the + // spelling the config named and the inode bwrap mounts. Every comparison + // between a read-deny mount and the paths that re-expose contents beneath + // it runs over both, so a carve-out written against a link or against + // its target both count (allowRead: node_modules/foo/public against a + // pnpm link, /bin/bash against /usr/bin on a usr-merged system). + const bothFormsCache = new Map() + const bothForms = (p: string): readonly string[] => { + let forms = bothFormsCache.get(p) + if (forms === undefined) { + const resolved = resolveEverySymlink(p) + forms = resolved === p ? [p] : [p, resolved] + bothFormsCache.set(p, forms) + } + return forms + } + const atOrUnderEitherForm = (p: string, dir: string): boolean => + bothForms(p).some(pf => bothForms(dir).some(df => isAtOrUnder(pf, df))) // Files masked by --ro-bind below. Map of dest → source // (/dev/null for read-deny, the sentinel fake for credential mask). Used // to filter denyWriteArgs so that --ro-bind doesn't undo // the mask, and to re-apply the correct source if a denyWrite ancestor // bind re-exposes the dest. const maskedFiles = new Map() - // Directories masked by --tmpfs below, in emission (shallow-first) order. - // Used to filter denyWriteArgs the same way: a dir in both deny lists must - // not get its host contents re-bound on top of its own tmpfs. + // Directories masked by --tmpfs below, in emission (shallow-first) order, + // by the spelling the config named; tmpfsDests holds where each landed + // (resolveEverySymlink). Used to filter denyWriteArgs the same way: a + // dir in both deny lists must not get its host contents re-bound on top + // of its own tmpfs. const tmpfsDirs: string[] = [] + const tmpfsDests = new Map() + // Where a re-bind of an allowed path lands. A path that is itself a + // symlink is bound at its target while the link still exists inside the + // sandbox (bwrap refuses a symlink as a destination); once a covering + // tmpfs has wiped the link's parent, the spelling is bound instead and + // bwrap recreates the path there. + const reBindDestFor = (p: string): string => { + let isLink = false + try { + isLink = fs.lstatSync(p).isSymbolicLink() + } catch { + // Absent: bound as spelled (and skipped by the existence check). + } + if (!isLink) return p + const parentWiped = tmpfsDirs.some(t => + atOrUnderEitherForm(path.dirname(p), t), + ) + return parentWiped ? p : resolveEverySymlink(p) + } // --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds). // Expand a root deny into its direct children so the existing per-dir tmpfs @@ -1486,25 +1548,33 @@ async function generateFilesystemArgs( // Normalize then sort shallow-first so tmpfs over ancestor dirs lands before // /dev/null masks on descendant files. Otherwise a file-deny listed before // a dir-deny in denyRead gets wiped when the ancestor tmpfs is applied. + // Depth is measured where the mount lands (a directory's tmpfs goes on its + // resolved path), so a symlink deny spelled shallow still follows a + // separately denied ancestor of its target. + const landingDepth = (p: string): number => { + const forms = bothForms(p) + return forms[forms.length - 1]!.split('/').length + } const normalizedDenyPaths = readDenyPaths .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. The same question isHiddenByTmpfs below - // asks of the buffered denyWrite binds. + .sort((a, b) => landingDepth(a) - landingDepth(b)) + + // A read-deny path at-or-under a tmpfs this loop already emitted (the + // shallow-first order above visits the covering directory first), in + // either spelling, is hidden by it unless an allowRead/allowWrite path + // at-or-under that tmpfs and at-or-above the path 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, a symlink plus its target) would otherwise cost one bwrap + // mount per file. The same question isHiddenByTmpfs below asks of the + // buffered denyWrite binds. const readDenyReExposers = [...allowedWritePaths, ...readAllowPaths] - const hiddenByEmittedTmpfs = (dest: string): boolean => + const hiddenByEmittedTmpfs = (p: string): boolean => tmpfsDirs.some( tmpfsDir => - isAtOrUnder(dest, tmpfsDir) && + atOrUnderEitherForm(p, tmpfsDir) && !readDenyReExposers.some( - p => isAtOrUnder(p, tmpfsDir) && isAtOrUnder(dest, p), + r => atOrUnderEitherForm(r, tmpfsDir) && atOrUnderEitherForm(p, r), ), ) @@ -1518,25 +1588,42 @@ async function generateFilesystemArgs( const readDenyStat = fs.statSync(normalizedPath) if (readDenyStat.isDirectory()) { - if (hiddenByEmittedTmpfs(normalizedPath)) { + // The tmpfs lands on the resolved path: bwrap refuses a symlink as a + // mount destination (resolveEverySymlink). Whether an earlier tmpfs + // already hides it is a question about that landing, not the + // spelling: a link nested under a denied directory still needs its + // own mount where its target lies outside every tmpfs. + const dest = resolveEverySymlink(normalizedPath) + if ( + [...tmpfsDests.values()].includes(dest) || + hiddenByEmittedTmpfs(dest) + ) { logForDebugging( `[Sandbox Linux] Skipping read deny directory already hidden by a denyRead tmpfs: ${normalizedPath}`, ) continue } tmpfsDirs.push(normalizedPath) + tmpfsDests.set(normalizedPath, dest) pushReadDenyDirMounts( args, - normalizedPath, + { spelling: normalizedPath, dest }, allowedWritePaths, readAllowPaths, + atOrUnderEitherForm, + reBindDestFor, ) } else { - // For files, only an exact allowRead match overrides the deny. A - // directory allowRead does not un-deny a file specifically listed in - // denyRead — otherwise denyRead: ['.env'] + allowRead: ['.'] silently - // drops the .env deny. - if (readAllowPaths.includes(normalizedPath)) { + // For files, only an exact allowRead match overrides the deny, in + // either spelling (a glob lists a file reached through a link under + // its target's spelling too). A directory allowRead does not un-deny a + // file specifically listed in denyRead — otherwise denyRead: ['.env'] + // + allowRead: ['.'] silently drops the .env deny. + if ( + readAllowPaths.some(allowPath => + bothForms(allowPath).some(f => bothForms(normalizedPath).includes(f)), + ) + ) { logForDebugging( `[Sandbox Linux] Skipping read deny for re-allowed path: ${normalizedPath}`, ) @@ -1544,10 +1631,14 @@ 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. + // A file that is itself a symlink to an already masked one needs + // nothing; a plain file under a directory link is masked once per + // spelling, since a carve-out re-bind may have recreated the link's + // path as a directory of its own. const denyDest = resolveSymlinkDenyDest(normalizedPath) - if (hiddenByEmittedTmpfs(denyDest)) { + if (maskedFiles.has(denyDest) || hiddenByEmittedTmpfs(normalizedPath)) { logForDebugging( - `[Sandbox Linux] Skipping read deny file already hidden by a denyRead tmpfs: ${denyDest}`, + `[Sandbox Linux] Skipping read deny file already masked or hidden by a denyRead tmpfs: ${normalizedPath}`, ) continue } @@ -1593,12 +1684,11 @@ async function generateFilesystemArgs( // either means the tmpfs really does cover this bind. const isHiddenByTmpfs = (dest: string): boolean => tmpfsDirs.some(tmpfsDir => { - const underTmpfs = dest === tmpfsDir || dest.startsWith(tmpfsDir + '/') - if (!underTmpfs) return false + if (!atOrUnderEitherForm(dest, tmpfsDir)) return false const reExposedByWriteBind = allowedWritePaths.some( writePath => - (writePath === tmpfsDir || writePath.startsWith(tmpfsDir + '/')) && - (dest === writePath || dest.startsWith(writePath + '/')), + atOrUnderEitherForm(writePath, tmpfsDir) && + atOrUnderEitherForm(dest, writePath), ) return !reExposedByWriteBind }) @@ -1631,7 +1721,38 @@ async function generateFilesystemArgs( logForDebugging( `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${tmpfsDir}`, ) - pushReadDenyDirMounts(args, tmpfsDir, allowedWritePaths, readAllowPaths) + pushReadDenyDirMounts( + args, + { spelling: tmpfsDir, dest: tmpfsDests.get(tmpfsDir)! }, + allowedWritePaths, + readAllowPaths, + atOrUnderEitherForm, + reBindDestFor, + ) + } + } + // A tmpfs whose spelling was a symlink sits at the link's target; a + // denyWrite bind that contains the target but not the spelling shadows it + // just the same, and the loop above only saw the spelling. + for (const [spelling, dest] of tmpfsDests) { + if (dest === spelling) continue + if ( + emittedDenyWriteDests.some(w => w !== dest && isAtOrUnder(dest, w)) && + !emittedDenyWriteDests.some( + w => w !== spelling && isAtOrUnder(spelling, w), + ) + ) { + logForDebugging( + `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind over its target: ${dest}`, + ) + pushReadDenyDirMounts( + args, + { spelling, dest }, + allowedWritePaths, + readAllowPaths, + atOrUnderEitherForm, + reBindDestFor, + ) } } // Same problem for masked files: the mask landed before the denyWrite diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index dae07d08a..27ed8e09a 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -31,24 +31,19 @@ function nearestPrefixIn( * 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 and no - * re-exposer sits between the two. + * re-exposer sits between the two (one at the ancestor counts: the deny + * loop re-binds it over the tmpfs, so everything beneath needs its own). */ export function collapseReadDenyMounts({ matches, reExposedPaths, - canonical, }: { - /** Absolute, normalized, trailing-slash-free paths, in the spelling the - * denyRead loop will mount (a symlink stays a symlink). */ + /** Absolute, normalized, trailing-slash-free paths; a match reached + * through a symlink appears in both its spellings. */ matches: readonly string[] /** allowRead/allowWrite paths the denyRead loop re-binds over a tmpfs, in - * every spelling that can name them; one at or between a match and its - * ancestor keeps the match's own mount. */ + * every spelling that can name them. */ reExposedPaths: readonly string[] - /** The resolved path of each match that is, or lies beneath, a symlink. - * A re-exposer at or above that resolved path keeps the mount too, so a - * carve-out written in either spelling counts. */ - canonical?: ReadonlyMap }): string[] { const reExposed = new Set(reExposedPaths) // A proper ancestor is a proper string prefix, so lexicographic order @@ -70,21 +65,6 @@ export function collapseReadDenyMounts({ break } } - const resolved = canonical?.get(candidate) - if (resolved !== undefined && !reExposedBetween) { - // Conservative on purpose: any re-exposer at or above the resolved - // path keeps the mount, at worst one redundant mount. - for ( - let end = resolved.length; - end > 0; - end = resolved.lastIndexOf('/', end - 1) - ) { - if (reExposed.has(resolved.slice(0, end))) { - reExposedBetween = true - break - } - } - } if (ancestor === undefined || reExposedBetween) kept.add(candidate) } return [...kept] @@ -96,9 +76,15 @@ export function collapseReadDenyMounts({ * allowRead and allowWrite entries, already put through * normalizePathForSandbox). A pattern ending in `/**` also takes its * directory form, so `**\/build/**` yields one mount per `build/` directory. - * Matches keep their spelling, symlinks included, so a carve-out written - * against a link still matches; only a match a covering directory's tmpfs - * cannot reach (see below) is mounted at its resolved path instead. + * + * Symlinks: a match keeps its spelling, so a carve-out written against a + * link still matches, and a match that reaches its inode through a link + * (one the walk descended, or one above the walk) is listed in its resolved + * spelling as well, so a carve-out written against the target matches too + * and the target stays denied where the link itself vanishes (a covering + * directory's tmpfs replaces the links beneath it with nothing). The deny + * loop mounts each entry at its resolved path, compares re-exposers in both + * spellings, and skips the second spelling of an inode it has covered. */ export function expandReadDenyGlobLinux( globPattern: string, @@ -108,7 +94,7 @@ export function expandReadDenyGlobLinux( const walk = walkGlobPattern(globPattern, { directoryPattern: directoryForm === globPattern ? undefined : directoryForm, }) - const matchSet = new Set(walk.matches) + const candidates = new Set(walk.matches) 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 @@ -118,10 +104,12 @@ export function expandReadDenyGlobLinux( walk.matches.map(m => m.slice(0, m.lastIndexOf('/'))), ) for (const dir of walk.directoryMatches) { - if (parents.has(dir)) matchSet.add(dir) + // A directory-form match that is a symlink counts in its own right: + // one the walk did not descend (a link back into its own ancestry) + // has no match beneath it, yet denies everything it reaches. + if (parents.has(dir) || walk.symlinks.has(dir)) candidates.add(dir) } } - const matches = [...matchSet] const realpathOf = (p: string): string | undefined => { try { @@ -130,62 +118,39 @@ export function expandReadDenyGlobLinux( return undefined // dangling or vanished: keep the spelling } } - // Re-exposers in both spellings, as the write-deny pre-pass compares them. + // Re-exposers in both spellings, as the deny loop compares them. const reExposedBothForms = new Set() for (const p of reExposedPaths) { reExposedBothForms.add(p) const resolved = realpathOf(p) if (resolved !== undefined) reExposedBothForms.add(resolved) } - const throughSymlink = (p: string): boolean => + // Resolved spellings: a realpath for a match under a link the walk + // descended, a string swap for one under a symlinked base. + const throughWalkLink = (p: string): boolean => walk.symlinks.has(p) || nearestPrefixIn(walk.symlinks, p) !== undefined - const canonical = new Map() - for (const m of matches) { - if (!throughSymlink(m)) continue - const resolved = realpathOf(m) - if (resolved !== undefined) canonical.set(m, resolved) + const baseSwapped = walk.baseReal !== walk.baseDir + for (const m of [...candidates]) { + let resolved: string | undefined + if (throughWalkLink(m)) resolved = realpathOf(m) + else if (baseSwapped) + resolved = walk.baseReal + m.slice(walk.baseDir.length) + if (resolved === '/') { + // A link to the root: a tmpfs there would hide everything. The + // spelling stays, and bwrap refuses to mount on the link. + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${m} resolves to /, not denied at its target`, + ) + continue + } + if (resolved !== undefined) candidates.add(resolved) } - let mounts = collapseReadDenyMounts({ - matches, + const mounts = collapseReadDenyMounts({ + matches: [...candidates], reExposedPaths: [...reExposedBothForms], - canonical, }) - // A dropped match whose spelling passes through a symlink STRICTLY BELOW - // its covering directory names an inode that directory's tmpfs does not - // hide: the denyRead loop emits the covering tmpfs first, which replaces - // the link with an empty directory inside the sandbox. Mount its resolved - // path instead. A link at or above the covering directory is fine, since - // that directory's own mount already resolves through it. - const kept = new Set(mounts) - const resolvedExtras: string[] = [] - for (const m of matches) { - if (kept.has(m)) continue - const ancestor = nearestPrefixIn(kept, m) - if (ancestor === undefined) continue - let linkBetween = false - for ( - let end = m.length; - end > ancestor.length; - end = m.lastIndexOf('/', end - 1) - ) { - if (walk.symlinks.has(m.slice(0, end))) { - linkBetween = true - break - } - } - if (!linkBetween) continue - const resolved = canonical.get(m) - if (resolved !== undefined) resolvedExtras.push(resolved) - } - if (resolvedExtras.length > 0) { - mounts = collapseReadDenyMounts({ - matches: [...mounts, ...resolvedExtras], - reExposedPaths: [...reExposedBothForms], - }) - } - logForDebugging( `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.length} mounts`, ) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index e7e5c5161..3f93aed7a 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -888,10 +888,16 @@ export interface GlobWalk { /** 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. */ + /** Every visited entry that is a symbolic link, by full path. The walk + * descends into symlinked directories, so a match beneath one really + * lives outside the tree it was found in. */ symlinks: Set + /** The directory listed (the pattern's static prefix) and its resolved + * form; they differ when a symlink sits above the walk, in which case + * every match has a second, resolved spelling. Empty when nothing was + * listed. */ + baseDir: string + baseReal: string } /** @@ -925,6 +931,8 @@ export function walkGlobPattern( matches: [], directoryMatches: [], symlinks: new Set(), + baseDir: '', + baseReal: '', } // Normalize to `/` separators throughout so {@link globToRegex} @@ -954,6 +962,7 @@ export function walkGlobPattern( ) return walk } + walk.baseDir = baseDir const flags = opts.caseInsensitive ? 'i' : '' const regex = new RegExp(globToRegex(normalizedPattern), flags) @@ -967,18 +976,19 @@ export function walkGlobPattern( // 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 + // one unreadable subtree — or a symlink cycle, which Bun's throws ELOOP + // on and Node's (22.13 and later) follows to the kernel's link limit, + // listing some forty phantom copies — would void or bloat 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. + // directories are descended like any other on every runtime (Node before + // 22.13 never descended one; 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. */ @@ -992,6 +1002,7 @@ export function walkGlobPattern( } catch { // Vanished between the existence check and here: list what remains. } + walk.baseReal = baseReal const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] while (pending.length > 0) { const { dir, real, linkedFrom } = pending.pop()! @@ -1023,6 +1034,12 @@ export function walkGlobPattern( } if (!entry.isSymbolicLink()) continue walk.symlinks.add(fullPath) + if (process.platform === 'win32') { + // A reparse point (junction, directory symlink) is listed but not + // descended, as readdirSync's recursive listing never did on + // Windows; the cycle check below also speaks POSIX separators. + continue + } // A link pays a stat and, when it leads to a directory, a realpath. let target: string | undefined try { diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index eca1a7217..1eb34b3ee 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -202,12 +202,36 @@ describe.if(!isWindows)('walkGlobPattern', () => { 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')]) + expect(walk.baseDir).toBe(BASE) + expect(walk.baseReal).toBe(BASE) + }) + + it('reports a symlinked base in both spellings', () => { + // alias -> the tree, sideways: normalizePathForSandbox keeps the link + // spelling for the pattern, so every match is spelled through it and + // the walk reports where it really is. + const BASE = realPath(RAW_BASE) + const alias = join( + RAW_BASE, + '..', + 'alias-' + Math.random().toString(36).slice(2), + ) + symlinkSync(BASE, alias) + try { + const walk = walkGlobPattern(join(alias, '**/build/**')) + expect(walk.baseDir).toBe(alias) + expect(walk.baseReal).toBe(BASE) + expect(walk.matches).toContain(join(alias, 'a', 'build', '1.out')) + } finally { + rmSync(alias) + } }) 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. + // build/up -> .. : a recursive readdir throws ELOOP (Bun) or follows + // the link to the kernel's limit and lists ~40 phantom copies (Node + // 22.13+); 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'), '') @@ -232,6 +256,8 @@ describe.if(!isWindows)('walkGlobPattern', () => { matches: [], directoryMatches: [], symlinks: new Set(), + baseDir: '', + baseReal: '', }) }) }) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index e18048c47..9ce237dfa 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' import { + lstatSync, mkdirSync, mkdtempSync, realpathSync, @@ -7,6 +8,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -16,14 +18,19 @@ import { } from '../../src/sandbox/read-deny-glob.js' import { expandGlobPattern } from '../../src/sandbox/sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.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, or a symlink between the two would leave the covering tmpfs - * short of the inode. + * it readable. A match reached through a symlink is listed in its resolved + * spelling too, so the inode stays denied where the link itself vanishes, + * and the deny loop mounts every directory at its resolved path. */ describe('collapseReadDenyMounts (pure)', () => { @@ -94,22 +101,6 @@ describe('collapseReadDenyMounts (pure)', () => { collapseReadDenyMounts({ matches: files, reExposedPaths: [] }), ).toEqual(files) }) - - it('matches a re-exposer against the resolved spelling of a match reached through a link', () => { - // /r/link -> /real. The carve-out is written in the resolved spelling; - // the match under the link must still keep its own mount. - const kept = collapseReadDenyMounts({ - matches: ['/r/link', '/r/link/public', '/r/link/public/x', '/r/link/y'], - reExposedPaths: ['/real/public'], - canonical: new Map([ - ['/r/link', '/real'], - ['/r/link/public', '/real/public'], - ['/r/link/public/x', '/real/public/x'], - ['/r/link/y', '/real/y'], - ]), - }) - expect(kept).toEqual(['/r/link', '/r/link/public', '/r/link/public/x']) - }) }) describe.if(!isWindows)('expandReadDenyGlobLinux (warn threshold)', () => { @@ -219,7 +210,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // 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. + // nothing. The target is listed in its own right and kept instead. const build = join(ROOT, 'pkg', 'a', 'build') const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) @@ -235,6 +226,25 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).not.toContain(join(OUTSIDE, 'key.pem')) }) + it('keeps the resolved carve-out beneath a link strictly below the covering directory', () => { + // pkg/a/build/link -> outside, with allowRead written against the + // target: outside/ is denied as a whole, its carve-out and the + // entries beneath keep their own mounts, and nothing else beneath it. + mkdirSync(join(OUTSIDE, 'pub')) + writeFileSync(join(OUTSIDE, 'pub', 'x.txt'), '') + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'pkg', 'a', '**/build/**'), + [join(OUTSIDE, 'pub')], + ) + + expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) + expect(mounts).toContain(OUTSIDE) + expect(mounts).toContain(join(OUTSIDE, 'pub')) + expect(mounts).toContain(join(OUTSIDE, 'pub', 'x.txt')) + expect(mounts).not.toContain(join(OUTSIDE, 'secret.txt')) + rmSync(join(OUTSIDE, 'pub'), { recursive: true }) + }) + it('resolves a relative directory symlink the same way', () => { const build = join(ROOT, 'pkg', 'c', 'build') const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) @@ -250,11 +260,11 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).not.toContain(join(ROOT, 'pkg', 'empty', 'build')) }) - it('keeps a directory symlink that is itself the covering directory on its spelling', () => { - // pkg/linked/build -> pkg/a/build: nothing above the link is denied, so - // its own tmpfs resolves through it and covers what lies beneath. The - // spelling stays, as it does for a literal directory deny, so carve-outs - // written against the link still match. + it('lists a directory symlink that is itself the covering directory in both spellings', () => { + // pkg/linked/build -> pkg/a/build: the link spelling stays, as it does + // for a literal directory deny, so carve-outs written against the link + // still match; the deny loop mounts it at the target and drops the + // second spelling of the same inode. const linked = join(ROOT, 'pkg', 'linked', 'build') const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) @@ -263,12 +273,12 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) }) - it('keeps a link named like the pattern segment on its spelling', () => { + it('lists a link named like the pattern segment with its target', () => { // 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 lands on - // the link, which bwrap resolves. + // through it (a global visited set would not). The target is where the + // mount lands. const shal = join(ROOT, 'shal') mkdirSync(join(shal, 'proj', 'vault'), { recursive: true }) writeFileSync(join(shal, 'proj', 'vault', 'secret.out'), '') @@ -277,13 +287,74 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { const mounts = expandReadDenyGlobLinux(join(shal, '**/secrets/**'), []) - expect(mounts).toEqual([join(shal, 'proj', 'config', 'secrets')]) + expect(mounts).toEqual([ + join(shal, 'proj', 'config', 'secrets'), + join(shal, 'proj', 'vault'), + ]) + }) + + it('lists every match in its resolved spelling too when the base is a symlink', () => { + // alias -> ROOT, sideways: normalizePathForSandbox keeps the link + // spelling for the pattern, so a carve-out written in ROOT spelling + // would match nothing without the resolved twins. + const alias = join( + ROOT, + '..', + `alias-${Math.random().toString(36).slice(2)}`, + ) + symlinkSync(ROOT, alias) + try { + const build = join('pkg', 'a', 'build') + const carveOut = join(ROOT, build, 'public') + mkdirSync(carveOut, { recursive: true }) + writeFileSync(join(carveOut, 'ok.txt'), '') + const mounts = expandReadDenyGlobLinux(join(alias, '**/build/**'), [ + carveOut, + ]) + + expect(mounts).toContain(join(alias, build)) + expect(mounts).toContain(join(ROOT, build)) + expect(mounts).toContain(carveOut) + expect(mounts).toContain(join(carveOut, 'ok.txt')) + expect(mounts).not.toContain(join(alias, build, '1.out')) + } finally { + rmSync(alias) + rmSync(join(ROOT, 'pkg', 'a', 'build', 'public'), { recursive: true }) + } + }) + + it('never denies the root through a link to it', () => { + // build/root -> /: the resolved spelling would expand to a tmpfs over + // every top-level directory. The link keeps its spelling; bwrap refuses + // to mount on it. + const rooted = join(ROOT, 'rooted') + mkdirSync(join(rooted, 'build'), { recursive: true }) + writeFileSync(join(rooted, 'build', '1.out'), '') + symlinkSync('/', join(rooted, 'build', 'root')) + + const mounts = expandReadDenyGlobLinux(join(rooted, '**/build/**'), []) + + expect(mounts).toEqual([join(rooted, 'build')]) + }) + + it('denies the target of a directory-form link the walk did not descend', () => { + // u/x/y/build -> u/x names a directory on its own descent chain, so the + // walk lists nothing beneath it; it is still a match, and its target is + // what a literal deny of the link would deny. + const u = join(ROOT, 'u') + mkdirSync(join(u, 'x', 'y'), { recursive: true }) + writeFileSync(join(u, 'x', 'src.ts'), '') + symlinkSync(join('..'), join(u, 'x', 'y', 'build')) + + const mounts = expandReadDenyGlobLinux(join(u, '**/build/**'), []) + + expect(mounts).toContain(join(u, 'x')) }) it('denies through a link back to the tree', () => { - // build/up -> ..: the link sits beneath the covering build tmpfs, so its - // target is mounted instead, which is the whole tree the link reaches; - // the walk itself stops at the link. + // build/up -> ..: the target is the whole tree the link reaches, as a + // literal deny of the link would have it; the walk itself stops at the + // link. const esc = join(ROOT, 'esc') mkdirSync(join(esc, 'build'), { recursive: true }) writeFileSync(join(esc, 'build', '1.out'), '') @@ -334,12 +405,248 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).toContain(link) expect(mounts).not.toContain(join(link, 'index.js')) - expect(mounts).toContain(join(link, 'public')) - expect(mounts).toContain(join(link, 'public', 'ok.txt')) + expect(mounts).toContain(real) + expect(mounts).toContain(join(real, 'public')) + expect(mounts).toContain(join(real, 'public', 'ok.txt')) + }) + + it('is not defeated by a re-exposer above the covering directory', () => { + // allowWrite ['.'] (the README's example) names the project root, + // which re-exposes nothing beneath a tmpfs, so the package still + // collapses to its two spellings. + const mounts = expandReadDenyGlobLinux( + join(P, '**/node_modules/foo/**'), + [P], + ) + + expect(mounts).toEqual([real, link]) }) }) }) +describe.if(isLinux)( + 'expandReadDenyGlobLinux (bwrap wiring through a symlink)', + () => { + // The pnpm layout again, driven through the real Linux wrapper: no tmpfs + // may land on a symlink (bubblewrap 0.12 refuses to start), and the + // carve-out must be the last word on the package's inode. + let ROOT: string + let P: string + let real: string + let link: string + const savedCwd = process.cwd() + const hasBwrap = spawnSync('bwrap', ['--version']).status === 0 + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bwrap-'))) + P = join(ROOT, 'pnpm') + real = join(P, '.pnpm', 'foo@1', 'node_modules', 'foo') + link = join(P, 'node_modules', 'foo') + mkdirSync(join(real, 'public'), { recursive: true }) + writeFileSync(join(real, 'index.js'), 'secret') + writeFileSync(join(real, 'public', 'ok.txt'), 'public') + mkdirSync(join(P, 'node_modules')) + symlinkSync(join('..', '.pnpm', 'foo@1', 'node_modules', 'foo'), link) + process.chdir(ROOT) + }) + + afterAll(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(ROOT, { recursive: true, force: true }) + }) + + async function wrap(command: string, carveOut: string): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(P, '**/node_modules/foo/**'), [ + carveOut, + ]), + allowWithinDeny: [carveOut], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + } + + for (const spelling of ['link', 'target'] as const) { + it(`mounts no tmpfs on a symlink and re-binds the carve-out last (allowRead in ${spelling} spelling)`, async () => { + const carveOut = join(spelling === 'link' ? link : real, 'public') + const wrapped = await wrap('echo hello', carveOut) + const ops = wrapped.split(' --').map(op => op.trim()) + + const tmpfsDests = ops + .filter(op => op.startsWith('tmpfs ')) + .map(op => op.slice('tmpfs '.length)) + expect(tmpfsDests).toContain(real) + expect(tmpfsDests).not.toContain(link) + for (const dest of tmpfsDests) { + expect(lstatSync(dest).isSymbolicLink()).toBe(false) + } + // One tmpfs per inode, and the carve-out's bind after the last one + // that covers it. + expect(tmpfsDests.filter(d => d === real)).toHaveLength(1) + const lastTmpfs = Math.max( + ...ops.flatMap((op, i) => (op.startsWith('tmpfs ') ? [i] : [])), + ) + const reBind = ops.lastIndexOf(`ro-bind ${carveOut} ${carveOut}`) + expect(reBind).toBeGreaterThan(lastTmpfs) + + if (hasBwrap) { + // Inside: the package is empty but for the carve-out, in both + // spellings; the entries beneath the carve-out keep their masks. + const run = spawnSync( + await wrap( + [ + `ls ${link}`, + `ls ${real}`, + `cat ${join(link, 'public', 'ok.txt')} | wc -c`, + `[ -e ${join(link, 'index.js')} ] && echo INDEX_VISIBLE || echo INDEX_HIDDEN`, + ].join('; '), + carveOut, + ), + { shell: true, encoding: 'utf8', timeout: 15000, cwd: ROOT }, + ) + expect(run.stderr ?? '').not.toContain('symlink destination') + expect(run.status).toBe(0) + expect(run.stdout.trim().split('\n')).toEqual([ + 'public', + 'public', + '0', + 'INDEX_HIDDEN', + ]) + } + }) + } + + it('honours a file carve-out written in the link spelling', async () => { + // The glob lists index.js under both spellings; only the link spelling + // is in allowRead. Neither twin may be masked, and the carve-out is + // bound back over the package tmpfs. + const carveOut = join(link, 'index.js') + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(P, '**/node_modules/foo/**'), [ + carveOut, + ]), + allowWithinDeny: [carveOut], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + + expect(wrapped).toContain(`--ro-bind ${carveOut} ${carveOut}`) + expect(wrapped).not.toContain(`/dev/null ${join(real, 'index.js')}`) + expect(wrapped).not.toContain(`/dev/null ${carveOut}`) + }) + + it('re-applies the tmpfs after a denyWrite bind that contains its target', async () => { + // denyWrite names the pnpm store, which contains the package's real + // location but not its link spelling; the bind lands after the tmpfs + // and would re-expose the package read-only without a re-application. + const store = join(P, '.pnpm') + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux( + join(P, '**/node_modules/foo/**'), + [], + ), + }, + writeConfig: { allowOnly: [P], denyWithinAllow: [store] }, + }) + + const storeBind = wrapped.lastIndexOf(`--ro-bind ${store} ${store}`) + expect(storeBind).toBeGreaterThan(-1) + expect(wrapped.lastIndexOf(`--tmpfs ${real}`)).toBeGreaterThan(storeBind) + }) + + it('binds a carve-out that is itself a symlink at its target', async () => { + // allowRead names the link; the tmpfs is on the target, so the re-bind + // goes there too (bwrap refuses a symlink destination) and the link, + // still present, leads to it. + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [real], allowWithinDeny: [link] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + + expect(wrapped).toContain(`--tmpfs ${real}`) + expect(wrapped).toContain(`--ro-bind ${link} ${real}`) + expect(wrapped).not.toContain(`--ro-bind ${link} ${link}`) + }) + + it('re-binds a literal carve-out written in the other spelling', async () => { + // Literal denies, no glob: denyRead names the link and allowRead its + // target's subdirectory, and the mirror. + const viaLink = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [link], + allowWithinDeny: [join(real, 'public')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(viaLink).toContain(`--tmpfs ${real}`) + expect(viaLink).toContain( + `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, + ) + + const viaTarget = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [real], + allowWithinDeny: [join(link, 'public')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(viaTarget).toContain(`--tmpfs ${real}`) + expect(viaTarget).toContain( + `--ro-bind ${join(link, 'public')} ${join(link, 'public')}`, + ) + }) + + it('emits one tmpfs for a directory and the files a glob lists beneath it', async () => { + // The cross-entry dedup: a directory deny plus per-file entries under + // it cost one mount, unless a carve-out keeps the masks beneath it + // meaningful. + const big = join(ROOT, 'big') + mkdirSync(join(big, 'keep'), { recursive: true }) + const keys = ['a.key', 'b.key', 'keep/c.key'].map(k => join(big, k)) + for (const k of keys) writeFileSync(k, '') + + const collapsed = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [big, ...keys] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(collapsed.split(`--tmpfs ${big}`)).toHaveLength(2) + expect(collapsed).not.toContain(`/dev/null ${big}/`) + + const carved = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [big, ...keys], + allowWithinDeny: [join(big, 'keep')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(carved).toContain( + `--ro-bind /dev/null ${join(big, 'keep', 'c.key')}`, + ) + expect(carved).not.toContain(`/dev/null ${join(big, 'a.key')}`) + }) + }, +) + describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { let ROOT: string const PKGS = ['a', 'b', 'c'] diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index ff41b430f..e704598dd 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -153,17 +153,19 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { }) it('does not re-expose a read-denied directory reached through a symlink', async () => { - // denyRead mounts a tmpfs on the raw (symlinked) dir, while the denyWrite - // dest is canonicalized. If the two are compared by raw string the - // denyWrite --ro-bind lands on top of the tmpfs and re-exposes the real, - // read-denied contents. + // denyRead names the symlinked dir; its tmpfs lands on the link's + // target (bwrap refuses a symlink as a mount destination), while the + // denyWrite dest is canonicalized. If the two were compared by the raw + // spelling the denyWrite --ro-bind would land on top of the tmpfs and + // re-expose the real, read-denied contents. const claudeLink = join(PROJ, '.claude') symlinkSync(join('..', 'dotfiles', 'claude'), claudeLink) const result = await wrap([join(claudeLink, 'commands')], [claudeLink]) const resolved = join(DOTFILES, 'claude', 'commands') - expect(result).toContain(`--tmpfs ${claudeLink}`) + expect(result).toContain(`--tmpfs ${join(DOTFILES, 'claude')}`) + expect(result).not.toContain(`--tmpfs ${claudeLink}`) expect(result).not.toContain(`--ro-bind ${resolved} ${resolved}`) }) From 0868b7f2fd047d0f98b1e1d21f9ba828a299fa31 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 29 Aug 2026 02:40:13 -0400 Subject: [PATCH 03/23] ci: run the Linux suite against bubblewrap 0.12.0 as well apt installs 0.9.0, which mounts on a symlink destination; 0.12.0 refuses, and on a usr-merged system a root-level denyRead used to emit --tmpfs /bin. Build 0.12.0 from source on the Linux jobs and run the suite a second time with it first on PATH. --- .github/workflows/integration-tests.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d3625490c..fa1ead0fc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -192,6 +192,24 @@ jobs: if: matrix.os != 'windows' run: npm test + # bubblewrap 0.12 refuses a mount on a symlink destination, which apt's + # 0.9.0 accepts; run the suite against it as well so a profile that + # mounts on a link is caught here, not on a user's machine. + - name: Build bubblewrap 0.12.0 (Linux) + if: matrix.os == 'linux' + run: | + sudo apt-get install -y -qq meson ninja-build libcap-dev pkg-config + git clone --depth 1 --branch v0.12.0 https://github.com/containers/bubblewrap.git "$RUNNER_TEMP/bubblewrap" + cd "$RUNNER_TEMP/bubblewrap" + meson setup _build --prefix="$RUNNER_TEMP/bwrap-0.12" -Dselinux=disabled -Dman=disabled -Dbash_completion=disabled -Dzsh_completion=disabled + ninja -C _build + ninja -C _build install + "$RUNNER_TEMP/bwrap-0.12/bin/bwrap" --version + + - name: Run tests against bubblewrap 0.12.0 (Linux) + if: matrix.os == 'linux' + run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$PATH" npm test + - name: Run Node.js fallback tests if: matrix.os != 'windows' run: node test/utils/which-node-test.mjs From 058e7c5ff0b641ae00c81efe19ee06b035983d79 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 29 Aug 2026 15:32:16 -0400 Subject: [PATCH 04/23] fix(linux): mount read denies where they land inside the sandbox, not at their host realpath A directory deny's tmpfs went on the host realpath of its spelling, and its second spelling was dropped as already covered. When an earlier tmpfs had wiped a symlink on the way and a carve-out re-bind recreated that path as a plain directory, the realpath is not where the path lives inside the sandbox, so the deny landed elsewhere and the entry stayed readable; a symlinked file deny under a denied directory was skipped though its target lay outside; and a tmpfs was re-applied after a denyWrite bind that contained only its link spelling, re-binding the carve-outs over their own file masks. The deny loop now keeps an ordered trail of where each tmpfs and bind landed inside the sandbox, mounts every deny at that landing and at its host target unless a tmpfs already covers it, re-binds carve-outs relative to the landing, and re-applies a tmpfs only for a bind over where it landed. Regression tests for each shape, run under bwrap where available. --- src/sandbox/linux-sandbox-utils.ts | 241 ++++++++++++++++++---------- test/sandbox/read-deny-glob.test.ts | 167 ++++++++++++++++++- 2 files changed, 321 insertions(+), 87 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 660459d0b..7077b8f47 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -880,9 +880,13 @@ function pushReadDenyDirMounts( allowedWritePaths: string[], readAllowPaths: string[], covers: (p: string, dir: string) => boolean, - bindDestFor: (p: string) => string, + bindDestForIn: (tmpfs: { + spelling: string + dest: string + }) => (p: string) => string, ): void { args.push('--tmpfs', dest) + const bindDestFor = bindDestForIn({ spelling, dest }) // tmpfs wiped any earlier write binds under this path — restore them. for (const writePath of allowedWritePaths) { @@ -1494,30 +1498,106 @@ async function generateFilesystemArgs( // bind re-exposes the dest. const maskedFiles = new Map() // Directories masked by --tmpfs below, in emission (shallow-first) order, - // by the spelling the config named; tmpfsDests holds where each landed - // (resolveEverySymlink). Used to filter denyWriteArgs the same way: a - // dir in both deny lists must not get its host contents re-bound on top - // of its own tmpfs. + // by the spelling the config named; tmpfsMounts pairs each with where it + // landed. Used to filter denyWriteArgs the same way: a dir in both deny + // lists must not get its host contents re-bound on top of its own tmpfs. const tmpfsDirs: string[] = [] - const tmpfsDests = new Map() - // Where a re-bind of an allowed path lands. A path that is itself a - // symlink is bound at its target while the link still exists inside the - // sandbox (bwrap refuses a symlink as a destination); once a covering - // tmpfs has wiped the link's parent, the spelling is bound instead and - // bwrap recreates the path there. - const reBindDestFor = (p: string): string => { - let isLink = false - try { - isLink = fs.lstatSync(p).isSymbolicLink() - } catch { - // Absent: bound as spelled (and skipped by the existence check). + const tmpfsMounts: Array<{ spelling: string; dest: string }> = [] + // The directory mounts emitted so far, by where they landed INSIDE the + // sandbox, in emission order: a tmpfs empties its landing, a re-bind + // shows a host directory (symlinks included) at its landing. The last + // one covering a path decides what that path is inside the sandbox. + const mountTrail = new Map() + let mountSeq = 0 + const recordMount = (landing: string, kind: 'tmpfs' | 'bind'): void => { + mountTrail.set(landing, { seq: mountSeq++, kind }) + } + const lastCovering = ( + q: string, + ): { landing: string; kind: 'tmpfs' | 'bind' } | undefined => { + let best: + | { landing: string; seq: number; kind: 'tmpfs' | 'bind' } + | undefined + for (let prefix = q; ; ) { + const m = mountTrail.get(prefix) + if (m !== undefined && (best === undefined || m.seq > best.seq)) { + best = { landing: prefix, ...m } + } + if (prefix === '/') break + const slash = prefix.lastIndexOf('/') + prefix = slash <= 0 ? '/' : prefix.slice(0, slash) } - if (!isLink) return p - const parentWiped = tmpfsDirs.some(t => - atOrUnderEitherForm(path.dirname(p), t), - ) - return parentWiped ? p : resolveEverySymlink(p) + return best } + // Host symlink lookups, cached: the host does not change while the + // profile is built, only the sandbox's view of it does. + const linkTargetCache = new Map() + const hostLinkTarget = (q: string): string | null => { + let t = linkTargetCache.get(q) + if (t === undefined) { + t = null + try { + if (fs.lstatSync(q).isSymbolicLink()) { + const r = fs.realpathSync(q) + if (r !== '/') t = r + } + } catch { + // absent or dangling: not a link to follow + } + linkTargetCache.set(q, t) + } + return t + } + // Where a mount whose destination is spelled `p` lands inside the + // sandbox, given the mounts emitted so far. bwrap resolves the + // destination in the new root: a component is a symlink there only while + // its parent is still host-visible (outside every tmpfs, or inside a + // directory re-bound over one); beneath a tmpfs the components are plain + // directories bwrap creates, so they stay as spelled. Host-visible + // territory is identity-mapped, so a link there resolves as on the host. + const landingOf = (p: string): string => { + let cur = '/' + for (const c of p.split('/')) { + if (c === '') continue + const next = cur === '/' ? `/${c}` : `${cur}/${c}` + if (lastCovering(cur)?.kind === 'tmpfs') { + cur = next + continue + } + cur = hostLinkTarget(next) ?? next + } + return cur + } + // Where a re-bind of an allowed path `p` lands for the tmpfs (spelling S, + // landing T) it restores: the part of `p` beneath S — in whichever + // spelling put it there — re-rooted at T, then landed like any other + // destination (a link still alive there is followed; bwrap refuses a + // symlink as a destination and, before 0.12, an absolute one anywhere in + // the path). + const reBindDestForIn = + (tmpfs: { spelling: string; dest: string }) => + (p: string): string => { + let rel: string | undefined + outer: for (const pf of bothForms(p)) { + for (const sf of [ + tmpfs.spelling, + ...bothForms(tmpfs.spelling), + tmpfs.dest, + ]) { + if (isAtOrUnder(pf, sf)) { + rel = pf.slice(sf.length) + break outer + } + } + } + const landing = landingOf( + rel === undefined + ? p + : (tmpfs.dest === '/' ? '' : tmpfs.dest) + rel || '/', + ) + recordMount(landing, 'bind') + return landing + } // --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds). // Expand a root deny into its direct children so the existing per-dir tmpfs @@ -1568,15 +1648,8 @@ async function generateFilesystemArgs( // beneath it, a symlink plus its target) would otherwise cost one bwrap // mount per file. The same question isHiddenByTmpfs below asks of the // buffered denyWrite binds. - const readDenyReExposers = [...allowedWritePaths, ...readAllowPaths] - const hiddenByEmittedTmpfs = (p: string): boolean => - tmpfsDirs.some( - tmpfsDir => - atOrUnderEitherForm(p, tmpfsDir) && - !readDenyReExposers.some( - r => atOrUnderEitherForm(r, tmpfsDir) && atOrUnderEitherForm(p, r), - ), - ) + const hiddenByEmittedTmpfs = (landing: string): boolean => + lastCovering(landing)?.kind === 'tmpfs' for (const normalizedPath of normalizedDenyPaths) { if (!fs.existsSync(normalizedPath)) { @@ -1588,31 +1661,36 @@ async function generateFilesystemArgs( const readDenyStat = fs.statSync(normalizedPath) if (readDenyStat.isDirectory()) { - // The tmpfs lands on the resolved path: bwrap refuses a symlink as a - // mount destination (resolveEverySymlink). Whether an earlier tmpfs - // already hides it is a question about that landing, not the - // spelling: a link nested under a denied directory still needs its - // own mount where its target lies outside every tmpfs. - const dest = resolveEverySymlink(normalizedPath) - if ( - [...tmpfsDests.values()].includes(dest) || - hiddenByEmittedTmpfs(dest) - ) { + // A directory is denied where its spelling lands inside the sandbox + // AND at its host target (a symlink's realpath), each unless an + // emitted tmpfs already hides that place. The two differ when a link + // on the way was wiped by an earlier tmpfs and recreated by a + // carve-out re-bind: the recreated path is a directory of its own, + // and the target is still reachable by its real name. + let mounted = false + for (const dest of new Set([ + landingOf(normalizedPath), + resolveEverySymlink(normalizedPath), + ])) { + if (hiddenByEmittedTmpfs(dest)) continue + mounted = true + tmpfsDirs.push(normalizedPath) + tmpfsMounts.push({ spelling: normalizedPath, dest }) + recordMount(dest, 'tmpfs') + pushReadDenyDirMounts( + args, + { spelling: normalizedPath, dest }, + allowedWritePaths, + readAllowPaths, + atOrUnderEitherForm, + reBindDestForIn, + ) + } + if (!mounted) { logForDebugging( `[Sandbox Linux] Skipping read deny directory already hidden by a denyRead tmpfs: ${normalizedPath}`, ) - continue } - tmpfsDirs.push(normalizedPath) - tmpfsDests.set(normalizedPath, dest) - pushReadDenyDirMounts( - args, - { spelling: normalizedPath, dest }, - allowedWritePaths, - readAllowPaths, - atOrUnderEitherForm, - reBindDestFor, - ) } else { // For files, only an exact allowRead match overrides the deny, in // either spelling (a glob lists a file reached through a link under @@ -1635,15 +1713,26 @@ async function generateFilesystemArgs( // nothing; a plain file under a directory link is masked once per // spelling, since a carve-out re-bind may have recreated the link's // path as a directory of its own. - const denyDest = resolveSymlinkDenyDest(normalizedPath) - if (maskedFiles.has(denyDest) || hiddenByEmittedTmpfs(normalizedPath)) { + // Masked where the spelling lands inside the sandbox and at the host + // target, as for directories. + let masked = false + for (const denyDest of new Set([ + landingOf(normalizedPath), + resolveEverySymlink(normalizedPath), + ])) { + if (maskedFiles.has(denyDest) || hiddenByEmittedTmpfs(denyDest)) { + continue + } + masked = true + args.push('--ro-bind', '/dev/null', denyDest) + maskedFiles.set(denyDest, '/dev/null') + } + if (!masked) { logForDebugging( `[Sandbox Linux] Skipping read deny file already masked or hidden by a denyRead tmpfs: ${normalizedPath}`, ) continue } - args.push('--ro-bind', '/dev/null', denyDest) - maskedFiles.set(denyDest, '/dev/null') maskedFiles.set(normalizedPath, '/dev/null') } } @@ -1705,6 +1794,9 @@ async function generateFilesystemArgs( continue } args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) + // A host directory bound here is host-visible territory again for any + // mount re-applied beneath it below. + if (denyWriteArgs[i + 1] === dest) recordMount(dest, 'bind') emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit // above a read-denied path?". A bind at the resolved dest also re-exposes @@ -1716,42 +1808,23 @@ async function generateFilesystemArgs( // contains a read-denied dir re-exposes that dir's real contents (the bind // landed after the tmpfs). Re-apply the tmpfs on top, with the same write // and allowRead re-binds the denyRead loop emitted. - for (const tmpfsDir of tmpfsDirs) { - if (emittedDenyWriteDests.some(dest => tmpfsDir.startsWith(dest + '/'))) { - logForDebugging( - `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${tmpfsDir}`, - ) - pushReadDenyDirMounts( - args, - { spelling: tmpfsDir, dest: tmpfsDests.get(tmpfsDir)! }, - allowedWritePaths, - readAllowPaths, - atOrUnderEitherForm, - reBindDestFor, - ) - } - } - // A tmpfs whose spelling was a symlink sits at the link's target; a - // denyWrite bind that contains the target but not the spelling shadows it - // just the same, and the loop above only saw the spelling. - for (const [spelling, dest] of tmpfsDests) { - if (dest === spelling) continue - if ( - emittedDenyWriteDests.some(w => w !== dest && isAtOrUnder(dest, w)) && - !emittedDenyWriteDests.some( - w => w !== spelling && isAtOrUnder(spelling, w), - ) - ) { + // What decides it is where the tmpfs LANDED, not how the config spelled + // it: a bind that contains only a symlink spelling leaves the mount at the + // landing untouched (and re-applying anyway would re-bind carve-outs over + // the file masks beneath them). + for (const { spelling, dest } of tmpfsMounts) { + if (emittedDenyWriteDests.some(w => w !== dest && isAtOrUnder(dest, w))) { logForDebugging( - `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind over its target: ${dest}`, + `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${dest}`, ) + recordMount(dest, 'tmpfs') pushReadDenyDirMounts( args, { spelling, dest }, allowedWritePaths, readAllowPaths, atOrUnderEitherForm, - reBindDestFor, + reBindDestForIn, ) } } diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 9ce237dfa..96c63bc97 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -490,7 +490,9 @@ describe.if(isLinux)( const lastTmpfs = Math.max( ...ops.flatMap((op, i) => (op.startsWith('tmpfs ') ? [i] : [])), ) - const reBind = ops.lastIndexOf(`ro-bind ${carveOut} ${carveOut}`) + const reBind = ops.lastIndexOf( + `ro-bind ${carveOut} ${join(real, 'public')}`, + ) expect(reBind).toBeGreaterThan(lastTmpfs) if (hasBwrap) { @@ -537,7 +539,9 @@ describe.if(isLinux)( writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).toContain(`--ro-bind ${carveOut} ${carveOut}`) + expect(wrapped).toContain( + `--ro-bind ${carveOut} ${join(real, 'index.js')}`, + ) expect(wrapped).not.toContain(`/dev/null ${join(real, 'index.js')}`) expect(wrapped).not.toContain(`/dev/null ${carveOut}`) }) @@ -564,6 +568,43 @@ describe.if(isLinux)( expect(wrapped.lastIndexOf(`--tmpfs ${real}`)).toBeGreaterThan(storeBind) }) + it('does not re-apply a tmpfs over its carve-out when a denyWrite bind covers only the link spelling', async () => { + // w/d/link -> realdir (outside the write root w). denyWrite [w/d] + // contains the link's spelling but not where the tmpfs landed + // (realdir), so the bind re-exposes nothing; re-applying the tmpfs + // there anyway would re-bind realdir/pub over the mask on + // realdir/pub/secret.txt and leave the file readable. + const R = join(ROOT, 'f4') + const realdir = join(R, 'realdir') + const W = join(R, 'w') + const S = join(W, 'd', 'link') + mkdirSync(join(realdir, 'pub'), { recursive: true }) + writeFileSync(join(realdir, 'pub', 'secret.txt'), 'secret') + mkdirSync(join(W, 'd'), { recursive: true }) + symlinkSync(realdir, S) + + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [S, join(realdir, 'pub', 'secret.txt')], + allowWithinDeny: [join(realdir, 'pub')], + }, + writeConfig: { allowOnly: [W], denyWithinAllow: [join(W, 'd')] }, + mandatoryDenySearchDepth: 1, + }) + + const mask = `--ro-bind /dev/null ${join(realdir, 'pub', 'secret.txt')}` + const carveOut = `--ro-bind ${join(realdir, 'pub')} ${join(realdir, 'pub')}` + expect(wrapped).toContain(mask) + // One tmpfs on the target, and the mask is the last word on the file: + // no carve-out re-bind after it. + expect(wrapped.split(`--tmpfs ${realdir} `)).toHaveLength(2) + expect(wrapped.lastIndexOf(mask)).toBeGreaterThan( + wrapped.lastIndexOf(carveOut), + ) + }) + it('binds a carve-out that is itself a symlink at its target', async () => { // allowRead names the link; the tmpfs is on the target, so the re-bind // goes there too (bwrap refuses a symlink destination) and the link, @@ -608,8 +649,128 @@ describe.if(isLinux)( }) expect(viaTarget).toContain(`--tmpfs ${real}`) expect(viaTarget).toContain( - `--ro-bind ${join(link, 'public')} ${join(link, 'public')}`, + `--ro-bind ${join(link, 'public')} ${join(real, 'public')}`, + ) + }) + + it('mounts a deny beneath a recreated symlinked carve-out where the carve-out was recreated', async () => { + // denyRead [D, D/lnk/sub] + allowRead [D/lnk], D/lnk -> ../e: D's tmpfs + // wipes the link, the carve-out re-bind recreates D/lnk as a plain + // directory showing e, so the deny must land at D/lnk/sub — a tmpfs + // at e/sub (the host realpath) would leave D/lnk/sub/secret readable. + const D = join(ROOT, 's1', 'D') + const e = join(ROOT, 's1', 'e') + mkdirSync(D, { recursive: true }) + mkdirSync(join(e, 'sub'), { recursive: true }) + writeFileSync(join(e, 'sub', 'secret'), 'secret') + symlinkSync(join('..', 'e'), join(D, 'lnk')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(D, 'lnk', 'sub', 'secret')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [D, join(D, 'lnk', 'sub')], + allowWithinDeny: [join(D, 'lnk')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).toContain(`--tmpfs ${join(D, 'lnk', 'sub')}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).not.toContain('secret') + expect(run.stdout).toContain('HIDDEN') + } + }) + + it('binds a symlinked carve-out nested in another carve-out at its target', async () => { + // denyRead [D] + allowRead [D/x, D/x/lnk]: D/x is re-bound from the + // host, so D/x/lnk is a live symlink inside the sandbox and bwrap 0.12 + // refuses it as a destination. + const D = join(ROOT, 's3', 'D') + const t = join(ROOT, 's3', 't') + mkdirSync(join(D, 'x'), { recursive: true }) + mkdirSync(t, { recursive: true }) + writeFileSync(join(t, 'f'), 'T') + symlinkSync(join('..', '..', 't'), join(D, 'x', 'lnk')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(D, 'x', 'lnk', 'f')}`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [D], + allowWithinDeny: [join(D, 'x'), join(D, 'x', 'lnk')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).not.toContain(` ${join(D, 'x', 'lnk')} --`) + expect(wrapped).toContain(`--ro-bind ${join(D, 'x', 'lnk')} ${t}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stderr ?? '').not.toContain('symlink destination') + expect(run.stdout).toBe('T') + } + }) + + it('re-binds a carve-out through an absolute intermediate symlink without a symlink in the destination', async () => { + // denyRead [T] + allowRead [P/L/sub], P/L -> T absolute: bubblewrap + // before 0.12 aborts on an absolute link inside a destination. + const T = join(ROOT, 's4', 'T') + const P = join(ROOT, 's4', 'P') + mkdirSync(join(T, 'sub'), { recursive: true }) + mkdirSync(P, { recursive: true }) + writeFileSync(join(T, 'sub', 'f'), 'F') + symlinkSync(T, join(P, 'L')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(P, 'L', 'sub', 'f')}`, + needsNetworkRestriction: false, + readConfig: { denyOnly: [T], allowWithinDeny: [join(P, 'L', 'sub')] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).toContain( + `--ro-bind ${join(P, 'L', 'sub')} ${join(T, 'sub')}`, ) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.status).toBe(0) + expect(run.stdout).toBe('F') + } + }) + + it('still masks the target of a file symlink listed beneath a denied directory', async () => { + // denyRead [cfg, cfg/token], cfg/token -> ../secrets/token: the link + // vanishes with cfg's tmpfs, but the file it named is the target, and + // main masked it there (resolveSymlinkDenyDest); it must stay masked. + const cfg = join(ROOT, 's9', 'cfg') + const secrets = join(ROOT, 's9', 'secrets') + mkdirSync(cfg, { recursive: true }) + mkdirSync(secrets, { recursive: true }) + writeFileSync(join(secrets, 'token'), 'TOKEN') + symlinkSync(join('..', 'secrets', 'token'), join(cfg, 'token')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(secrets, 'token')}; echo`, + needsNetworkRestriction: false, + readConfig: { denyOnly: [cfg, join(cfg, 'token')] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).toContain(`--ro-bind /dev/null ${join(secrets, 'token')}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).not.toContain('TOKEN') + } }) it('emits one tmpfs for a directory and the files a glob lists beneath it', async () => { From 1f1ab7afcbb5481d48a65109bb5e154986939c31 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 2 Sep 2026 08:32:03 +0100 Subject: [PATCH 05/23] refactor(linux): one record of emitted read-deny tmpfs mounts, bind-dest passed directly No behaviour change: - tmpfsMounts already pairs each emitted tmpfs's spelling with where it landed; isHiddenByTmpfs reads the spelling from it instead of a second, parallel tmpfsDirs array. - pushReadDenyDirMounts takes the landing function for its tmpfs directly rather than a factory it applied to its own arguments; callers build it from the same mount record they pass. - The prefix list a re-bind is re-rooted against no longer names the tmpfs spelling twice (bothForms already leads with it). - README: the Linux glob bullet is split into sub-bullets; same content. --- README.md | 7 ++-- src/sandbox/linux-sandbox-utils.ts | 57 +++++++++++++----------------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 94b1530dc..4dec17d2f 100644 --- a/README.md +++ b/README.md @@ -375,8 +375,11 @@ Examples: 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, except beneath a directory a `denyRead` pattern ending in `/**` matched: such a directory becomes one tmpfs mount rather than one mount per file beneath it, with a literal directory deny's semantics — inside the sandbox it is empty and writable, writes into it stay in the tmpfs and never reach the host, and a file added to it later on the host is hidden too. An `allowRead` beneath it is bound back over the tmpfs; the entries beneath that carve-out which the pattern matched keep their own masks, as they did before. Symlinked directories are descended, and an entry reached through a symlink is denied at the link's target as well (a link back up the tree denies everything the link reaches, as a literal deny of the link would; a link to `/` is left alone). A carve-out beneath a link applies whichever spelling it is written in; an `allowRead` that is itself a symlink is bound at its target. -- A directory `denyRead` whose path is a symlink — literal, or matched by a pattern — is mounted at the link's target (bubblewrap refuses to mount on a symlink), and every other rule treats a directory a `/**` pattern matched exactly as a directory listed in `denyRead` literally. +- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the matching entries when the command is wrapped, so a file that appears later is not covered — with one exception: + - A directory matched by a `denyRead` pattern ending in `/**` becomes one tmpfs mount rather than one mount per file beneath it, and behaves exactly like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it stay in the tmpfs and never reach the host, and a file added to it later on the host is hidden too. An `allowRead` beneath it is bound back over the tmpfs, and matched entries beneath that carve-out keep their own masks. + - Symlinked directories are descended. An entry reached through a symlink is denied at the link's target as well; a link back up the tree denies everything it reaches, as a literal deny of the link would; a link to `/` is left alone. + - A carve-out beneath a link applies in whichever spelling it is written; an `allowRead` that is itself a symlink is bound at its target. +- A directory `denyRead` whose path is a symlink — literal, or matched by a pattern — is mounted at the link's target, since bubblewrap refuses to mount on a symlink. Examples: diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7077b8f47..5594b9e92 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -867,10 +867,11 @@ function resolveEverySymlink(p: string): string { * Mount a tmpfs over a read-denied directory (at `dest`, its resolved * path), then restore the allowed write paths and allowRead paths the tmpfs * just wiped: those at or under the directory in either spelling - * (`covers`), re-bound where `bindDestFor` says — the spelling the config - * named, which bwrap resolves inside the sandbox and recreates where a link - * the tmpfs replaced no longer exists, or the target of a path that is - * itself a still-present symlink. Used by the denyRead loop in + * (`covers`), re-bound where `bindDestFor` (the caller's landing function + * for this tmpfs) says — the spelling the config named, which bwrap + * resolves inside the sandbox and recreates where a link the tmpfs replaced + * no longer exists, or the target of a path that is itself a still-present + * symlink. Used by the denyRead loop in * generateFilesystemArgs and again when a late denyWrite ro-bind re-exposes * a read-denied directory and the tmpfs must be re-applied on top. */ @@ -880,13 +881,9 @@ function pushReadDenyDirMounts( allowedWritePaths: string[], readAllowPaths: string[], covers: (p: string, dir: string) => boolean, - bindDestForIn: (tmpfs: { - spelling: string - dest: string - }) => (p: string) => string, + bindDestFor: (p: string) => string, ): void { args.push('--tmpfs', dest) - const bindDestFor = bindDestForIn({ spelling, dest }) // tmpfs wiped any earlier write binds under this path — restore them. for (const writePath of allowedWritePaths) { @@ -1497,11 +1494,10 @@ async function generateFilesystemArgs( // the mask, and to re-apply the correct source if a denyWrite ancestor // bind re-exposes the dest. const maskedFiles = new Map() - // Directories masked by --tmpfs below, in emission (shallow-first) order, - // by the spelling the config named; tmpfsMounts pairs each with where it - // landed. Used to filter denyWriteArgs the same way: a dir in both deny - // lists must not get its host contents re-bound on top of its own tmpfs. - const tmpfsDirs: string[] = [] + // Directories masked by --tmpfs below, in emission (shallow-first) order: + // the spelling the config named, paired with where the mount landed. Used + // to filter denyWriteArgs the same way: a dir in both deny lists must not + // get its host contents re-bound on top of its own tmpfs. const tmpfsMounts: Array<{ spelling: string; dest: string }> = [] // The directory mounts emitted so far, by where they landed INSIDE the // sandbox, in emission order: a tmpfs empties its landing, a re-bind @@ -1574,16 +1570,12 @@ async function generateFilesystemArgs( // destination (a link still alive there is followed; bwrap refuses a // symlink as a destination and, before 0.12, an absolute one anywhere in // the path). - const reBindDestForIn = + const reBindDestFor = (tmpfs: { spelling: string; dest: string }) => (p: string): string => { let rel: string | undefined outer: for (const pf of bothForms(p)) { - for (const sf of [ - tmpfs.spelling, - ...bothForms(tmpfs.spelling), - tmpfs.dest, - ]) { + for (const sf of [...bothForms(tmpfs.spelling), tmpfs.dest]) { if (isAtOrUnder(pf, sf)) { rel = pf.slice(sf.length) break outer @@ -1674,16 +1666,16 @@ async function generateFilesystemArgs( ])) { if (hiddenByEmittedTmpfs(dest)) continue mounted = true - tmpfsDirs.push(normalizedPath) - tmpfsMounts.push({ spelling: normalizedPath, dest }) + const mount = { spelling: normalizedPath, dest } + tmpfsMounts.push(mount) recordMount(dest, 'tmpfs') pushReadDenyDirMounts( args, - { spelling: normalizedPath, dest }, + mount, allowedWritePaths, readAllowPaths, atOrUnderEitherForm, - reBindDestForIn, + reBindDestFor(mount), ) } if (!mounted) { @@ -1767,12 +1759,12 @@ async function generateFilesystemArgs( // if an allowed write path at-or-under that tmpfs covers the dest, the // denyRead loop re-bound it (the .git/hooks case) and the write-deny bind // is still required on top. - // tmpfsDirs and allowedWritePaths hold unresolved paths while dest has been - // canonicalized, so each dest is tested under both spellings: they name the - // same inode after bwrap resolves the mount destinations, and a hit on - // either means the tmpfs really does cover this bind. + // tmpfs spellings and allowedWritePaths hold unresolved paths while dest + // has been canonicalized, so each dest is tested under both spellings: they + // name the same inode after bwrap resolves the mount destinations, and a + // hit on either means the tmpfs really does cover this bind. const isHiddenByTmpfs = (dest: string): boolean => - tmpfsDirs.some(tmpfsDir => { + tmpfsMounts.some(({ spelling: tmpfsDir }) => { if (!atOrUnderEitherForm(dest, tmpfsDir)) return false const reExposedByWriteBind = allowedWritePaths.some( writePath => @@ -1812,7 +1804,8 @@ async function generateFilesystemArgs( // it: a bind that contains only a symlink spelling leaves the mount at the // landing untouched (and re-applying anyway would re-bind carve-outs over // the file masks beneath them). - for (const { spelling, dest } of tmpfsMounts) { + for (const mount of tmpfsMounts) { + const { dest } = mount if (emittedDenyWriteDests.some(w => w !== dest && isAtOrUnder(dest, w))) { logForDebugging( `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${dest}`, @@ -1820,11 +1813,11 @@ async function generateFilesystemArgs( recordMount(dest, 'tmpfs') pushReadDenyDirMounts( args, - { spelling, dest }, + mount, allowedWritePaths, readAllowPaths, atOrUnderEitherForm, - reBindDestForIn, + reBindDestFor(mount), ) } } From f59ad79dcc780719629ffc4cb52b562ec30c8af2 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 00:56:29 +0000 Subject: [PATCH 06/23] refactor(linux): give the read-deny mount trail and the path spellings one owner each The mount trail, the landing computation and the both-spellings cache were closures inside generateFilesystemArgs, threaded into pushReadDenyDirMounts as two callbacks. They are now a SandboxMountView beside it, which records a tmpfs and each bind where it is pushed, so no query-named callback mutates the trail and callers no longer pair the record with the call by hand. The re-bind destination drops three guards no caller could reach. pathSpellings() and properAncestors() in sandbox-utils.ts replace three realpath-or-fallback helpers and three hand-rolled prefix walks, so the glob collapse and the deny loop compare the same spellings under the same root rule. walkGlobPattern derives the directory form itself (withDirectoryForm) instead of taking a second pattern nothing checked, and reports its base as an optional record rather than two empty strings. resolveReadPathEntries takes the glob expander as a required argument instead of an optional thunk whose absence meant allowRead, and expandReadDenyGlobLinux normalizes the re-exposing paths itself. collapseReadDenyMounts and the warn threshold are no longer exported: their tests go through expandReadDenyGlobLinux over a temp tree, and the two 513-file tests of a debug-only hint are dropped. Comments that narrated the previous implementation or restated their neighbours are cut to the constraint. Tests get their own fixtures where they shared a mutable one, cleanup in finally, one scenario per test, and descriptive names for describe-scoped bindings. --- src/sandbox/linux-sandbox-utils.ts | 418 +++++++++++++--------------- src/sandbox/read-deny-glob.ts | 143 ++++------ src/sandbox/sandbox-manager.ts | 89 +++--- src/sandbox/sandbox-utils.ts | 102 ++++--- test/sandbox/glob-expand.test.ts | 71 +++-- test/sandbox/read-deny-glob.test.ts | 363 +++++++++--------------- 6 files changed, 505 insertions(+), 681 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 14516722c..87940b19c 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -19,6 +19,9 @@ import { DANGEROUS_FILES, isAtOrUnder, isStrictlyUnder, + pathSpellings, + type PathSpellings, + properAncestors, getDangerousDirectories, } from './sandbox-utils.js' import type { @@ -864,14 +867,12 @@ function buildSandboxCommand( * bwrap cannot create a file bind mount point over a destination that is * itself a symlink — `--ro-bind /dev/null ` fails with "Can't create * file at " (0.12 and later: "Can't mount on symlink destination") and - * the whole command refuses to start. File read-deny binds therefore target - * the symlink's resolved target instead: reads through the symlink resolve - * to that target inside the mount namespace, so the denied content stays - * covered. This matters for credential dotfiles (~/.netrc, ~/.npmrc, …) + * the whole command refuses to start. A file mask whose path is a symlink + * therefore targets the link's resolved target: reads through the symlink + * resolve to that target inside the mount namespace, so the denied content + * stays covered. This matters for credential dotfiles (~/.netrc, ~/.npmrc, …) * that are commonly symlinks into a dotfile manager's directory. A link - * higher up the path is left alone: bwrap resolves it inside the sandbox, - * where a covering tmpfs may have replaced it with a directory an allowRead - * bind recreated, and the mask must land in that spelling too. + * higher up the path is left alone. */ function resolveSymlinkDenyDest(normalizedPath: string): string { try { @@ -884,53 +885,161 @@ function resolveSymlinkDenyDest(normalizedPath: string): string { return normalizedPath } +type MountKind = 'tmpfs' | 'bind' + +/** A read-denied directory's tmpfs: the spelling the config named, and where + * the mount landed (that spelling's landing, or its host target). */ +type ReadDenyTmpfsMount = { spelling: string; dest: string } + /** - * `p` with every symlink resolved: where a directory deny's tmpfs lands - * (bwrap refuses a mount whose destination is a symlink, and releases before - * 0.12 abort on an absolute link anywhere in a destination, which they - * resolve against the new root), and the second spelling every comparison - * between a deny mount and a re-exposing path runs over. A carve-out - * written against a link (allowRead: node_modules/foo/public under a pnpm - * link, /bin/bash on a usr-merged system) still applies for that reason. - * `p` itself when it cannot be resolved, or resolves to the root: a tmpfs - * over / would hide everything, and bwrap refuses the link instead. + * The directory mounts emitted so far, by where they landed inside the + * sandbox: a tmpfs empties its landing, a bind shows a host directory + * (symlinks included) at its landing, and the last one covering a path + * decides what that path is in the new root. Host lookups are cached: the + * host does not change while a profile is built, only the sandbox's view. */ -function resolveEverySymlink(p: string): string { - try { - const resolved = fs.realpathSync(p) - if (resolved !== '/') return resolved - } catch { - // Dangling symlink or vanished path — keep the original. +class SandboxMountView { + private readonly trail = new Map() + private nextSeq = 0 + private readonly spellingsCache = new Map() + private readonly linkTargetCache = new Map() + + /** + * `p` as the config spelled it and as the host resolves it. Comparisons + * between a read-deny mount and the paths that re-expose contents beneath + * it run over both, so a carve-out written against a link or against its + * target both count (allowRead: node_modules/foo/public against a pnpm + * link, /bin/bash against /usr/bin on a usr-merged system). + */ + spellings(p: string): PathSpellings { + let spellings = this.spellingsCache.get(p) + if (spellings === undefined) { + spellings = pathSpellings(p) + this.spellingsCache.set(p, spellings) + } + return spellings + } + + /** `p` with every symlink resolved; `p` itself when it cannot be resolved + * or resolves to '/'. bwrap refuses a mount on a symlink, so this is where + * a mount on a still-present link goes. */ + resolved(p: string): string { + const spellings = this.spellings(p) + return spellings.length === 2 ? spellings[1] : spellings[0] + } + + atOrUnderEitherSpelling(p: string, dir: string): boolean { + return this.spellings(p).some(pf => + this.spellings(dir).some(df => isAtOrUnder(pf, df)), + ) + } + + record(landing: string, kind: MountKind): void { + this.trail.set(landing, { seq: this.nextSeq++, kind }) + } + + /** Whether the last mount covering `landing` is a tmpfs: a mount there + * would be created inside it and hide nothing on the host. */ + underTmpfs(landing: string): boolean { + let last: { seq: number; kind: MountKind } | undefined + for (const at of [landing, ...properAncestors(landing)]) { + const mount = this.trail.get(at) + if (mount !== undefined && (last === undefined || mount.seq > last.seq)) { + last = mount + } + } + return last?.kind === 'tmpfs' + } + + /** + * Where a mount whose destination is spelled `p` lands. bwrap resolves the + * destination in the new root: a component is a symlink there only while + * its parent is host-visible (outside every tmpfs, or inside a directory + * bound back over one), and resolves as on the host; beneath a tmpfs the + * components are plain directories bwrap creates, so they stay as spelled. + */ + landingOf(p: string): string { + let landing = '/' + for (const component of p.split('/')) { + if (component === '') continue + const next = landing === '/' ? `/${component}` : `${landing}/${component}` + landing = this.underTmpfs(landing) ? next : this.hostLinkTarget(next) + } + return landing + } + + /** + * Where a bind restoring allowed path `p` lands for the tmpfs that wiped + * it: the part of `p` beneath the tmpfs, in whichever spelling put it + * there, re-rooted at the tmpfs's landing (bwrap refuses a symlink as a + * destination and, before 0.12, an absolute one anywhere in the path). + */ + reBindLanding(tmpfs: ReadDenyTmpfsMount, p: string): string { + for (const pf of this.spellings(p)) { + for (const sf of [...this.spellings(tmpfs.spelling), tmpfs.dest]) { + if (isAtOrUnder(pf, sf)) { + return this.landingOf(tmpfs.dest + pf.slice(sf.length)) + } + } + } + throw new Error(`${p} is not at or under the read-deny tmpfs ${tmpfs.dest}`) + } + + /** `q`'s target when `q` is itself a symlink (to anything but '/'), else `q`. */ + private hostLinkTarget(q: string): string { + let target = this.linkTargetCache.get(q) + if (target === undefined) { + const resolved = resolveSymlinkDenyDest(q) + target = resolved === '/' ? q : resolved + this.linkTargetCache.set(q, target) + } + return target } - return p } /** - * Mount a tmpfs over a read-denied directory (at `dest`, its resolved - * path), then restore the allowed write paths and allowRead paths the tmpfs - * just wiped: those at or under the directory in either spelling - * (`covers`), re-bound where `bindDestFor` (the caller's landing function - * for this tmpfs) says — the spelling the config named, which bwrap - * resolves inside the sandbox and recreates where a link the tmpfs replaced - * no longer exists, or the target of a path that is itself a still-present - * symlink. Used by the denyRead loop in - * generateFilesystemArgs and again when a late denyWrite ro-bind re-exposes - * a read-denied directory and the tmpfs must be re-applied on top. + * Read-deny paths shallow-first by resolved path, so a tmpfs over an ancestor + * directory lands before a mask on a file beneath it. + */ +function orderReadDenyPaths( + paths: readonly string[], + view: SandboxMountView, +): string[] { + const resolvedDepth = (p: string): number => + view.resolved(p).split('/').length + return [...new Set(paths)].sort((a, b) => resolvedDepth(a) - resolvedDepth(b)) +} + +/** + * Mount a tmpfs over a read-denied directory at `mount.dest`, then bind back + * the allowed write paths and allowRead paths the tmpfs just wiped: those at + * or under the directory in either spelling, each where + * {@link SandboxMountView.reBindLanding} puts it. Used by the denyRead loop + * in generateFilesystemArgs and again when a late denyWrite ro-bind + * re-exposes a read-denied directory and the tmpfs must be re-applied on top. */ function pushReadDenyDirMounts( args: string[], - { spelling, dest }: { spelling: string; dest: string }, + mount: ReadDenyTmpfsMount, allowedWritePaths: string[], readAllowPaths: string[], - covers: (p: string, dir: string) => boolean, - bindDestFor: (p: string) => string, + view: SandboxMountView, ): void { - args.push('--tmpfs', dest) + const covers = (p: string, dir: string): boolean => + view.atOrUnderEitherSpelling(p, dir) + const bindBack = (flag: '--bind' | '--ro-bind', source: string): void => { + const landing = view.reBindLanding(mount, source) + args.push(flag, source, landing) + view.record(landing, 'bind') + } + + args.push('--tmpfs', mount.dest) + view.record(mount.dest, 'tmpfs') // tmpfs wiped any earlier write binds under this path — restore them. for (const writePath of allowedWritePaths) { - if (covers(writePath, spelling)) { - args.push('--bind', writePath, bindDestFor(writePath)) + if (covers(writePath, mount.spelling)) { + bindBack('--bind', writePath) logForDebugging( `[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${writePath}`, ) @@ -941,7 +1050,7 @@ function pushReadDenyDirMounts( // After mounting tmpfs over the denied dir, bind back the allowed subdirectories // so they are readable again. for (const allowPath of readAllowPaths) { - if (covers(allowPath, spelling)) { + if (covers(allowPath, mount.spelling)) { if (!fs.existsSync(allowPath)) { logForDebugging( `[Sandbox Linux] Skipping non-existent read allow path: ${allowPath}`, @@ -953,12 +1062,14 @@ function pushReadDenyDirMounts( // re-bound (it wasn't wiped), so allowPath under it still needs // its own ro-bind here. if ( - allowedWritePaths.some(w => covers(w, spelling) && covers(allowPath, w)) + allowedWritePaths.some( + w => covers(w, mount.spelling) && covers(allowPath, w), + ) ) { continue } // Bind the allowed path back over the tmpfs so it's readable - args.push('--ro-bind', allowPath, bindDestFor(allowPath)) + bindBack('--ro-bind', allowPath) logForDebugging( `[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`, ) @@ -1554,126 +1665,17 @@ async function generateFilesystemArgs( const readAllowPaths = (readConfig?.allowWithinDeny || []).map(p => normalizePathForSandbox(p), ) - // A path and, when a symlink sits anywhere in it, its resolved form: the - // spelling the config named and the inode bwrap mounts. Every comparison - // between a read-deny mount and the paths that re-expose contents beneath - // it runs over both, so a carve-out written against a link or against - // its target both count (allowRead: node_modules/foo/public against a - // pnpm link, /bin/bash against /usr/bin on a usr-merged system). - const bothFormsCache = new Map() - const bothForms = (p: string): readonly string[] => { - let forms = bothFormsCache.get(p) - if (forms === undefined) { - const resolved = resolveEverySymlink(p) - forms = resolved === p ? [p] : [p, resolved] - bothFormsCache.set(p, forms) - } - return forms - } - const atOrUnderEitherForm = (p: string, dir: string): boolean => - bothForms(p).some(pf => bothForms(dir).some(df => isAtOrUnder(pf, df))) + const view = new SandboxMountView() // Files masked by --ro-bind below. Map of dest → source // (/dev/null for read-deny, the sentinel fake for credential mask). Used // to filter denyWriteArgs so that --ro-bind doesn't undo // the mask, and to re-apply the correct source if a denyWrite ancestor // bind re-exposes the dest. const maskedFiles = new Map() - // Directories masked by --tmpfs below, in emission (shallow-first) order: - // the spelling the config named, paired with where the mount landed. Used - // to filter denyWriteArgs the same way: a dir in both deny lists must not - // get its host contents re-bound on top of its own tmpfs. - const tmpfsMounts: Array<{ spelling: string; dest: string }> = [] - // The directory mounts emitted so far, by where they landed INSIDE the - // sandbox, in emission order: a tmpfs empties its landing, a re-bind - // shows a host directory (symlinks included) at its landing. The last - // one covering a path decides what that path is inside the sandbox. - const mountTrail = new Map() - let mountSeq = 0 - const recordMount = (landing: string, kind: 'tmpfs' | 'bind'): void => { - mountTrail.set(landing, { seq: mountSeq++, kind }) - } - const lastCovering = ( - q: string, - ): { landing: string; kind: 'tmpfs' | 'bind' } | undefined => { - let best: - | { landing: string; seq: number; kind: 'tmpfs' | 'bind' } - | undefined - for (let prefix = q; ; ) { - const m = mountTrail.get(prefix) - if (m !== undefined && (best === undefined || m.seq > best.seq)) { - best = { landing: prefix, ...m } - } - if (prefix === '/') break - const slash = prefix.lastIndexOf('/') - prefix = slash <= 0 ? '/' : prefix.slice(0, slash) - } - return best - } - // Host symlink lookups, cached: the host does not change while the - // profile is built, only the sandbox's view of it does. - const linkTargetCache = new Map() - const hostLinkTarget = (q: string): string | null => { - let t = linkTargetCache.get(q) - if (t === undefined) { - t = null - try { - if (fs.lstatSync(q).isSymbolicLink()) { - const r = fs.realpathSync(q) - if (r !== '/') t = r - } - } catch { - // absent or dangling: not a link to follow - } - linkTargetCache.set(q, t) - } - return t - } - // Where a mount whose destination is spelled `p` lands inside the - // sandbox, given the mounts emitted so far. bwrap resolves the - // destination in the new root: a component is a symlink there only while - // its parent is still host-visible (outside every tmpfs, or inside a - // directory re-bound over one); beneath a tmpfs the components are plain - // directories bwrap creates, so they stay as spelled. Host-visible - // territory is identity-mapped, so a link there resolves as on the host. - const landingOf = (p: string): string => { - let cur = '/' - for (const c of p.split('/')) { - if (c === '') continue - const next = cur === '/' ? `/${c}` : `${cur}/${c}` - if (lastCovering(cur)?.kind === 'tmpfs') { - cur = next - continue - } - cur = hostLinkTarget(next) ?? next - } - return cur - } - // Where a re-bind of an allowed path `p` lands for the tmpfs (spelling S, - // landing T) it restores: the part of `p` beneath S — in whichever - // spelling put it there — re-rooted at T, then landed like any other - // destination (a link still alive there is followed; bwrap refuses a - // symlink as a destination and, before 0.12, an absolute one anywhere in - // the path). - const reBindDestFor = - (tmpfs: { spelling: string; dest: string }) => - (p: string): string => { - let rel: string | undefined - outer: for (const pf of bothForms(p)) { - for (const sf of [...bothForms(tmpfs.spelling), tmpfs.dest]) { - if (isAtOrUnder(pf, sf)) { - rel = pf.slice(sf.length) - break outer - } - } - } - const landing = landingOf( - rel === undefined - ? p - : (tmpfs.dest === '/' ? '' : tmpfs.dest) + rel || '/', - ) - recordMount(landing, 'bind') - return landing - } + // Directories masked by --tmpfs below, in emission order. Used to filter + // denyWriteArgs the same way: a dir in both deny lists must not get its + // host contents re-bound on top of its own tmpfs. + const tmpfsMounts: ReadDenyTmpfsMount[] = [] // --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds). // Expand a root deny into its direct children so the existing per-dir tmpfs @@ -1701,31 +1703,10 @@ async function generateFilesystemArgs( readDenyPaths.push('/etc/ssh/ssh_config.d') } - // Normalize then sort shallow-first so tmpfs over ancestor dirs lands before - // /dev/null masks on descendant files. Otherwise a file-deny listed before - // a dir-deny in denyRead gets wiped when the ancestor tmpfs is applied. - // Depth is measured where the mount lands (a directory's tmpfs goes on its - // resolved path), so a symlink deny spelled shallow still follows a - // separately denied ancestor of its target. - const landingDepth = (p: string): number => { - const forms = bothForms(p) - return forms[forms.length - 1]!.split('/').length - } - const normalizedDenyPaths = readDenyPaths - .map(p => normalizePathForSandbox(p)) - .sort((a, b) => landingDepth(a) - landingDepth(b)) - - // A read-deny path at-or-under a tmpfs this loop already emitted (the - // shallow-first order above visits the covering directory first), in - // either spelling, is hidden by it unless an allowRead/allowWrite path - // at-or-under that tmpfs and at-or-above the path 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, a symlink plus its target) would otherwise cost one bwrap - // mount per file. The same question isHiddenByTmpfs below asks of the - // buffered denyWrite binds. - const hiddenByEmittedTmpfs = (landing: string): boolean => - lastCovering(landing)?.kind === 'tmpfs' + const normalizedDenyPaths = orderReadDenyPaths( + readDenyPaths.map(p => normalizePathForSandbox(p)), + view, + ) for (const normalizedPath of normalizedDenyPaths) { if (!fs.existsSync(normalizedPath)) { @@ -1735,31 +1716,31 @@ async function generateFilesystemArgs( continue } - const readDenyStat = fs.statSync(normalizedPath) - if (readDenyStat.isDirectory()) { - // A directory is denied where its spelling lands inside the sandbox - // AND at its host target (a symlink's realpath), each unless an - // emitted tmpfs already hides that place. The two differ when a link - // on the way was wiped by an earlier tmpfs and recreated by a - // carve-out re-bind: the recreated path is a directory of its own, - // and the target is still reachable by its real name. + // A path is denied where its spelling lands inside the sandbox AND at + // its host target, each unless a tmpfs emitted so far already hides that + // place (a mount there would be created inside the tmpfs and change + // nothing). The two differ when a link on the way was wiped by an earlier + // tmpfs and recreated by a carve-out bound back over it: the recreated + // path is a directory of its own, and the target is still reachable by + // its real name. + const dests = new Set([ + view.landingOf(normalizedPath), + view.resolved(normalizedPath), + ]) + + if (fs.statSync(normalizedPath).isDirectory()) { let mounted = false - for (const dest of new Set([ - landingOf(normalizedPath), - resolveEverySymlink(normalizedPath), - ])) { - if (hiddenByEmittedTmpfs(dest)) continue + for (const dest of dests) { + if (view.underTmpfs(dest)) continue mounted = true const mount = { spelling: normalizedPath, dest } tmpfsMounts.push(mount) - recordMount(dest, 'tmpfs') pushReadDenyDirMounts( args, mount, allowedWritePaths, readAllowPaths, - atOrUnderEitherForm, - reBindDestFor(mount), + view, ) } if (!mounted) { @@ -1775,7 +1756,9 @@ async function generateFilesystemArgs( // + allowRead: ['.'] silently drops the .env deny. if ( readAllowPaths.some(allowPath => - bothForms(allowPath).some(f => bothForms(normalizedPath).includes(f)), + view + .spellings(allowPath) + .some(f => view.spellings(normalizedPath).includes(f)), ) ) { logForDebugging( @@ -1783,25 +1766,14 @@ async function generateFilesystemArgs( ) continue } - // For files, bind /dev/null instead of tmpfs. bwrap rejects symlink - // bind destinations, so the deny bind lands on the resolved target. - // A file that is itself a symlink to an already masked one needs - // nothing; a plain file under a directory link is masked once per - // spelling, since a carve-out re-bind may have recreated the link's - // path as a directory of its own. - // Masked where the spelling lands inside the sandbox and at the host - // target, as for directories. + // For files, bind /dev/null instead of tmpfs. A file that is itself a + // symlink to an already masked one needs nothing. let masked = false - for (const denyDest of new Set([ - landingOf(normalizedPath), - resolveEverySymlink(normalizedPath), - ])) { - if (maskedFiles.has(denyDest) || hiddenByEmittedTmpfs(denyDest)) { - continue - } + for (const dest of dests) { + if (maskedFiles.has(dest) || view.underTmpfs(dest)) continue masked = true - args.push('--ro-bind', '/dev/null', denyDest) - maskedFiles.set(denyDest, '/dev/null') + args.push('--ro-bind', '/dev/null', dest) + maskedFiles.set(dest, '/dev/null') } if (!masked) { logForDebugging( @@ -1849,11 +1821,11 @@ async function generateFilesystemArgs( // hit on either means the tmpfs really does cover this bind. const isHiddenByTmpfs = (dest: string): boolean => tmpfsMounts.some(({ spelling: tmpfsDir }) => { - if (!atOrUnderEitherForm(dest, tmpfsDir)) return false + if (!view.atOrUnderEitherSpelling(dest, tmpfsDir)) return false const reExposedByWriteBind = allowedWritePaths.some( writePath => - atOrUnderEitherForm(writePath, tmpfsDir) && - atOrUnderEitherForm(dest, writePath), + view.atOrUnderEitherSpelling(writePath, tmpfsDir) && + view.atOrUnderEitherSpelling(dest, writePath), ) return !reExposedByWriteBind }) @@ -1870,9 +1842,9 @@ async function generateFilesystemArgs( continue } args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) - // A host directory bound here is host-visible territory again for any - // mount re-applied beneath it below. - if (denyWriteArgs[i + 1] === dest) recordMount(dest, 'bind') + // A host directory bound here is host-visible territory again for the + // tmpfsMounts re-application beneath it. + if (denyWriteArgs[i + 1] === dest) view.record(dest, 'bind') emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit // above a read-denied path?". A bind at the resolved dest also re-exposes @@ -1896,14 +1868,12 @@ async function generateFilesystemArgs( logForDebugging( `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${dest}`, ) - recordMount(dest, 'tmpfs') pushReadDenyDirMounts( args, mount, allowedWritePaths, readAllowPaths, - atOrUnderEitherForm, - reBindDestFor(mount), + view, ) } } diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 27ed8e09a..6ac2dd554 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,99 +1,68 @@ -import * as fs from 'node:fs' import { logForDebugging } from '../utils/debug.js' -import { removeTrailingGlobSuffix, walkGlobPattern } from './sandbox-utils.js' +import { + normalizePathForSandbox, + pathSpellings, + properAncestors, + 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. */ -export const READ_DENY_GLOB_MOUNT_WARN_THRESHOLD = 256 - -/** The nearest proper prefix of `p` (at a segment boundary) found in `set`. */ -function nearestPrefixIn( - set: ReadonlySet, - p: string, -): string | undefined { - // Proper prefixes only: slash > 0 skips p itself and the root, which the - // walk never yields. - for ( - let slash = p.lastIndexOf('/'); - slash > 0; - slash = p.lastIndexOf('/', slash - 1) - ) { - const prefix = p.slice(0, slash) - if (set.has(prefix)) return prefix - } - return undefined -} +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 and no - * re-exposer sits between the two (one at the ancestor counts: the deny - * loop re-binds it over the tmpfs, so everything beneath needs its own). + * re-exposer sits at the ancestor or between the two. */ -export function collapseReadDenyMounts({ +function collapseReadDenyMounts({ matches, reExposedPaths, }: { /** Absolute, normalized, trailing-slash-free paths; a match reached * through a symlink appears in both its spellings. */ - matches: readonly string[] + matches: Iterable /** allowRead/allowWrite paths the denyRead loop re-binds over a tmpfs, in * every spelling that can name them. */ - reExposedPaths: readonly string[] + reExposedPaths: ReadonlySet }): 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) { - 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 + // A re-exposer at the kept ancestor counts: the deny loop binds it back + // over the tmpfs, so everything beneath needs its own mount. + let reExposedBetween = reExposedPaths.has(candidate) + let hidden = false + for (const ancestor of properAncestors(candidate)) { + if (reExposedPaths.has(ancestor)) reExposedBetween = true + if (kept.has(ancestor)) { + hidden = true break } } - if (ancestor === undefined || reExposedBetween) kept.add(candidate) + if (!hidden || reExposedBetween) kept.add(candidate) } return [...kept] } /** * 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). A pattern ending in `/**` also takes its - * directory form, so `**\/build/**` yields one mount per `build/` directory. - * - * Symlinks: a match keeps its spelling, so a carve-out written against a - * link still matches, and a match that reaches its inode through a link - * (one the walk descended, or one above the walk) is listed in its resolved - * spelling as well, so a carve-out written against the target matches too - * and the target stays denied where the link itself vanishes (a covering - * directory's tmpfs replaces the links beneath it with nothing). The deny - * loop mounts each entry at its resolved path, compares re-exposers in both - * spellings, and skips the second spelling of an inode it has covered. + * against `reExposedPaths` (the caller's allowRead and allowWrite entries). + * A pattern ending in `/**` also takes its directory form, so + * `**\/build/**` yields one mount per `build/` directory. A match reached + * through a symlink is listed in its resolved spelling as well. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], ): string[] { - const directoryForm = removeTrailingGlobSuffix(globPattern) - const walk = walkGlobPattern(globPattern, { - directoryPattern: directoryForm === globPattern ? undefined : directoryForm, - }) + const walk = walkGlobPattern(globPattern, { withDirectoryForm: true }) const candidates = new Set(walk.matches) if (walk.directoryMatches.length > 0) { // Everything beneath a directory-form match is itself a match (the @@ -111,44 +80,44 @@ export function expandReadDenyGlobLinux( } } - const realpathOf = (p: string): string | undefined => { - try { - return fs.realpathSync(p) - } catch { - return undefined // dangling or vanished: keep the spelling - } - } - // Re-exposers in both spellings, as the deny loop compares them. - const reExposedBothForms = new Set() - for (const p of reExposedPaths) { - reExposedBothForms.add(p) - const resolved = realpathOf(p) - if (resolved !== undefined) reExposedBothForms.add(resolved) - } + const reExposed = new Set( + reExposedPaths.flatMap(p => pathSpellings(normalizePathForSandbox(p))), + ) // Resolved spellings: a realpath for a match under a link the walk // descended, a string swap for one under a symlinked base. - const throughWalkLink = (p: string): boolean => - walk.symlinks.has(p) || nearestPrefixIn(walk.symlinks, p) !== undefined - const baseSwapped = walk.baseReal !== walk.baseDir - for (const m of [...candidates]) { - let resolved: string | undefined - if (throughWalkLink(m)) resolved = realpathOf(m) - else if (baseSwapped) - resolved = walk.baseReal + m.slice(walk.baseDir.length) - if (resolved === '/') { - // A link to the root: a tmpfs there would hide everything. The - // spelling stays, and bwrap refuses to mount on the link. - logForDebugging( - `[Sandbox Linux] denyRead glob "${globPattern}": ${m} resolves to /, not denied at its target`, + const throughWalkLink = (p: string): boolean => { + if (walk.symlinks.has(p)) return true + for (const ancestor of properAncestors(p)) { + if (walk.symlinks.has(ancestor)) return true + } + return false + } + const base = walk.base + const swappedBase = + base !== undefined && base.real !== base.dir ? base : undefined + for (const match of [...candidates]) { + if (throughWalkLink(match)) { + const spellings = pathSpellings(match) + if (spellings.length === 2) { + candidates.add(spellings[1]) + } else if (walk.symlinks.has(match)) { + // The spelling stays, and bwrap refuses to mount on the link. + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${match} is dangling or resolves to /, not denied at its target`, + { level: 'warn' }, + ) + } + } else if (swappedBase !== undefined) { + const beneathBase = match.slice(swappedBase.dir.length) + candidates.add( + swappedBase.real === '/' ? beneathBase : swappedBase.real + beneathBase, ) - continue } - if (resolved !== undefined) candidates.add(resolved) } const mounts = collapseReadDenyMounts({ - matches: [...candidates], - reExposedPaths: [...reExposedBothForms], + matches: candidates, + reExposedPaths: reExposed, }) logForDebugging( diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 4b853f0f3..3fd598bb0 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -81,7 +81,6 @@ import { containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, - normalizePathForSandbox, decodeSandboxedCommand, encodeSandboxedCommand, } from './sandbox-utils.js' @@ -1167,48 +1166,36 @@ function unionDenyReadPaths( } /** - * 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. + * Strip a trailing `/**` from each read-path entry and, on Linux, replace + * any remaining glob with what `expandGlob` returns for it (bubblewrap takes + * concrete paths only). Other platforms match globs natively. */ function resolveReadPathEntries( paths: readonly string[], - denyReExposers?: () => readonly string[], + expandGlob: (pattern: string) => string[], ): string[] { - let reExposers: readonly string[] | undefined - const out: string[] = [] - for (const p of paths) { + return paths.flatMap(p => { 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 + return getPlatform() === 'linux' && containsGlobChars(stripped) + ? expandGlob(p) + : [stripped] + }) +} + +function expandAllowReadGlob(pattern: string): string[] { + const expanded = expandGlobPattern(pattern) + logForDebugging( + `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, + ) + return expanded } /** * 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, which recomputes the mount set. + * On Linux, denyRead globs are collapsed to covering directory mounts against + * this config's allowRead and {@link getFsWriteConfig}'s allowOnly, so + * `denyOnly` is only sound alongside that write config and must not be handed + * to wrapCommandWithSandboxLinux with a different one. */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { @@ -1225,18 +1212,18 @@ function getFsReadConfig(): FsReadRestrictionConfig { ), ) - // 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 ?? []) - - // 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, - ]) + // allowRead (re-allow within denied regions) is resolved first: the + // denyRead glob expansion collapses against it. + const allowPaths = resolveReadPathEntries( + config.filesystem.allowRead ?? [], + expandAllowReadGlob, + ) + const denyPaths = resolveReadPathEntries(rawDenyRead, pattern => + expandReadDenyGlobLinux(pattern, [ + ...allowPaths, + ...getFsWriteConfig().allowOnly, + ]), + ) return { denyOnly: denyPaths, @@ -1624,6 +1611,7 @@ async function wrapWithSandbox( // directory (allowRead + allowWrite), so both must be final here. const expandedAllowRead = resolveReadPathEntries( customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], + expandAllowReadGlob, ) // 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 @@ -1635,11 +1623,10 @@ async function wrapWithSandbox( if (javaAgentJarPath) { expandedAllowRead.push(javaAgentJarPath) } - const writeAllowOnly = writeConfig.allowOnly - const expandedDenyRead = resolveReadPathEntries(rawDenyRead, () => [ - ...expandedAllowRead, - ...writeAllowOnly, - ]) + const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly] + const expandedDenyRead = resolveReadPathEntries(rawDenyRead, pattern => + expandReadDenyGlobLinux(pattern, reExposedPaths), + ) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index f2ec50d1c..c5735aa2d 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -65,6 +65,36 @@ export function isStrictlyUnder(p: string, dir: string): boolean { return p !== dir && isAtOrUnder(p, dir) } +/** The proper ancestors of an absolute POSIX path, nearest first, ending at '/'. */ +export function* properAncestors(p: string): Generator { + for ( + let slash = p.lastIndexOf('/'); + slash > 0; + slash = p.lastIndexOf('/', slash - 1) + ) { + yield p.slice(0, slash) + } + if (p !== '/') yield '/' +} + +/** A path as spelled and, when that differs, with every symlink resolved. */ +export type PathSpellings = readonly [string] | readonly [string, string] + +/** + * The spellings that name `p` for a mount comparison. The spelling alone when + * `p` cannot be resolved (dangling, vanished) or resolves to '/': a mount + * over the root would hide everything. + */ +export function pathSpellings(p: string): PathSpellings { + try { + const resolved = fs.realpathSync(p) + if (resolved !== p && resolved !== '/') return [p, resolved] + } catch { + // Dangling or vanished: only the spelling names it. + } + return [p] +} + /** * Check if a path pattern contains glob characters */ @@ -890,19 +920,17 @@ export interface ExpandGlobOptions { 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. */ + /** With `withDirectoryForm`: directories (a symlink to one included) + * matching the pattern without its trailing `/**`. */ directoryMatches: string[] /** Every visited entry that is a symbolic link, by full path. The walk * descends into symlinked directories, so a match beneath one really * lives outside the tree it was found in. */ symlinks: Set /** The directory listed (the pattern's static prefix) and its resolved - * form; they differ when a symlink sits above the walk, in which case - * every match has a second, resolved spelling. Empty when nothing was - * listed. */ - baseDir: string - baseReal: string + * form, which differ when a symlink sits above the walk: every match then + * has a second, resolved spelling. Unset when nothing was listed. */ + base?: { dir: string; real: string } } /** @@ -924,20 +952,18 @@ export function expandGlobPattern( } /** - * 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. + * The walk behind {@link expandGlobPattern}: one listing of the pattern's + * static prefix, filtered by `globPath` and, with `withDirectoryForm`, by + * `globPath` without its trailing `/**`, with the symlinks seen recorded. */ export function walkGlobPattern( globPath: string, - opts: ExpandGlobOptions & { directoryPattern?: string } = {}, + opts: ExpandGlobOptions & { withDirectoryForm?: boolean } = {}, ): GlobWalk { const walk: GlobWalk = { matches: [], directoryMatches: [], symlinks: new Set(), - baseDir: '', - baseReal: '', } // Normalize to `/` separators throughout so {@link globToRegex} @@ -967,33 +993,22 @@ export function walkGlobPattern( ) return walk } - walk.baseDir = baseDir const flags = opts.caseInsensitive ? 'i' : '' const regex = new RegExp(globToRegex(normalizedPattern), flags) + const directoryForm = removeTrailingGlobSuffix(normalizedPattern) 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 Bun's throws ELOOP - // on and Node's (22.13 and later) follows to the kernel's link limit, - // listing some forty phantom copies — would void or bloat the whole - // pattern, and a read-deny glob would silently deny nothing. Symlinked - // directories are descended like any other on every runtime (Node before - // 22.13 never descended one; 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. + opts.withDirectoryForm && directoryForm !== normalizedPattern + ? new RegExp(globToRegex(directoryForm), flags) + : undefined + + // One readdir per directory rather than readdirSync's `recursive` option, + // so an unreadable subtree or a symlink cycle costs only itself, not the + // whole pattern. Symlinked directories are descended: every spelling the + // sandboxed command could read through is listed, so a target reached + // twice is listed twice. The one exception is a link whose target is at or + // above a directory on the current descent (the real directory each earlier + // link was taken from, and this one), which would never terminate. type Frame = { dir: string /** `dir` with every symlink resolved. */ @@ -1007,10 +1022,10 @@ export function walkGlobPattern( } catch { // Vanished between the existence check and here: list what remains. } - walk.baseReal = baseReal + walk.base = { dir: baseDir, real: baseReal } const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] - while (pending.length > 0) { - const { dir, real, linkedFrom } = pending.pop()! + for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) { + const { dir, real, linkedFrom } = frame let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) @@ -1040,19 +1055,18 @@ export function walkGlobPattern( if (!entry.isSymbolicLink()) continue walk.symlinks.add(fullPath) if (process.platform === 'win32') { - // A reparse point (junction, directory symlink) is listed but not - // descended, as readdirSync's recursive listing never did on - // Windows; the cycle check below also speaks POSIX separators. + // The Windows ACL expansion does not follow reparse points + // (junctions, directory symlinks), and the `cycle` check compares + // POSIX-separated paths. continue } - // 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. + // Dangling, vanished or not traversable: nothing to descend into. } if (target === undefined) continue if (directoryRegex?.test(candidate)) { diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 1eb34b3ee..e42ca2df4 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { mkdirSync, + mkdtempSync, writeFileSync, rmSync, existsSync, @@ -192,18 +193,21 @@ describe.if(!isWindows)('walkGlobPattern', () => { rmSync(RAW_BASE, { recursive: true, force: true }) }) - it('evaluates the directory pattern over the same listing and records symlinks', () => { + it('evaluates the directory form 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'), + const walk = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**'), { + withDirectoryForm: true, }) 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')]) - expect(walk.baseDir).toBe(BASE) - expect(walk.baseReal).toBe(BASE) + expect(walk.base).toEqual({ dir: join(BASE, 'a'), real: join(BASE, 'a') }) + }) + + it('lists no directory matches without the directory form', () => { + const walk = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**')) + expect(walk.directoryMatches).toEqual([]) }) it('reports a symlinked base in both spellings', () => { @@ -219,8 +223,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(BASE, alias) try { const walk = walkGlobPattern(join(alias, '**/build/**')) - expect(walk.baseDir).toBe(alias) - expect(walk.baseReal).toBe(BASE) + expect(walk.base).toEqual({ dir: alias, real: BASE }) expect(walk.matches).toContain(join(alias, 'a', 'build', '1.out')) } finally { rmSync(alias) @@ -228,37 +231,31 @@ describe.if(!isWindows)('walkGlobPattern', () => { }) it('terminates on a symlink cycle and still lists the tree', () => { - // build/up -> .. : a recursive readdir throws ELOOP (Bun) or follows - // the link to the kernel's limit and lists ~40 phantom copies (Node - // 22.13+); 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) + // build/up -> ..: the link leads back into its own ancestry, so a walk + // that followed it would never end. + const cyc = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-cycle-'))) + try { + mkdirSync(join(cyc, 'build')) + writeFileSync(join(cyc, 'build', '1.out'), '') + symlinkSync('..', join(cyc, 'build', 'up')) + + const walk = walkGlobPattern(join(cyc, '**/build/**'), { + withDirectoryForm: true, + }) + + expect(walk.matches).toContain(join(cyc, 'build', '1.out')) + expect(walk.directoryMatches).toEqual([join(cyc, 'build')]) + expect(walk.symlinks.has(join(cyc, 'build', 'up'))).toBe(true) + // The cycle is not re-entered: nothing appears twice. + expect(new Set(walk.matches).size).toBe(walk.matches.length) + } finally { + rmSync(cyc, { recursive: true, force: true }) + } }) - 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(), - baseDir: '', - baseReal: '', - }) + it('leaves the base unset when nothing was listed', () => { + const walk = walkGlobPattern(join(RAW_BASE, 'nope', '*.env')) + expect(walk.base).toBeUndefined() }) }) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 96c63bc97..c7a50500f 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { lstatSync, mkdirSync, @@ -11,11 +11,7 @@ import { import { spawnSync } from 'node:child_process' 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 { expandReadDenyGlobLinux } from '../../src/sandbox/read-deny-glob.js' import { expandGlobPattern } from '../../src/sandbox/sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { @@ -24,145 +20,49 @@ import { } from '../../src/sandbox/linux-sandbox-utils.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 listed in its resolved - * spelling too, so the inode stays denied where the link itself vanishes, - * and the deny loop mounts every directory at its resolved path. - */ - -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)', () => { +describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { 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' + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-rules-'))) + // build/ and its string-prefix sibling build-cache/, each with a nested + // directory. + for (const dir of ['build', 'build-cache']) { + mkdirSync(join(ROOT, dir, 'sub'), { recursive: true }) + writeFileSync(join(ROOT, dir, '1.out'), '') + writeFileSync(join(ROOT, dir, 'sub', '2.out'), '') + } }) 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('does not treat a string-prefix sibling as an ancestor', () => { + const mounts = expandReadDenyGlobLinux(join(ROOT, 'build*/**'), []) + expect(mounts).toEqual([join(ROOT, 'build'), join(ROOT, 'build-cache')]) + }) - 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('treats a re-exposer AT the covering directory as re-exposing everything beneath it', () => { + // denyRead and allowRead naming the same directory: its tmpfs is bound + // back at once, so every match beneath needs its own mount. + const build = join(ROOT, 'build') + const mounts = expandReadDenyGlobLinux(join(ROOT, 'build*/**'), [build]) + expect(mounts).toEqual([ + build, + join(ROOT, 'build-cache'), + join(build, '1.out'), + join(build, 'sub'), + ]) }) - 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([]) + it('ignores re-exposers below the candidate or unrelated to it', () => { + const mounts = expandReadDenyGlobLinux(join(ROOT, 'build*/**'), [ + join(ROOT, 'build', 'sub', '2.out', 'deeper'), + join(ROOT, 'buildx'), + '/elsewhere', + ]) + expect(mounts).toEqual([join(ROOT, 'build'), join(ROOT, 'build-cache')]) }) }) @@ -185,13 +85,6 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { 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. @@ -232,27 +125,20 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // entries beneath keep their own mounts, and nothing else beneath it. mkdirSync(join(OUTSIDE, 'pub')) writeFileSync(join(OUTSIDE, 'pub', 'x.txt'), '') - const mounts = expandReadDenyGlobLinux( - join(ROOT, 'pkg', 'a', '**/build/**'), - [join(OUTSIDE, 'pub')], - ) - - expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) - expect(mounts).toContain(OUTSIDE) - expect(mounts).toContain(join(OUTSIDE, 'pub')) - expect(mounts).toContain(join(OUTSIDE, 'pub', 'x.txt')) - expect(mounts).not.toContain(join(OUTSIDE, 'secret.txt')) - rmSync(join(OUTSIDE, 'pub'), { recursive: true }) - }) - - it('resolves a relative directory symlink the same way', () => { - const build = join(ROOT, 'pkg', 'c', 'build') - const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + try { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'pkg', 'a', '**/build/**'), + [join(OUTSIDE, 'pub')], + ) - expect(mounts).toContain(build) - expect(mounts).toContain(OUTSIDE) - expect(mounts).not.toContain(join(build, 'rel')) - expect(mounts).not.toContain(join(build, 'rel', 'secret.txt')) + expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) + expect(mounts).toContain(OUTSIDE) + expect(mounts).toContain(join(OUTSIDE, 'pub')) + expect(mounts).toContain(join(OUTSIDE, 'pub', 'x.txt')) + expect(mounts).not.toContain(join(OUTSIDE, 'secret.txt')) + } finally { + rmSync(join(OUTSIDE, 'pub'), { recursive: true }) + } }) it('gives an empty matched directory no mount', () => { @@ -368,23 +254,23 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { describe('carve-out through a symlink (pnpm layout)', () => { // node_modules/foo -> ../.pnpm/foo@1/node_modules/foo, the shape pnpm // installs; the glob matches both the link and the real tree. - let P: string + let pnpmRoot: string let real: string let link: string beforeAll(() => { - P = join(ROOT, 'pnpm') - real = join(P, '.pnpm', 'foo@1', 'node_modules', 'foo') - link = join(P, 'node_modules', 'foo') + pnpmRoot = join(ROOT, 'pnpm') + real = join(pnpmRoot, '.pnpm', 'foo@1', 'node_modules', 'foo') + link = join(pnpmRoot, 'node_modules', 'foo') mkdirSync(join(real, 'public'), { recursive: true }) writeFileSync(join(real, 'index.js'), '') writeFileSync(join(real, 'public', 'ok.txt'), '') - mkdirSync(join(P, 'node_modules')) + mkdirSync(join(pnpmRoot, 'node_modules')) symlinkSync(join('..', '.pnpm', 'foo@1', 'node_modules', 'foo'), link) }) it('keeps the carve-out written against the link spelling', () => { const mounts = expandReadDenyGlobLinux( - join(P, '**/node_modules/foo/**'), + join(pnpmRoot, '**/node_modules/foo/**'), [join(link, 'public')], ) @@ -399,7 +285,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { it('keeps the carve-out written against the resolved spelling', () => { const mounts = expandReadDenyGlobLinux( - join(P, '**/node_modules/foo/**'), + join(pnpmRoot, '**/node_modules/foo/**'), [join(real, 'public')], ) @@ -415,8 +301,8 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // which re-exposes nothing beneath a tmpfs, so the package still // collapses to its two spellings. const mounts = expandReadDenyGlobLinux( - join(P, '**/node_modules/foo/**'), - [P], + join(pnpmRoot, '**/node_modules/foo/**'), + [pnpmRoot], ) expect(mounts).toEqual([real, link]) @@ -431,7 +317,7 @@ describe.if(isLinux)( // may land on a symlink (bubblewrap 0.12 refuses to start), and the // carve-out must be the last word on the package's inode. let ROOT: string - let P: string + let pnpmRoot: string let real: string let link: string const savedCwd = process.cwd() @@ -439,13 +325,13 @@ describe.if(isLinux)( beforeAll(() => { ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bwrap-'))) - P = join(ROOT, 'pnpm') - real = join(P, '.pnpm', 'foo@1', 'node_modules', 'foo') - link = join(P, 'node_modules', 'foo') + pnpmRoot = join(ROOT, 'pnpm') + real = join(pnpmRoot, '.pnpm', 'foo@1', 'node_modules', 'foo') + link = join(pnpmRoot, 'node_modules', 'foo') mkdirSync(join(real, 'public'), { recursive: true }) writeFileSync(join(real, 'index.js'), 'secret') writeFileSync(join(real, 'public', 'ok.txt'), 'public') - mkdirSync(join(P, 'node_modules')) + mkdirSync(join(pnpmRoot, 'node_modules')) symlinkSync(join('..', '.pnpm', 'foo@1', 'node_modules', 'foo'), link) process.chdir(ROOT) }) @@ -461,9 +347,10 @@ describe.if(isLinux)( command, needsNetworkRestriction: false, readConfig: { - denyOnly: expandReadDenyGlobLinux(join(P, '**/node_modules/foo/**'), [ - carveOut, - ]), + denyOnly: expandReadDenyGlobLinux( + join(pnpmRoot, '**/node_modules/foo/**'), + [carveOut], + ), allowWithinDeny: [carveOut], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, @@ -531,9 +418,10 @@ describe.if(isLinux)( command: 'true', needsNetworkRestriction: false, readConfig: { - denyOnly: expandReadDenyGlobLinux(join(P, '**/node_modules/foo/**'), [ - carveOut, - ]), + denyOnly: expandReadDenyGlobLinux( + join(pnpmRoot, '**/node_modules/foo/**'), + [carveOut], + ), allowWithinDeny: [carveOut], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, @@ -550,17 +438,17 @@ describe.if(isLinux)( // denyWrite names the pnpm store, which contains the package's real // location but not its link spelling; the bind lands after the tmpfs // and would re-expose the package read-only without a re-application. - const store = join(P, '.pnpm') + const store = join(pnpmRoot, '.pnpm') const wrapped = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, readConfig: { denyOnly: expandReadDenyGlobLinux( - join(P, '**/node_modules/foo/**'), + join(pnpmRoot, '**/node_modules/foo/**'), [], ), }, - writeConfig: { allowOnly: [P], denyWithinAllow: [store] }, + writeConfig: { allowOnly: [pnpmRoot], denyWithinAllow: [store] }, }) const storeBind = wrapped.lastIndexOf(`--ro-bind ${store} ${store}`) @@ -621,9 +509,7 @@ describe.if(isLinux)( expect(wrapped).not.toContain(`--ro-bind ${link} ${link}`) }) - it('re-binds a literal carve-out written in the other spelling', async () => { - // Literal denies, no glob: denyRead names the link and allowRead its - // target's subdirectory, and the mirror. + it('re-binds a literal carve-out written against the target of a denied link', async () => { const viaLink = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, @@ -637,7 +523,9 @@ describe.if(isLinux)( expect(viaLink).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) + }) + it('re-binds a literal carve-out written against a link to the denied directory', async () => { const viaTarget = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, @@ -718,23 +606,23 @@ describe.if(isLinux)( }) it('re-binds a carve-out through an absolute intermediate symlink without a symlink in the destination', async () => { - // denyRead [T] + allowRead [P/L/sub], P/L -> T absolute: bubblewrap - // before 0.12 aborts on an absolute link inside a destination. - const T = join(ROOT, 's4', 'T') - const P = join(ROOT, 's4', 'P') - mkdirSync(join(T, 'sub'), { recursive: true }) - mkdirSync(P, { recursive: true }) - writeFileSync(join(T, 'sub', 'f'), 'F') - symlinkSync(T, join(P, 'L')) + // denyRead [target] + allowRead [parent/link/sub], parent/link -> + // target absolute: bubblewrap before 0.12 aborts on an absolute link + // inside a destination. + const target = join(ROOT, 's4', 'target') + const parent = join(ROOT, 's4', 'parent') + mkdirSync(join(target, 'sub'), { recursive: true }) + mkdirSync(parent, { recursive: true }) + writeFileSync(join(target, 'sub', 'f'), 'F') + symlinkSync(target, join(parent, 'link')) + const carveOut = join(parent, 'link', 'sub') const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(P, 'L', 'sub', 'f')}`, + command: `cat ${join(carveOut, 'f')}`, needsNetworkRestriction: false, - readConfig: { denyOnly: [T], allowWithinDeny: [join(P, 'L', 'sub')] }, + readConfig: { denyOnly: [target], allowWithinDeny: [carveOut] }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).toContain( - `--ro-bind ${join(P, 'L', 'sub')} ${join(T, 'sub')}`, - ) + expect(wrapped).toContain(`--ro-bind ${carveOut} ${join(target, 'sub')}`) if (hasBwrap) { const run = spawnSync(wrapped, { shell: true, @@ -748,8 +636,8 @@ describe.if(isLinux)( it('still masks the target of a file symlink listed beneath a denied directory', async () => { // denyRead [cfg, cfg/token], cfg/token -> ../secrets/token: the link - // vanishes with cfg's tmpfs, but the file it named is the target, and - // main masked it there (resolveSymlinkDenyDest); it must stay masked. + // vanishes with cfg's tmpfs, but the file it named is the target, + // which stays reachable by its own name and must be masked there. const cfg = join(ROOT, 's9', 'cfg') const secrets = join(ROOT, 's9', 'secrets') mkdirSync(cfg, { recursive: true }) @@ -769,41 +657,47 @@ describe.if(isLinux)( encoding: 'utf8', timeout: 15000, }) + expect(run.status).toBe(0) expect(run.stdout).not.toContain('TOKEN') } }) - it('emits one tmpfs for a directory and the files a glob lists beneath it', async () => { - // The cross-entry dedup: a directory deny plus per-file entries under - // it cost one mount, unless a carve-out keeps the masks beneath it - // meaningful. - const big = join(ROOT, 'big') - mkdirSync(join(big, 'keep'), { recursive: true }) - const keys = ['a.key', 'b.key', 'keep/c.key'].map(k => join(big, k)) - for (const k of keys) writeFileSync(k, '') + describe('a directory deny plus per-file entries beneath it', () => { + let big: string + let keys: string[] + beforeAll(() => { + big = join(ROOT, 'big') + mkdirSync(join(big, 'keep'), { recursive: true }) + keys = ['a.key', 'b.key', 'keep/c.key'].map(k => join(big, k)) + for (const k of keys) writeFileSync(k, '') + }) - const collapsed = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: { denyOnly: [big, ...keys] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + it('costs one tmpfs', async () => { + const collapsed = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [big, ...keys] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(collapsed.split(`--tmpfs ${big}`)).toHaveLength(2) + expect(collapsed).not.toContain(`/dev/null ${big}/`) }) - expect(collapsed.split(`--tmpfs ${big}`)).toHaveLength(2) - expect(collapsed).not.toContain(`/dev/null ${big}/`) - const carved = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: { - denyOnly: [big, ...keys], - allowWithinDeny: [join(big, 'keep')], - }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + it('keeps the masks beneath a carve-out', async () => { + const carved = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [big, ...keys], + allowWithinDeny: [join(big, 'keep')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(carved).toContain( + `--ro-bind /dev/null ${join(big, 'keep', 'c.key')}`, + ) + expect(carved).not.toContain(`/dev/null ${join(big, 'a.key')}`) }) - expect(carved).toContain( - `--ro-bind /dev/null ${join(big, 'keep', 'c.key')}`, - ) - expect(carved).not.toContain(`/dev/null ${join(big, 'a.key')}`) }) }, ) @@ -835,13 +729,11 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { 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', () => { @@ -850,22 +742,19 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { 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. + // What the carve-out binds back keeps its own masks. 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. + // The trailing slash is stripped before the collapse compares, so the + // carve-out still keeps the file's own mask beneath the build tmpfs. const carveOut = join(ROOT, 'pkg', 'a', 'build', 'public') try { const wrapped = await SandboxManager.wrapWithSandbox( @@ -919,13 +808,11 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { 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() From dcf5d8089c1f18df8686a4c0363203a26f2b5d01 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 00:56:41 +0000 Subject: [PATCH 07/23] fix(linux): mount a read-deny path after every path that contains it; deny an unlistable directory whole Read-deny paths were sorted by resolved depth alone. A deny spelled beneath a denied directory's symlinked carve-out (denyRead [D, D/lnk/sub], allowRead [D/lnk]) resolves shallower than D when the link's target is shallower, so it was mounted first and at the target only; D's tmpfs and the carve-out bound back over it then showed the target's unmasked contents at D/lnk/sub. Paths are now ordered so that each follows every path containing it in either spelling, with resolved depth breaking ties. A directory the glob walk could not list (any error but absence) was logged and skipped, so a denyRead glob emitted no mount for anything beneath it: a command with write access to the tree could make a directory unlistable and have the next wrap leave its contents readable by name. The walk now records such directories and the deny expansion mounts over them whole. --- src/sandbox/linux-sandbox-utils.ts | 35 +++++++++++++++-- src/sandbox/read-deny-glob.ts | 6 ++- src/sandbox/sandbox-utils.ts | 8 ++++ test/sandbox/glob-expand.test.ts | 29 +++++++++++++++ test/sandbox/read-deny-glob.test.ts | 58 +++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 87940b19c..0114c1e6b 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -998,8 +998,12 @@ class SandboxMountView { } /** - * Read-deny paths shallow-first by resolved path, so a tmpfs over an ancestor - * directory lands before a mask on a file beneath it. + * Read-deny paths ordered so that each follows every other one that contains + * it in either spelling. A directory's tmpfs and the carve-outs bound back + * over it must be in place before anything beneath is mounted: emitted + * later, the tmpfs would wipe that mount and a carve-out re-expose what it + * hid. Shallow resolved paths first otherwise, so a file mask follows the + * tmpfs of a directory above it. */ function orderReadDenyPaths( paths: readonly string[], @@ -1007,7 +1011,32 @@ function orderReadDenyPaths( ): string[] { const resolvedDepth = (p: string): number => view.resolved(p).split('/').length - return [...new Set(paths)].sort((a, b) => resolvedDepth(a) - resolvedDepth(b)) + const shallowFirst = [...new Set(paths)].sort( + (a, b) => resolvedDepth(a) - resolvedDepth(b), + ) + const bySpelling = new Map() + for (const p of shallowFirst) { + for (const spelling of view.spellings(p)) { + const named = bySpelling.get(spelling) + if (named === undefined) bySpelling.set(spelling, [p]) + else named.push(p) + } + } + const ordered: string[] = [] + const visited = new Set() + const visit = (p: string): void => { + // Already placed, or an ancestry cycle through symlinks: stop here. + if (visited.has(p)) return + visited.add(p) + for (const spelling of view.spellings(p)) { + for (const ancestor of properAncestors(spelling)) { + for (const container of bySpelling.get(ancestor) ?? []) visit(container) + } + } + ordered.push(p) + } + for (const p of shallowFirst) visit(p) + return ordered } /** diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 6ac2dd554..d7dbace23 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -56,14 +56,16 @@ function collapseReadDenyMounts({ * against `reExposedPaths` (the caller's allowRead and allowWrite entries). * A pattern ending in `/**` also takes its directory form, so * `**\/build/**` yields one mount per `build/` directory. A match reached - * through a symlink is listed in its resolved spelling as well. + * through a symlink is listed in its resolved spelling as well, and a + * directory the walk could not list is denied whole. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], ): string[] { const walk = walkGlobPattern(globPattern, { withDirectoryForm: true }) - const candidates = new Set(walk.matches) + // An unlisted directory hides whatever the pattern matches beneath it. + const candidates = new Set([...walk.matches, ...walk.unlisted]) 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 diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c5735aa2d..edcbb5d86 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -927,6 +927,10 @@ export interface GlobWalk { * descends into symlinked directories, so a match beneath one really * lives outside the tree it was found in. */ symlinks: Set + /** Directories the walk reached but could not list (any error but + * absence). Whatever the pattern matches beneath them is missing from + * `matches`; a deny expansion must cover them whole. */ + unlisted: string[] /** The directory listed (the pattern's static prefix) and its resolved * form, which differ when a symlink sits above the walk: every match then * has a second, resolved spelling. Unset when nothing was listed. */ @@ -964,6 +968,7 @@ export function walkGlobPattern( matches: [], directoryMatches: [], symlinks: new Set(), + unlisted: [], } // Normalize to `/` separators throughout so {@link globToRegex} @@ -1030,8 +1035,11 @@ export function walkGlobPattern( try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ENOENT') walk.unlisted.push(dir) logForDebugging( `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, + { level: code === 'ENOENT' ? 'info' : 'warn' }, ) continue } diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index e42ca2df4..1d361e256 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { + chmodSync, mkdirSync, mkdtempSync, writeFileSync, @@ -253,9 +254,37 @@ describe.if(!isWindows)('walkGlobPattern', () => { } }) + it.if(process.getuid?.() !== 0)( + 'records a directory it cannot list and still lists its siblings', + () => { + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-unlisted-'))) + const locked = join(root, 'pkg', 'locked') + try { + mkdirSync(join(locked, 'build'), { recursive: true }) + writeFileSync(join(locked, 'build', 'secret.out'), '') + mkdirSync(join(root, 'pkg', 'open', 'build'), { recursive: true }) + writeFileSync(join(root, 'pkg', 'open', 'build', '1.out'), '') + // Searchable but not listable: what a sandboxed command with write + // access to the tree can leave behind for the next wrap. + chmodSync(locked, 0o311) + + const walk = walkGlobPattern(join(root, '**/build/**')) + + expect(walk.unlisted).toEqual([locked]) + expect(walk.matches).toEqual([ + join(root, 'pkg', 'open', 'build', '1.out'), + ]) + } finally { + chmodSync(locked, 0o755) + rmSync(root, { recursive: true, force: true }) + } + }, + ) + it('leaves the base unset when nothing was listed', () => { const walk = walkGlobPattern(join(RAW_BASE, 'nope', '*.env')) expect(walk.base).toBeUndefined() + expect(walk.unlisted).toEqual([]) }) }) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index c7a50500f..a238a0236 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { + chmodSync, lstatSync, mkdirSync, mkdtempSync, @@ -64,6 +65,27 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { ]) expect(mounts).toEqual([join(ROOT, 'build'), join(ROOT, 'build-cache')]) }) + + it.if(process.getuid?.() !== 0)( + 'denies a directory it cannot list as a whole', + () => { + // Searchable but not listable (what a sandboxed command with write + // access to the tree can leave for the next wrap): the matches beneath + // it cannot be found, so the directory itself is the mount. + const locked = join(ROOT, 'locked') + mkdirSync(join(locked, 'build'), { recursive: true }) + writeFileSync(join(locked, 'build', 'secret.out'), '') + chmodSync(locked, 0o311) + try { + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) + expect(mounts).toContain(locked) + expect(mounts).toContain(join(ROOT, 'build')) + } finally { + chmodSync(locked, 0o755) + rmSync(locked, { recursive: true, force: true }) + } + }, + ) }) describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { @@ -573,6 +595,42 @@ describe.if(isLinux)( } }) + it('mounts a deny spelled beneath a denied directory after it, however shallow its target', async () => { + // denyRead [a/b/D/lnk/sub, a/b/D] + allowRead [a/b/D/lnk], lnk -> x: + // the deny beneath the carve-out resolves shallower than D. Mounted + // before D, it would go to x/sub alone, and D's tmpfs plus the + // carve-out bound back over it would show x/sub/secret at D/lnk/sub. + const D = join(ROOT, 's2', 'a', 'b', 'D') + const target = join(ROOT, 's2', 'x') + mkdirSync(D, { recursive: true }) + mkdirSync(join(target, 'sub'), { recursive: true }) + writeFileSync(join(target, 'sub', 'secret'), 'secret') + symlinkSync(target, join(D, 'lnk')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(D, 'lnk', 'sub', 'secret')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [join(D, 'lnk', 'sub'), D], + allowWithinDeny: [join(D, 'lnk')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect( + wrapped.indexOf(`--tmpfs ${join(D, 'lnk', 'sub')}`), + ).toBeGreaterThan( + wrapped.indexOf(`--ro-bind ${join(D, 'lnk')} ${join(D, 'lnk')}`), + ) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).not.toContain('secret') + expect(run.stdout).toContain('HIDDEN') + } + }) + it('binds a symlinked carve-out nested in another carve-out at its target', async () => { // denyRead [D] + allowRead [D/x, D/x/lnk]: D/x is re-bound from the // host, so D/x/lnk is a live symlink inside the sandbox and bwrap 0.12 From 709f54443de03e227068745317c2324ed67c8f47 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 00:56:41 +0000 Subject: [PATCH 08/23] docs: scope the Linux glob notes to what the expansion does An empty matched directory gets no mount, a pattern whose first wildcard follows / is skipped, a trailing /** on a write path is honoured, only bubblewrap 0.12 and later refuse a symlink destination, and a directory the expansion cannot list is denied whole. --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4dec17d2f..a72625eb0 100644 --- a/README.md +++ b/README.md @@ -374,12 +374,13 @@ Examples: 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, so a file that appears later is not covered — with one exception: - - A directory matched by a `denyRead` pattern ending in `/**` becomes one tmpfs mount rather than one mount per file beneath it, and behaves exactly like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it stay in the tmpfs and never reach the host, and a file added to it later on the host is hidden too. An `allowRead` beneath it is bound back over the tmpfs, and matched entries beneath that carve-out keep their own masks. - - Symlinked directories are descended. An entry reached through a symlink is denied at the link's target as well; a link back up the tree denies everything it reaches, as a literal deny of the link would; a link to `/` is left alone. - - A carve-out beneath a link applies in whichever spelling it is written; an `allowRead` that is itself a symlink is bound at its target. -- A directory `denyRead` whose path is a symlink — literal, or matched by a pattern — is mounted at the link's target, since bubblewrap refuses to mount on a symlink. +- `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. +- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one whose first wildcard comes straight after `/`, such as `/**/*.pem`, is skipped on Linux. +- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it never reach the host, and a file added to it later on the host is hidden too. A matched directory that is empty at that point gets no mount. An `allowRead` beneath a mounted directory is bound back over the tmpfs, and matched entries beneath that carve-out keep their own masks. +- A directory the expansion cannot list is denied as a whole. +- Symlinked directories are descended. An entry reached through a symlink is denied at the link's target as well, and a link back up the tree denies everything it reaches, as a literal deny of the link would. Nothing is denied at `/` through a link that resolves to it. +- A carve-out beneath a link applies in whichever spelling it is written; an `allowRead` that is itself a symlink is bound at its target. +- A directory `denyRead` whose path is a symlink (listed literally, or matched by a pattern) is mounted at the link's target: bubblewrap 0.12 and later refuse to mount on a symlink. Examples: From 92ad199ed012536fc6221aac56f584e84bf8867e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 02:34:17 +0000 Subject: [PATCH 09/23] perf(linux): list each directory of a glob once, and only where the pattern can match walkGlobPattern descended every directory under the pattern's base whatever the pattern was, and read a directory again for every symlink chain that led to it: N packages linking to each other cost exponentially many readdir and realpath calls on every wrapped command. The listing of a directory and the target of a link are now read once, keyed on where they really are; each spelling is still reported. Only directories the pattern can match beneath are listed, segment by segment up to the first `**`, so `certs/*.pem` lists `certs` alone and an unlistable directory the pattern cannot reach is no longer reported as one to deny whole. The walk also reports where each match really lives (it already tracks it), which saves the caller a realpath per match. A pattern whose only literal directory is the root (`/opt*/keys/**`) is skipped like `/**/*.pem`: it used to list the whole filesystem. The match is made with the `s` flag, so a name holding a line terminator matches `**`. --- src/sandbox/sandbox-utils.ts | 177 +++++++++++++++++++++++-------- test/sandbox/glob-expand.test.ts | 126 ++++++++++++++++++++-- 2 files changed, 248 insertions(+), 55 deletions(-) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index edcbb5d86..c6ec4c674 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -931,9 +931,13 @@ export interface GlobWalk { * absence). Whatever the pattern matches beneath them is missing from * `matches`; a deny expansion must cover them whole. */ unlisted: string[] + /** Where an entry of `matches`, `directoryMatches` or `unlisted` really + * lives, for each one whose path passes through a symlink (above the walk, + * on the way down, or the entry itself). A symlink that does not resolve + * has no entry. */ + realOf: Map /** The directory listed (the pattern's static prefix) and its resolved - * form, which differ when a symlink sits above the walk: every match then - * has a second, resolved spelling. Unset when nothing was listed. */ + * form. Unset when nothing was listed. */ base?: { dir: string; real: string } } @@ -955,6 +959,37 @@ export function expandGlobPattern( return walkGlobPattern(globPath, opts).matches } +/** + * A test for whether `normalizedPattern` can match anything strictly beneath + * a directory, so the walk lists only directories that can hold a match + * (`proj/*.pem` lists `proj` alone). Segment by segment up to the first one + * that can span directories (`**`); from there on every directory qualifies. + * A pattern with no such segment matches at exactly its own depth. Errs + * towards descending: a bracket expression holding a `/` cannot be split + * into segments, so nothing is pruned for it. + */ +function globDescentFilter( + normalizedPattern: string, + flags: string, +): (dir: string) => boolean { + if (/\[[^\]]*\/[^\]]*\]/.test(normalizedPattern)) return () => true + const segments = normalizedPattern.split('/') + const spanning = segments.findIndex(segment => segment.includes('**')) + const fixedDepth = spanning === -1 ? segments.length : spanning + const segmentRegexes = segments + .slice(0, fixedDepth) + .map(segment => new RegExp(globToRegex(segment), flags)) + return dir => { + const dirSegments = dir.split('/') + if (spanning === -1 && dirSegments.length >= segments.length) return false + const compared = Math.min(dirSegments.length, fixedDepth) + for (let i = 0; i < compared; i++) { + if (!segmentRegexes[i]!.test(dirSegments[i]!)) return false + } + return true + } +} + /** * The walk behind {@link expandGlobPattern}: one listing of the pattern's * static prefix, filtered by `globPath` and, with `withDirectoryForm`, by @@ -969,6 +1004,7 @@ export function walkGlobPattern( directoryMatches: [], symlinks: new Set(), unlisted: [], + realOf: new Map(), } // Normalize to `/` separators throughout so {@link globToRegex} @@ -980,18 +1016,20 @@ export function walkGlobPattern( process.platform === 'win32' ? s.replace(/\\/g, '/') : s const normalizedPattern = toFwd(normalizePathForSandbox(globPath)) - // Extract the static directory prefix before any glob characters + // Extract the static directory prefix before any glob characters, and the + // base directory from it. A wildcard in the first path component leaves + // the root as the only directory to start from. const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] - if (!staticPrefix || staticPrefix === '/') { + const baseDir = !staticPrefix + ? '' + : staticPrefix.endsWith('/') + ? staticPrefix.slice(0, -1) + : path.dirname(staticPrefix) + if (baseDir === '' || baseDir === '/') { logForDebugging(`[Sandbox] Glob pattern too broad, skipping: ${globPath}`) return walk } - // Get the base directory from the static prefix - const baseDir = staticPrefix.endsWith('/') - ? staticPrefix.slice(0, -1) - : path.dirname(staticPrefix) - if (!fs.existsSync(baseDir)) { logForDebugging( `[Sandbox] Base directory for glob does not exist: ${baseDir}`, @@ -999,21 +1037,25 @@ export function walkGlobPattern( return walk } - const flags = opts.caseInsensitive ? 'i' : '' + // `s`: a name may hold a line terminator, which `.` alone does not match. + const flags = opts.caseInsensitive ? 'is' : 's' const regex = new RegExp(globToRegex(normalizedPattern), flags) const directoryForm = removeTrailingGlobSuffix(normalizedPattern) const directoryRegex = opts.withDirectoryForm && directoryForm !== normalizedPattern ? new RegExp(globToRegex(directoryForm), flags) : undefined + const canHoldMatch = globDescentFilter(normalizedPattern, flags) // One readdir per directory rather than readdirSync's `recursive` option, // so an unreadable subtree or a symlink cycle costs only itself, not the // whole pattern. Symlinked directories are descended: every spelling the // sandboxed command could read through is listed, so a target reached - // twice is listed twice. The one exception is a link whose target is at or - // above a directory on the current descent (the real directory each earlier - // link was taken from, and this one), which would never terminate. + // twice is listed twice (its listing and its links' targets are read + // once, keyed on where they really are). The one exception is a link + // whose target is at or above a directory on the current descent (the real + // directory each earlier link was taken from, and this one), which would + // never terminate. type Frame = { dir: string /** `dir` with every symlink resolved. */ @@ -1021,6 +1063,46 @@ export function walkGlobPattern( /** The real directory each symlink on the way here was taken from. */ linkedFrom: readonly string[] } + type Listing = { entries: fs.Dirent[] } | { errorCode: string | undefined } + const listings = new Map() + const listingOf = (frame: Frame): Listing => { + let listing = listings.get(frame.real) + if (listing === undefined) { + try { + listing = { + entries: fs.readdirSync(frame.dir, { withFileTypes: true }), + } + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException | undefined)?.code + listing = { errorCode } + logForDebugging( + `[Sandbox] Error listing ${frame.dir} for glob pattern ${globPath}: ${err}`, + { level: errorCode === 'ENOENT' ? 'info' : 'warn' }, + ) + } + listings.set(frame.real, listing) + } + return listing + } + /** Where a symlink leads and whether that is a directory; undefined for + * one that dangles, vanished or cannot be traversed. */ + type LinkTarget = { real: string; isDirectory: boolean } | undefined + const linkTargets = new Map() + const linkTargetOf = (linkPath: string, realLinkPath: string): LinkTarget => { + if (linkTargets.has(realLinkPath)) return linkTargets.get(realLinkPath) + let target: LinkTarget + try { + target = { + isDirectory: fs.statSync(linkPath).isDirectory(), + real: fs.realpathSync(linkPath), + } + } catch { + // Dangling, vanished or not traversable: nothing to descend into. + } + linkTargets.set(realLinkPath, target) + return target + } + let baseReal = baseDir try { baseReal = fs.realpathSync(baseDir) @@ -1031,36 +1113,37 @@ export function walkGlobPattern( const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) { const { dir, real, linkedFrom } = frame - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(dir, { withFileTypes: true }) - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code - if (code !== 'ENOENT') walk.unlisted.push(dir) - logForDebugging( - `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, - { level: code === 'ENOENT' ? 'info' : 'warn' }, - ) + const listing = listingOf(frame) + if (!('entries' in listing)) { + if (listing.errorCode !== 'ENOENT') { + walk.unlisted.push(dir) + if (real !== dir) walk.realOf.set(dir, real) + } continue } - for (const entry of entries) { + for (const entry of listing.entries) { const fullPath = path.join(dir, entry.name) + const realPath = path.join(real, entry.name) const candidate = toFwd(fullPath) - if (regex.test(candidate)) { - walk.matches.push(fullPath) - } + const isMatch = regex.test(candidate) + if (isMatch) walk.matches.push(fullPath) if (entry.isDirectory()) { - if (directoryRegex?.test(candidate)) { - walk.directoryMatches.push(fullPath) + const isDirectoryMatch = directoryRegex?.test(candidate) === true + if (isDirectoryMatch) walk.directoryMatches.push(fullPath) + if ((isMatch || isDirectoryMatch) && realPath !== fullPath) { + walk.realOf.set(fullPath, realPath) + } + if (canHoldMatch(candidate)) { + pending.push({ dir: fullPath, real: realPath, linkedFrom }) + } + continue + } + if (!entry.isSymbolicLink()) { + if (isMatch && realPath !== fullPath) { + walk.realOf.set(fullPath, realPath) } - pending.push({ - dir: fullPath, - real: path.join(real, entry.name), - linkedFrom, - }) continue } - if (!entry.isSymbolicLink()) continue walk.symlinks.add(fullPath) if (process.platform === 'win32') { // The Windows ACL expansion does not follow reparse points @@ -1068,30 +1151,30 @@ export function walkGlobPattern( // POSIX-separated paths. continue } - let target: string | undefined - try { - if (fs.statSync(fullPath).isDirectory()) { - target = fs.realpathSync(fullPath) - } - } catch { - // Dangling, vanished or not traversable: nothing to descend into. - } + const isDirectoryFormCandidate = directoryRegex?.test(candidate) === true + const descends = canHoldMatch(candidate) + if (!isMatch && !isDirectoryFormCandidate && !descends) continue + const target = linkTargetOf(fullPath, realPath) if (target === undefined) continue - if (directoryRegex?.test(candidate)) { + if (isMatch) walk.realOf.set(fullPath, target.real) + if (!target.isDirectory) continue + if (isDirectoryFormCandidate) { walk.directoryMatches.push(fullPath) + walk.realOf.set(fullPath, target.real) } + if (!descends) continue const cycle = [...linkedFrom, real].some(from => - isAtOrUnder(from, target), + isAtOrUnder(from, target.real), ) if (cycle) { logForDebugging( - `[Sandbox] Not following symlink ${fullPath} -> ${target} for glob pattern ${globPath}: it leads back into its own ancestry`, + `[Sandbox] Not following symlink ${fullPath} -> ${target.real} for glob pattern ${globPath}: it leads back into its own ancestry`, ) continue } pending.push({ dir: fullPath, - real: target, + real: target.real, linkedFrom: [...linkedFrom, real], }) } diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 1d361e256..ebe5fe8bf 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' -import { +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' +import fs, { chmodSync, mkdirSync, mkdtempSync, @@ -203,7 +203,10 @@ describe.if(!isWindows)('walkGlobPattern', () => { 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')]) - expect(walk.base).toEqual({ dir: join(BASE, 'a'), real: join(BASE, 'a') }) + // Only an entry reached through a symlink has a second, real location. + expect([...walk.realOf]).toEqual([ + [join(BASE, 'a', 'build', 'link'), join(BASE, 'elsewhere')], + ]) }) it('lists no directory matches without the directory form', () => { @@ -211,7 +214,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(walk.directoryMatches).toEqual([]) }) - it('reports a symlinked base in both spellings', () => { + it('reports where a match beneath a symlinked base really is', () => { // alias -> the tree, sideways: normalizePathForSandbox keeps the link // spelling for the pattern, so every match is spelled through it and // the walk reports where it really is. @@ -224,8 +227,9 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(BASE, alias) try { const walk = walkGlobPattern(join(alias, '**/build/**')) - expect(walk.base).toEqual({ dir: alias, real: BASE }) - expect(walk.matches).toContain(join(alias, 'a', 'build', '1.out')) + const match = join(alias, 'a', 'build', '1.out') + expect(walk.matches).toContain(match) + expect(walk.realOf.get(match)).toBe(join(BASE, 'a', 'build', '1.out')) } finally { rmSync(alias) } @@ -281,11 +285,117 @@ describe.if(!isWindows)('walkGlobPattern', () => { }, ) - it('leaves the base unset when nothing was listed', () => { + it('finds nothing, and nothing unlisted, under a base that is not there', () => { const walk = walkGlobPattern(join(RAW_BASE, 'nope', '*.env')) - expect(walk.base).toBeUndefined() + expect(walk.matches).toEqual([]) + expect(walk.unlisted).toEqual([]) + }) + + it.if(process.getuid?.() !== 0)( + 'does not try to list a directory the pattern cannot match beneath', + () => { + // proj/*.pem matches at one depth only: a directory beneath proj can + // hold no match, so it is never listed and never reported as + // unlistable, whatever its mode. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-prune-'))) + const proj = join(root, 'proj') + try { + mkdirSync(join(proj, 'pgdata'), { recursive: true }) + mkdirSync(join(proj, 'deep', 'x', 'locked'), { recursive: true }) + writeFileSync(join(proj, 'top.pem'), '') + writeFileSync(join(proj, 'deep', 'x', 'nested.pem'), '') + chmodSync(join(proj, 'pgdata'), 0o000) + chmodSync(join(proj, 'deep', 'x', 'locked'), 0o311) + + const walk = walkGlobPattern(join(proj, '*.pem')) + expect(walk.matches).toEqual([join(proj, 'top.pem')]) + expect(walk.unlisted).toEqual([]) + + // A fixed-depth pattern descends only where its segments allow. + const nested = walkGlobPattern(join(proj, 'de*/x/*.pem')) + expect(nested.matches).toEqual([join(proj, 'deep', 'x', 'nested.pem')]) + expect(nested.unlisted).toEqual([]) + + // From a ** on, every directory can hold a match again. + const spanning = walkGlobPattern(join(proj, 'deep/**/*.pem')) + expect(spanning.matches).toEqual([ + join(proj, 'deep', 'x', 'nested.pem'), + ]) + expect(spanning.unlisted).toEqual([join(proj, 'deep', 'x', 'locked')]) + } finally { + chmodSync(join(proj, 'pgdata'), 0o755) + chmodSync(join(proj, 'deep', 'x', 'locked'), 0o755) + rmSync(root, { recursive: true, force: true }) + } + }, + ) + + it('skips a pattern whose only literal directory is the root', () => { + // /tm*/... would have to start listing at '/'. + const walk = walkGlobPattern('/tm*/glob-walk-no-such-dir/**') + expect(walk.matches).toEqual([]) expect(walk.unlisted).toEqual([]) }) + + it('matches a name that holds a line terminator', () => { + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-newline-'))) + try { + mkdirSync(join(root, 'build')) + writeFileSync(join(root, 'build', 'a\nb.out'), '') + expect(expandGlobPattern(join(root, '**/build/**'))).toEqual([ + join(root, 'build', 'a\nb.out'), + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('reads a directory reached through several links once', () => { + // N packages that each link to every other: a package directory is + // reached along many link chains, and each chain is a spelling of its + // own, but every real directory is listed a single time. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-memo-'))) + const names = ['a', 'b', 'c', 'd', 'e'] + try { + for (const name of names) { + mkdirSync(join(root, name, 'node_modules'), { recursive: true }) + writeFileSync(join(root, name, 'index.js'), '') + } + for (const from of names) { + for (const to of names) { + if (from !== to) { + symlinkSync(join(root, to), join(root, from, 'node_modules', to)) + } + } + } + const listed: string[] = [] + const readdirSync = fs.readdirSync + const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + listed.push(realPath(String(args[0]))) + return readdirSync(...args) + }) as typeof fs.readdirSync) + let walk + try { + walk = walkGlobPattern(join(root, '**/index.js')) + } finally { + readdirSpy.mockRestore() + } + + // Every chain of distinct packages ends in a spelling of index.js … + expect(walk.matches.length).toBeGreaterThan(names.length * 10) + expect(new Set(walk.matches).size).toBe(walk.matches.length) + // … which all resolve to the five real files … + expect(new Set(walk.matches.map(m => walk.realOf.get(m) ?? m)).size).toBe( + names.length, + ) + // … and no real directory was listed twice. + expect(new Set(listed).size).toBe(listed.length) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) // ============================================================================ From 0301b3071bb8ea08fbb00d4f7a8e878cb3d1d4f6 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 02:34:51 +0000 Subject: [PATCH 10/23] fix(linux): mount a read-deny, and a path bound back over one, only where it really is A read-denied path was mounted where its spelling landed inside the sandbox and at its host target, and an allowed path was bound back at a landing derived from either spelling. That gave one host directory two names in the sandbox, and the second name had none of the mounts that protect the first: - an allowRead entry was matched by what it resolved to, so a symlink at an allowRead path (planted by a sandboxed command with write access there, or shipped by the repository) bound the denied directory it pointed at back over its own tmpfs, or lifted the mask of the denied file; - an allowed write path was bound writable at the second name, without the denyWrite binds and file masks emitted at the first; - a bind at a landing outside the tmpfs being restored buried read-deny mounts already made there; - which name a deny covered depended on the order of the denyRead entries. Every read-deny mount now goes to the resolved location of its entry, one mount per location, shallow first. An allowed path is bound back over a denied directory only when its name lives inside it (symlinked directories resolved, the last component as written) and it resolves inside it, at the place it resolves to; a file mask is lifted only by an allowRead entry that names that very file. A glob's matches are collapsed by where they live for the same reason: the spelled parent of a match reached through a second link does not contain it. Fail-closed cases: a denyRead entry that cannot be inspected (its parent is readable but not searchable) hides the nearest directory that can, and a read-denied directory that cannot be listed has nothing bound back over it, since a pattern's matches beneath an allowed path there cannot be found. A matched link that resolves to / or to nothing is dropped instead of being mounted on, which stopped every later command. A '/' deny no longer denies the root's symlinks as entries of their own (/sbin landed inside an allowed /usr). A denyWrite bind is dropped as hidden only when its resolved destination is under a read-deny tmpfs, and a file denied through a symlinked directory is masked, and re-masked, once. --- src/sandbox/linux-sandbox-utils.ts | 600 +++++++++---------- src/sandbox/read-deny-glob.ts | 108 ++-- src/sandbox/sandbox-utils.ts | 4 - test/sandbox/read-deny-glob.test.ts | 664 +++++++++++++++++----- test/sandbox/symlinked-deny-paths.test.ts | 36 ++ 5 files changed, 910 insertions(+), 502 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 0114c1e6b..17aaec9ad 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -19,8 +19,6 @@ import { DANGEROUS_FILES, isAtOrUnder, isStrictlyUnder, - pathSpellings, - type PathSpellings, properAncestors, getDangerousDirectories, } from './sandbox-utils.js' @@ -885,64 +883,106 @@ function resolveSymlinkDenyDest(normalizedPath: string): string { return normalizedPath } +/** An errno meaning the path is not there (a missing component, a file where + * a directory was expected, a symlink cycle, a name too long to exist), as + * opposed to one meaning it could not be looked at (EACCES, EPERM, EIO, + * anything unrecognised). */ +function isAbsenceErrno(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return ( + code === 'ENOENT' || + code === 'ENOTDIR' || + code === 'ELOOP' || + code === 'ENAMETOOLONG' + ) +} + +/** + * The root's children a denyRead of '/' stands for: --tmpfs / would wipe + * every prior mount (ro-bind /, write binds, deny binds), so each child is + * denied instead. /proc and /dev are skipped (the caller remounts them after + * generateFilesystemArgs returns) and so is /sys (kernel interface; the + * host's is already read-only via ro-bind). A symlink is skipped too: what it + * leads to lies under another child and is denied there, whereas a deny of + * its own would land inside that child after the child's allowRead entries + * were bound back (/sbin -> usr/sbin under an allowed /usr). + */ +function rootReadDenyChildren(): string[] { + return fs + .readdirSync('/', { withFileTypes: true }) + .filter( + child => + !child.isSymbolicLink() && !['proc', 'dev', 'sys'].includes(child.name), + ) + .map(child => '/' + child.name) +} + type MountKind = 'tmpfs' | 'bind' -/** A read-denied directory's tmpfs: the spelling the config named, and where - * the mount landed (that spelling's landing, or its host target). */ -type ReadDenyTmpfsMount = { spelling: string; dest: string } +/** + * One place the read section denies: a directory (tmpfs) or a file + * (/dev/null mask), keyed by where it really is. bwrap resolves a mount + * destination through symlinks, and refuses one that is a symlink, so every + * read-deny mount and every path bound back over one goes to its resolved + * location: no host path ever shows under a second name, and one mount there + * covers every spelling. + */ +type ReadDenyUnit = { + /** Where the tmpfs or mask is mounted; symlink-free. */ + location: string + isDirectory: boolean + /** False when nothing beneath `location` can be vouched for (it stands in + * for an entry that could not be inspected): no allowed path is bound back + * over it. */ + restores: boolean +} /** - * The directory mounts emitted so far, by where they landed inside the - * sandbox: a tmpfs empties its landing, a bind shows a host directory - * (symlinks included) at its landing, and the last one covering a path - * decides what that path is in the new root. Host lookups are cached: the - * host does not change while a profile is built, only the sandbox's view. + * The read-deny mounts emitted so far, by location: a tmpfs empties its + * location, a bind shows the host directory there again, and the last one + * covering a path decides whether that path is hidden. Host lookups are + * cached: the host does not change while a profile is built. */ class SandboxMountView { private readonly trail = new Map() private nextSeq = 0 - private readonly spellingsCache = new Map() - private readonly linkTargetCache = new Map() - - /** - * `p` as the config spelled it and as the host resolves it. Comparisons - * between a read-deny mount and the paths that re-expose contents beneath - * it run over both, so a carve-out written against a link or against its - * target both count (allowRead: node_modules/foo/public against a pnpm - * link, /bin/bash against /usr/bin on a usr-merged system). - */ - spellings(p: string): PathSpellings { - let spellings = this.spellingsCache.get(p) - if (spellings === undefined) { - spellings = pathSpellings(p) - this.spellingsCache.set(p, spellings) - } - return spellings - } + private readonly resolvedCache = new Map() - /** `p` with every symlink resolved; `p` itself when it cannot be resolved - * or resolves to '/'. bwrap refuses a mount on a symlink, so this is where - * a mount on a still-present link goes. */ + /** `p` with every symlink resolved; `p` itself when it cannot be. */ resolved(p: string): string { - const spellings = this.spellings(p) - return spellings.length === 2 ? spellings[1] : spellings[0] + let resolved = this.resolvedCache.get(p) + if (resolved === undefined) { + try { + resolved = fs.realpathSync(p) + } catch { + resolved = p // dangling or vanished: only the spelling names it + } + this.resolvedCache.set(p, resolved) + } + return resolved } - atOrUnderEitherSpelling(p: string, dir: string): boolean { - return this.spellings(p).some(pf => - this.spellings(dir).some(df => isAtOrUnder(pf, df)), - ) + /** + * Where the name `p` lives: its directory with every symlink resolved, its + * last component as written. This, not what `p` resolves to, decides which + * read-deny an allowed path is an exception to: a symlink at an allowed + * path names the link, and re-allows nothing it points at. + */ + nameLocation(p: string): string { + if (p === '/') return p + const parent = this.resolved(path.dirname(p)) + return (parent === '/' ? '' : parent) + '/' + path.basename(p) } - record(landing: string, kind: MountKind): void { - this.trail.set(landing, { seq: this.nextSeq++, kind }) + record(location: string, kind: MountKind): void { + this.trail.set(location, { seq: this.nextSeq++, kind }) } - /** Whether the last mount covering `landing` is a tmpfs: a mount there + /** Whether the last mount covering `location` is a tmpfs: a mount there * would be created inside it and hide nothing on the host. */ - underTmpfs(landing: string): boolean { + underTmpfs(location: string): boolean { let last: { seq: number; kind: MountKind } | undefined - for (const at of [landing, ...properAncestors(landing)]) { + for (const at of [location, ...properAncestors(location)]) { const mount = this.trail.get(at) if (mount !== undefined && (last === undefined || mount.seq > last.seq)) { last = mount @@ -950,160 +990,181 @@ class SandboxMountView { } return last?.kind === 'tmpfs' } +} - /** - * Where a mount whose destination is spelled `p` lands. bwrap resolves the - * destination in the new root: a component is a symlink there only while - * its parent is host-visible (outside every tmpfs, or inside a directory - * bound back over one), and resolves as on the host; beneath a tmpfs the - * components are plain directories bwrap creates, so they stay as spelled. - */ - landingOf(p: string): string { - let landing = '/' - for (const component of p.split('/')) { - if (component === '') continue - const next = landing === '/' ? `/${component}` : `${landing}/${component}` - landing = this.underTmpfs(landing) ? next : this.hostLinkTarget(next) - } - return landing - } - - /** - * Where a bind restoring allowed path `p` lands for the tmpfs that wiped - * it: the part of `p` beneath the tmpfs, in whichever spelling put it - * there, re-rooted at the tmpfs's landing (bwrap refuses a symlink as a - * destination and, before 0.12, an absolute one anywhere in the path). - */ - reBindLanding(tmpfs: ReadDenyTmpfsMount, p: string): string { - for (const pf of this.spellings(p)) { - for (const sf of [...this.spellings(tmpfs.spelling), tmpfs.dest]) { - if (isAtOrUnder(pf, sf)) { - return this.landingOf(tmpfs.dest + pf.slice(sf.length)) - } +/** + * What the read section mounts for one denyRead entry: the entry itself, or, + * when it cannot be inspected (a parent that is readable but not searchable + * hides whether it exists), the nearest ancestor that can, standing in for + * it. Undefined when the entry is not there. + */ +function readDenyTargetOf( + entry: string, +): { path: string; isDirectory: boolean } | undefined { + for (let candidate = entry; ; candidate = path.dirname(candidate)) { + try { + return { + path: candidate, + isDirectory: fs.statSync(candidate).isDirectory(), } + } catch (err) { + if (isAbsenceErrno(err) || candidate === '/') return undefined } - throw new Error(`${p} is not at or under the read-deny tmpfs ${tmpfs.dest}`) - } - - /** `q`'s target when `q` is itself a symlink (to anything but '/'), else `q`. */ - private hostLinkTarget(q: string): string { - let target = this.linkTargetCache.get(q) - if (target === undefined) { - const resolved = resolveSymlinkDenyDest(q) - target = resolved === '/' ? q : resolved - this.linkTargetCache.set(q, target) - } - return target } } /** - * Read-deny paths ordered so that each follows every other one that contains - * it in either spelling. A directory's tmpfs and the carve-outs bound back - * over it must be in place before anything beneath is mounted: emitted - * later, the tmpfs would wipe that mount and a carve-out re-expose what it - * hid. Shallow resolved paths first otherwise, so a file mask follows the - * tmpfs of a directory above it. + * The places the denyRead entries deny, shallow first. Grouped by resolved + * location, so an entry spelled through a symlink and one naming its target + * are one mount; ordered by that location's depth, so a directory's tmpfs and + * the paths bound back over it are in place before anything inside it is + * mounted, however either was spelled. */ -function orderReadDenyPaths( - paths: readonly string[], +function readDenyUnitsOf( + entries: readonly string[], view: SandboxMountView, -): string[] { - const resolvedDepth = (p: string): number => - view.resolved(p).split('/').length - const shallowFirst = [...new Set(paths)].sort( - (a, b) => resolvedDepth(a) - resolvedDepth(b), - ) - const bySpelling = new Map() - for (const p of shallowFirst) { - for (const spelling of view.spellings(p)) { - const named = bySpelling.get(spelling) - if (named === undefined) bySpelling.set(spelling, [p]) - else named.push(p) +): ReadDenyUnit[] { + const units = new Map() + for (const entry of new Set(entries)) { + const target = readDenyTargetOf(entry) + if (target === undefined) { + logForDebugging( + `[Sandbox Linux] Skipping non-existent read deny path: ${entry}`, + ) + continue } - } - const ordered: string[] = [] - const visited = new Set() - const visit = (p: string): void => { - // Already placed, or an ancestry cycle through symlinks: stop here. - if (visited.has(p)) return - visited.add(p) - for (const spelling of view.spellings(p)) { - for (const ancestor of properAncestors(spelling)) { - for (const container of bySpelling.get(ancestor) ?? []) visit(container) - } + const isStandIn = target.path !== entry + if (isStandIn) { + logForDebugging( + `[Sandbox Linux] Read deny path ${entry} cannot be inspected; hiding ${target.path} instead`, + { level: 'warn' }, + ) + } + const location = view.resolved(target.path) + if (location === '/') { + // Reached through a link (a '/' entry was expanded into the root's + // children before this): a tmpfs over the root would wipe every mount, + // and bwrap refuses one on the link. + logForDebugging( + `[Sandbox Linux] Skipping read deny path that resolves to /: ${entry}`, + { level: 'warn' }, + ) + continue + } + const unit = units.get(location) + if (unit === undefined) { + units.set(location, { + location, + isDirectory: target.isDirectory, + restores: !isStandIn, + }) + } else if (isStandIn) { + unit.restores = false } - ordered.push(p) } - for (const p of shallowFirst) visit(p) - return ordered + const depth = (unit: ReadDenyUnit): number => unit.location.split('/').length + return [...units.values()].sort((a, b) => depth(a) - depth(b)) +} + +/** Whether the process may list `dir`. */ +function canListDirectory(dir: string): boolean { + try { + fs.accessSync(dir, fs.constants.R_OK) + return true + } catch { + return false + } } /** - * Mount a tmpfs over a read-denied directory at `mount.dest`, then bind back - * the allowed write paths and allowRead paths the tmpfs just wiped: those at - * or under the directory in either spelling, each where - * {@link SandboxMountView.reBindLanding} puts it. Used by the denyRead loop - * in generateFilesystemArgs and again when a late denyWrite ro-bind + * Mount a tmpfs over a read-denied directory, then bind back the allowed + * write paths and allowRead paths the tmpfs just wiped. Used by the denyRead + * loop in generateFilesystemArgs and again when a late denyWrite ro-bind * re-exposes a read-denied directory and the tmpfs must be re-applied on top. + * Returns the locations it bound back writable. + * + * An allowed path is bound back only when its name lives beneath the + * directory ({@link SandboxMountView.nameLocation}) and it resolves to + * somewhere inside it, and it is bound where it resolves to. Matching on what + * an allowed path resolves to would let a symlink planted at an allowRead + * path cancel the deny of whatever it points at; binding one that resolves + * elsewhere would show that other tree under this name, whatever read-denies + * cover it there. */ function pushReadDenyDirMounts( args: string[], - mount: ReadDenyTmpfsMount, - allowedWritePaths: string[], - readAllowPaths: string[], + unit: ReadDenyUnit, + allowedWritePaths: readonly string[], + readAllowPaths: readonly string[], view: SandboxMountView, -): void { - const covers = (p: string, dir: string): boolean => - view.atOrUnderEitherSpelling(p, dir) - const bindBack = (flag: '--bind' | '--ro-bind', source: string): void => { - const landing = view.reBindLanding(mount, source) - args.push(flag, source, landing) - view.record(landing, 'bind') +): string[] { + const { location } = unit + args.push('--tmpfs', location) + view.record(location, 'tmpfs') + + const restoredWrites: string[] = [] + if (!unit.restores) return restoredWrites + // Entries beneath a directory that cannot be listed cannot be enumerated + // either, so a glob's matches beneath an allowed path there are unknown: + // the whole directory stays hidden. + if (!canListDirectory(location)) { + logForDebugging( + `[Sandbox Linux] Read-denied directory cannot be listed; restoring nothing beneath it: ${location}`, + { level: 'warn' }, + ) + return restoredWrites } - args.push('--tmpfs', mount.dest) - view.record(mount.dest, 'tmpfs') - - // tmpfs wiped any earlier write binds under this path — restore them. - for (const writePath of allowedWritePaths) { - if (covers(writePath, mount.spelling)) { - bindBack('--bind', writePath) + /** Where to bind allowed path `p` back, if it is a carve-out of this unit. */ + const restoreLocationOf = (p: string): string | undefined => { + if (!isAtOrUnder(view.nameLocation(p), location)) return undefined + const resolved = view.resolved(p) + if (!isAtOrUnder(resolved, location)) { logForDebugging( - `[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${writePath}`, + `[Sandbox Linux] Not restoring ${p} over denyRead tmpfs ${location}: it resolves outside it, to ${resolved}`, ) + return undefined } + return resolved + } + + // tmpfs wiped any earlier write binds under this path — restore them. + for (const writePath of allowedWritePaths) { + const restoreAt = restoreLocationOf(writePath) + if (restoreAt === undefined) continue + args.push('--bind', writePath, restoreAt) + view.record(restoreAt, 'bind') + restoredWrites.push(restoreAt) + logForDebugging( + `[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${writePath}`, + ) } // Re-allow specific paths within the denied directory (allowRead overrides denyRead). // After mounting tmpfs over the denied dir, bind back the allowed subdirectories // so they are readable again. for (const allowPath of readAllowPaths) { - if (covers(allowPath, mount.spelling)) { - if (!fs.existsSync(allowPath)) { - logForDebugging( - `[Sandbox Linux] Skipping non-existent read allow path: ${allowPath}`, - ) - continue - } - // Skip only if a write path was re-bound just above AND covers - // allowPath. A write path that's an ancestor of the deny dir isn't - // re-bound (it wasn't wiped), so allowPath under it still needs - // its own ro-bind here. - if ( - allowedWritePaths.some( - w => covers(w, mount.spelling) && covers(allowPath, w), - ) - ) { - continue - } - // Bind the allowed path back over the tmpfs so it's readable - bindBack('--ro-bind', allowPath) + const restoreAt = restoreLocationOf(allowPath) + if (restoreAt === undefined) continue + if (!fs.existsSync(allowPath)) { logForDebugging( - `[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`, + `[Sandbox Linux] Skipping non-existent read allow path: ${allowPath}`, ) + continue } + // Skip only if a write path was re-bound just above AND covers + // allowPath. A write path that's an ancestor of the deny dir isn't + // re-bound (it wasn't wiped), so allowPath under it still needs + // its own ro-bind here. + if (restoredWrites.some(w => isAtOrUnder(restoreAt, w))) continue + // Bind the allowed path back over the tmpfs so it's readable + args.push('--ro-bind', allowPath, restoreAt) + view.record(restoreAt, 'bind') + logForDebugging( + `[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`, + ) } + return restoredWrites } /** @@ -1136,16 +1197,15 @@ async function generateFilesystemArgs( // creat() the mount point inside that read-only mount and abort ("Can't // 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. + // --ro-bind

is skipped as redundant. The stub-skip guard tests a + // covering directory in its canonical form AND every recorded spelling + // against the read-deny tmpfs directories; a spelling over-predicts (the + // tmpfs sits where its directory really is), which only keeps a stub. const readOnlyDenyDirSpellings = new Map>() // dest → the pre-resolution deny path it came from. denyWrite dests are - // canonicalized through symlinks but the denyRead tmpfs dirs and the write - // binds they are later compared against are not, so a dest reached via a - // symlink no longer matches them by string prefix. Both spellings name the - // same inode once bwrap resolves them, so the comparisons below test both. + // canonicalized through symlinks, and so are the read-deny mounts they are + // compared against; the raw spelling is recorded beside an emitted dest for + // the re-application passes. const denyWriteRawDests = new Map() // Determine initial root mount based on write restrictions @@ -1234,15 +1294,17 @@ async function generateFilesystemArgs( // domains. // // prospectiveReadDenyTmpfsDirsBothForms: the tmpfs targets the denyRead - // loop below will actually mount, 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 - // read-only /dev/null mask instead of a tmpfs) — in raw and canonical - // spellings. A read-denied tmpfs at or under a covering deny dir is the - // TRIGGER for the post-denyWrite writable re-application, and a deny bind - // whose dest sits under one can be dropped by the emission filter — both - // facts feed the guard. + // loop below will mount, found the way that loop finds them — a '/' entry + // stands for rootReadDenyChildren(), /etc/ssh/ssh_config.d is added when + // present, and readDenyTargetOf() says what each entry mounts (nothing + // for an absent entry, a read-only /dev/null mask for a file, a tmpfs for + // a directory or for the ancestor standing in for an entry that cannot + // be inspected) — as named and as resolved, which is where the tmpfs + // goes. The named form over-predicts, which only keeps a stub. A + // read-denied tmpfs at or under a covering deny dir is the TRIGGER for the + // post-denyWrite writable re-application, and a deny bind whose dest sits + // under one can be dropped by the emission filter — both facts feed the + // guard. let stubSkipVetoInputs: | { allowedWritePathsBothForms: string[] @@ -1270,14 +1332,10 @@ async function generateFilesystemArgs( const prospectiveReadDenyTmpfsDirsBothForms: string[] = [] if (readConfig) { const effectiveReadDenyPaths: string[] = [] - const rootSkipForGuard = new Set(['proc', 'dev', 'sys']) for (const denyReadPattern of readConfig.denyOnly || []) { if (normalizePathForSandbox(denyReadPattern) === '/') { try { - for (const child of fs.readdirSync('/')) { - if (!rootSkipForGuard.has(child)) - effectiveReadDenyPaths.push('/' + child) - } + effectiveReadDenyPaths.push(...rootReadDenyChildren()) } catch { // Unreadable root: contribute nothing. (The denyRead loop has // no catch and would abort the whole wrap, so an @@ -1292,24 +1350,20 @@ async function generateFilesystemArgs( effectiveReadDenyPaths.push('/etc/ssh/ssh_config.d') } for (const effectiveReadDenyPath of effectiveReadDenyPaths) { - const denyReadPath = normalizePathForSandbox(effectiveReadDenyPath) - let isDirectory = false - try { - isDirectory = fs.statSync(denyReadPath).isDirectory() - } catch { - continue // absent: the denyRead loop skips it — no tmpfs, no trigger - } - if (!isDirectory) { - continue // a file gets a read-only /dev/null mask, never a tmpfs + const target = readDenyTargetOf( + normalizePathForSandbox(effectiveReadDenyPath), + ) + if (target === undefined || !target.isDirectory) { + continue // absent, or a file: no tmpfs } - prospectiveReadDenyTmpfsDirsBothForms.push(denyReadPath) + prospectiveReadDenyTmpfsDirsBothForms.push(target.path) try { - const canonical = fs.realpathSync(denyReadPath) - if (canonical !== denyReadPath) { + const canonical = fs.realpathSync(target.path) + if (canonical !== target.path) { prospectiveReadDenyTmpfsDirsBothForms.push(canonical) } } catch { - // vanished between the stat and here: the raw form suffices + // vanished between the stat and here: the named form suffices } } } @@ -1701,22 +1755,16 @@ async function generateFilesystemArgs( // the mask, and to re-apply the correct source if a denyWrite ancestor // bind re-exposes the dest. const maskedFiles = new Map() - // Directories masked by --tmpfs below, in emission order. Used to filter - // denyWriteArgs the same way: a dir in both deny lists must not get its - // host contents re-bound on top of its own tmpfs. - const tmpfsMounts: ReadDenyTmpfsMount[] = [] - - // --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds). - // Expand a root deny into its direct children so the existing per-dir tmpfs - // + re-bind logic applies. Skip /proc and /dev: they're remounted by the - // caller after this function returns. Skip /sys: kernel interface, tmpfs - // over it breaks tooling and the host /sys is already read-only via ro-bind. - const rootSkip = new Set(['proc', 'dev', 'sys']) + // Directories masked by --tmpfs below, in emission order, each with the + // write paths bound back over it. Used to filter denyWriteArgs the same + // way: a dir in both deny lists must not get its host contents re-bound + // on top of its own tmpfs. + const tmpfsMounts: Array<{ unit: ReadDenyUnit; restoredWrites: string[] }> = + [] + for (const p of readConfig?.denyOnly || []) { if (normalizePathForSandbox(p) === '/') { - for (const child of fs.readdirSync('/')) { - if (!rootSkip.has(child)) readDenyPaths.push('/' + child) - } + readDenyPaths.push(...rootReadDenyChildren()) } else { readDenyPaths.push(p) } @@ -1732,85 +1780,56 @@ async function generateFilesystemArgs( readDenyPaths.push('/etc/ssh/ssh_config.d') } - const normalizedDenyPaths = orderReadDenyPaths( + const readDenyUnits = readDenyUnitsOf( readDenyPaths.map(p => normalizePathForSandbox(p)), view, ) - for (const normalizedPath of normalizedDenyPaths) { - if (!fs.existsSync(normalizedPath)) { + for (const unit of readDenyUnits) { + const { location } = unit + // A tmpfs emitted so far already hides this place, and nothing bound + // back over that tmpfs shows it again: a mount here would be created + // inside the tmpfs and change nothing. + if (view.underTmpfs(location)) { logForDebugging( - `[Sandbox Linux] Skipping non-existent read deny path: ${normalizedPath}`, + `[Sandbox Linux] Skipping read deny already hidden by a denyRead tmpfs: ${location}`, ) continue } - // A path is denied where its spelling lands inside the sandbox AND at - // its host target, each unless a tmpfs emitted so far already hides that - // place (a mount there would be created inside the tmpfs and change - // nothing). The two differ when a link on the way was wiped by an earlier - // tmpfs and recreated by a carve-out bound back over it: the recreated - // path is a directory of its own, and the target is still reachable by - // its real name. - const dests = new Set([ - view.landingOf(normalizedPath), - view.resolved(normalizedPath), - ]) - - if (fs.statSync(normalizedPath).isDirectory()) { - let mounted = false - for (const dest of dests) { - if (view.underTmpfs(dest)) continue - mounted = true - const mount = { spelling: normalizedPath, dest } - tmpfsMounts.push(mount) - pushReadDenyDirMounts( + if (unit.isDirectory) { + tmpfsMounts.push({ + unit, + restoredWrites: pushReadDenyDirMounts( args, - mount, + unit, allowedWritePaths, readAllowPaths, view, - ) - } - if (!mounted) { - logForDebugging( - `[Sandbox Linux] Skipping read deny directory already hidden by a denyRead tmpfs: ${normalizedPath}`, - ) - } + ), + }) } else { - // For files, only an exact allowRead match overrides the deny, in - // either spelling (a glob lists a file reached through a link under - // its target's spelling too). A directory allowRead does not un-deny a + // For files, only an exact allowRead match overrides the deny: an + // entry that names this very file, through whatever symlinked + // directories (a glob lists a file reached through a link under both + // spellings). One that is a symlink to it names the link and lifts + // nothing, or a link planted at an allowRead path would cancel the deny + // of the file it points at. A directory allowRead does not un-deny a // file specifically listed in denyRead — otherwise denyRead: ['.env'] // + allowRead: ['.'] silently drops the .env deny. if ( - readAllowPaths.some(allowPath => - view - .spellings(allowPath) - .some(f => view.spellings(normalizedPath).includes(f)), + readAllowPaths.some( + allowPath => view.nameLocation(allowPath) === location, ) ) { logForDebugging( - `[Sandbox Linux] Skipping read deny for re-allowed path: ${normalizedPath}`, + `[Sandbox Linux] Skipping read deny for re-allowed path: ${location}`, ) continue } - // For files, bind /dev/null instead of tmpfs. A file that is itself a - // symlink to an already masked one needs nothing. - let masked = false - for (const dest of dests) { - if (maskedFiles.has(dest) || view.underTmpfs(dest)) continue - masked = true - args.push('--ro-bind', '/dev/null', dest) - maskedFiles.set(dest, '/dev/null') - } - if (!masked) { - logForDebugging( - `[Sandbox Linux] Skipping read deny file already masked or hidden by a denyRead tmpfs: ${normalizedPath}`, - ) - continue - } - maskedFiles.set(normalizedPath, '/dev/null') + // For files, bind /dev/null instead of tmpfs. + args.push('--ro-bind', '/dev/null', location) + maskedFiles.set(location, '/dev/null') } } @@ -1844,36 +1863,29 @@ async function generateFilesystemArgs( // if an allowed write path at-or-under that tmpfs covers the dest, the // denyRead loop re-bound it (the .git/hooks case) and the write-deny bind // is still required on top. - // tmpfs spellings and allowedWritePaths hold unresolved paths while dest - // has been canonicalized, so each dest is tested under both spellings: they - // name the same inode after bwrap resolves the mount destinations, and a - // hit on either means the tmpfs really does cover this bind. + // Read-deny mounts sit at resolved locations and dest is canonical too, so + // the comparison is by location alone: a deny whose raw spelling passes + // through a read-denied directory, but which resolves outside it, is not + // hidden by that tmpfs and keeps its bind. const isHiddenByTmpfs = (dest: string): boolean => - tmpfsMounts.some(({ spelling: tmpfsDir }) => { - if (!view.atOrUnderEitherSpelling(dest, tmpfsDir)) return false - const reExposedByWriteBind = allowedWritePaths.some( - writePath => - view.atOrUnderEitherSpelling(writePath, tmpfsDir) && - view.atOrUnderEitherSpelling(dest, writePath), - ) - return !reExposedByWriteBind - }) + tmpfsMounts.some( + ({ unit, restoredWrites }) => + isAtOrUnder(dest, unit.location) && + !restoredWrites.some(writePath => isAtOrUnder(dest, writePath)), + ) const emittedDenyWriteDests: string[] = [] for (let i = 0; i < denyWriteArgs.length; i += 3) { const dest = denyWriteArgs[i + 2]! const rawDest = denyWriteRawDests.get(dest) ?? dest if (maskedFiles.has(dest)) continue - if (isHiddenByTmpfs(dest) || isHiddenByTmpfs(rawDest)) { + if (isHiddenByTmpfs(dest)) { logForDebugging( `[Sandbox Linux] Skipping denyWrite bind already hidden by denyRead tmpfs: ${dest}`, ) continue } args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) - // A host directory bound here is host-visible territory again for the - // tmpfsMounts re-application beneath it. - if (denyWriteArgs[i + 1] === dest) view.record(dest, 'bind') emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit // above a read-denied path?". A bind at the resolved dest also re-exposes @@ -1886,24 +1898,14 @@ async function generateFilesystemArgs( // landed after the tmpfs). Re-apply the tmpfs on top, with the same write // and allowRead re-binds the denyRead loop emitted. A bind of '/' itself // (allowOnly and denyWithinAllow both naming it) contains every one of - // them, so containment is root-aware. - // What decides it is where the tmpfs LANDED, not how the config spelled - // it: a bind that contains only a symlink spelling leaves the mount at the - // landing untouched (and re-applying anyway would re-bind carve-outs over - // the file masks beneath them). - for (const mount of tmpfsMounts) { - const { dest } = mount - if (emittedDenyWriteDests.some(w => isStrictlyUnder(dest, w))) { + // them, so containment is root-aware. Units are re-applied in the order + // they were mounted, so one nested in another's carve-out stays on top. + for (const { unit } of tmpfsMounts) { + if (emittedDenyWriteDests.some(w => isStrictlyUnder(unit.location, w))) { logForDebugging( - `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${dest}`, - ) - pushReadDenyDirMounts( - args, - mount, - allowedWritePaths, - readAllowPaths, - view, + `[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${unit.location}`, ) + pushReadDenyDirMounts(args, unit, allowedWritePaths, readAllowPaths, view) } } // Same problem for masked files: the mask landed before the denyWrite diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index d7dbace23..f1d02fa64 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -14,41 +14,42 @@ import { 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 and no - * re-exposer sits at the ancestor or between the two. + * Reduce the places a read-deny glob's matches really live to the ones whose + * mount changes what the sandbox can read. A location is dropped only when a + * kept proper ancestor's tmpfs already hides it and no re-exposer sits at the + * ancestor or between the two. The denyRead loop mounts each entry where it + * really lives, so this is decided there too: the spelled parent of a match + * reached through a symlink need not contain it. */ -function collapseReadDenyMounts({ - matches, +function collapseReadDenyLocations({ + locations, reExposedPaths, }: { - /** Absolute, normalized, trailing-slash-free paths; a match reached - * through a symlink appears in both its spellings. */ - matches: Iterable - /** allowRead/allowWrite paths the denyRead loop re-binds over a tmpfs, in - * every spelling that can name them. */ + /** Absolute, symlink-free, trailing-slash-free paths. */ + locations: Iterable + /** allowRead/allowWrite paths the denyRead loop binds back over a tmpfs, as + * spelled and as resolved. */ reExposedPaths: ReadonlySet -}): string[] { +}): Set { // A proper ancestor is a proper string prefix, so lexicographic order // visits every ancestor before its descendants. - const sorted = [...new Set(matches)].sort() + const sorted = [...new Set(locations)].sort() const kept = new Set() - for (const candidate of sorted) { + for (const location of sorted) { // A re-exposer at the kept ancestor counts: the deny loop binds it back // over the tmpfs, so everything beneath needs its own mount. - let reExposedBetween = reExposedPaths.has(candidate) + let reExposedBetween = reExposedPaths.has(location) let hidden = false - for (const ancestor of properAncestors(candidate)) { + for (const ancestor of properAncestors(location)) { if (reExposedPaths.has(ancestor)) reExposedBetween = true if (kept.has(ancestor)) { hidden = true break } } - if (!hidden || reExposedBetween) kept.add(candidate) + if (!hidden || reExposedBetween) kept.add(location) } - return [...kept] + return kept } /** @@ -56,8 +57,9 @@ function collapseReadDenyMounts({ * against `reExposedPaths` (the caller's allowRead and allowWrite entries). * A pattern ending in `/**` also takes its directory form, so * `**\/build/**` yields one mount per `build/` directory. A match reached - * through a symlink is listed in its resolved spelling as well, and a - * directory the walk could not list is denied whole. + * through a symlink is listed where it really lives, and a directory the walk + * could not list is denied whole. Sorted, so an ancestor precedes its + * descendants. */ export function expandReadDenyGlobLinux( globPattern: string, @@ -82,56 +84,50 @@ export function expandReadDenyGlobLinux( } } - const reExposed = new Set( - reExposedPaths.flatMap(p => pathSpellings(normalizePathForSandbox(p))), - ) - // Resolved spellings: a realpath for a match under a link the walk - // descended, a string swap for one under a symlinked base. - const throughWalkLink = (p: string): boolean => { - if (walk.symlinks.has(p)) return true - for (const ancestor of properAncestors(p)) { - if (walk.symlinks.has(ancestor)) return true + // Where each candidate really lives: the denyRead loop mounts an entry + // there, whatever spelling named it. + const locations = new Set() + for (const candidate of candidates) { + const location = walk.realOf.get(candidate) ?? candidate + if (walk.symlinks.has(candidate) && !walk.realOf.has(candidate)) { + // A link that resolves to nothing denies nothing, and bwrap cannot + // mount on the link itself. + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} does not resolve, skipping`, + ) + continue } - return false - } - const base = walk.base - const swappedBase = - base !== undefined && base.real !== base.dir ? base : undefined - for (const match of [...candidates]) { - if (throughWalkLink(match)) { - const spellings = pathSpellings(match) - if (spellings.length === 2) { - candidates.add(spellings[1]) - } else if (walk.symlinks.has(match)) { - // The spelling stays, and bwrap refuses to mount on the link. - logForDebugging( - `[Sandbox Linux] denyRead glob "${globPattern}": ${match} is dangling or resolves to /, not denied at its target`, - { level: 'warn' }, - ) - } - } else if (swappedBase !== undefined) { - const beneathBase = match.slice(swappedBase.dir.length) - candidates.add( - swappedBase.real === '/' ? beneathBase : swappedBase.real + beneathBase, + if (location === '/') { + // A tmpfs over the root would hide everything, and one on the link is + // refused by bwrap: every later command would fail to start for as + // long as the link exists. + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} resolves to /, skipping`, + { level: 'warn' }, ) + continue } + locations.add(location) } - const mounts = collapseReadDenyMounts({ - matches: candidates, + const reExposed = new Set( + reExposedPaths.flatMap(p => pathSpellings(normalizePathForSandbox(p))), + ) + const mounts = collapseReadDenyLocations({ + locations, reExposedPaths: reExposed, }) logForDebugging( - `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.length} mounts`, + `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.size} mounts`, ) - if (mounts.length > READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) { + if (mounts.size > READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) { logForDebugging( - `[Sandbox Linux] denyRead glob "${globPattern}" still needs ${mounts.length} mounts after collapsing ` + + `[Sandbox Linux] denyRead glob "${globPattern}" still needs ${mounts.size} mounts after collapsing ` + `(threshold ${READ_DENY_GLOB_MOUNT_WARN_THRESHOLD}); each is a separate bwrap mount at sandbox start. ` + `Prefer denying the enclosing directories.`, { level: 'warn' }, ) } - return mounts + return [...mounts].sort() } diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c6ec4c674..5dd43f3fa 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -936,9 +936,6 @@ export interface GlobWalk { * on the way down, or the entry itself). A symlink that does not resolve * has no entry. */ realOf: Map - /** The directory listed (the pattern's static prefix) and its resolved - * form. Unset when nothing was listed. */ - base?: { dir: string; real: string } } /** @@ -1109,7 +1106,6 @@ export function walkGlobPattern( } catch { // Vanished between the existence check and here: list what remains. } - walk.base = { dir: baseDir, real: baseReal } const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) { const { dir, real, linkedFrom } = frame diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index a238a0236..8c19f1874 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -86,6 +86,27 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { } }, ) + + it.if(process.getuid?.() !== 0)( + 'leaves alone an unlistable directory the pattern cannot match beneath', + () => { + // certs/*.pem matches at one depth: certs/pgdata (a volume owned by + // another user, say) holds no match whatever it contains, and as a + // tmpfs it would be an empty directory whose writes go nowhere. + const certs = join(ROOT, 'certs') + mkdirSync(join(certs, 'pgdata'), { recursive: true }) + writeFileSync(join(certs, 'top.pem'), '') + chmodSync(join(certs, 'pgdata'), 0o000) + try { + expect(expandReadDenyGlobLinux(join(certs, '*.pem'), [])).toEqual([ + join(certs, 'top.pem'), + ]) + } finally { + chmodSync(join(certs, 'pgdata'), 0o755) + rmSync(certs, { recursive: true, force: true }) + } + }, + ) }) describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { @@ -168,17 +189,15 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).not.toContain(join(ROOT, 'pkg', 'empty', 'build')) }) - it('lists a directory symlink that is itself the covering directory in both spellings', () => { - // pkg/linked/build -> pkg/a/build: the link spelling stays, as it does - // for a literal directory deny, so carve-outs written against the link - // still match; the deny loop mounts it at the target and drops the - // second spelling of the same inode. + it('lists a directory symlink that is itself the covering directory where it really is', () => { + // pkg/linked/build -> pkg/a/build: one mount, on the target, which is + // where the deny loop would put an entry spelled through the link. const linked = join(ROOT, 'pkg', 'linked', 'build') const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []) - expect(mounts).toContain(linked) - expect(mounts).not.toContain(join(linked, '1.out')) expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) + expect(mounts).not.toContain(linked) + expect(mounts).not.toContain(join(linked, '1.out')) }) it('lists a link named like the pattern segment with its target', () => { @@ -195,16 +214,14 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { const mounts = expandReadDenyGlobLinux(join(shal, '**/secrets/**'), []) - expect(mounts).toEqual([ - join(shal, 'proj', 'config', 'secrets'), - join(shal, 'proj', 'vault'), - ]) + expect(mounts).toEqual([join(shal, 'proj', 'vault')]) }) - it('lists every match in its resolved spelling too when the base is a symlink', () => { + it('lists every match where it really is when the base is a symlink', () => { // alias -> ROOT, sideways: normalizePathForSandbox keeps the link - // spelling for the pattern, so a carve-out written in ROOT spelling - // would match nothing without the resolved twins. + // spelling for the pattern, so every match is spelled through it. The + // mounts go where the matches really are, and a carve-out counts the + // same whether it is written through the alias or not. const alias = join( ROOT, '..', @@ -216,15 +233,21 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { const carveOut = join(ROOT, build, 'public') mkdirSync(carveOut, { recursive: true }) writeFileSync(join(carveOut, 'ok.txt'), '') - const mounts = expandReadDenyGlobLinux(join(alias, '**/build/**'), [ + + const real = expandReadDenyGlobLinux(join(alias, '**/build/**'), [ carveOut, ]) + expect(real).toContain(join(ROOT, build)) + expect(real).toContain(carveOut) + expect(real).toContain(join(carveOut, 'ok.txt')) + expect(real).not.toContain(join(ROOT, build, '1.out')) + expect(real.filter(m => m.startsWith(alias + '/'))).toEqual([]) - expect(mounts).toContain(join(alias, build)) - expect(mounts).toContain(join(ROOT, build)) - expect(mounts).toContain(carveOut) - expect(mounts).toContain(join(carveOut, 'ok.txt')) - expect(mounts).not.toContain(join(alias, build, '1.out')) + expect( + expandReadDenyGlobLinux(join(alias, '**/build/**'), [ + join(alias, build, 'public'), + ]), + ).toEqual(real) } finally { rmSync(alias) rmSync(join(ROOT, 'pkg', 'a', 'build', 'public'), { recursive: true }) @@ -232,9 +255,9 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { }) it('never denies the root through a link to it', () => { - // build/root -> /: the resolved spelling would expand to a tmpfs over - // every top-level directory. The link keeps its spelling; bwrap refuses - // to mount on it. + // build/root -> /: denied where it resolves, that would be a tmpfs over + // every top-level directory; denied at the link, a mount bwrap refuses, + // so that no later command starts while the link exists. It is dropped. const rooted = join(ROOT, 'rooted') mkdirSync(join(rooted, 'build'), { recursive: true }) writeFileSync(join(rooted, 'build', '1.out'), '') @@ -243,6 +266,13 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { const mounts = expandReadDenyGlobLinux(join(rooted, '**/build/**'), []) expect(mounts).toEqual([join(rooted, 'build')]) + + // The same when the link is itself the matched directory. + mkdirSync(join(rooted, 'img')) + symlinkSync('/', join(rooted, 'img', 'build')) + expect( + expandReadDenyGlobLinux(join(rooted, 'img', '**/build/**'), []), + ).toEqual([]) }) it('denies the target of a directory-form link the walk did not descend', () => { @@ -290,33 +320,22 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { symlinkSync(join('..', '.pnpm', 'foo@1', 'node_modules', 'foo'), link) }) - it('keeps the carve-out written against the link spelling', () => { - const mounts = expandReadDenyGlobLinux( - join(pnpmRoot, '**/node_modules/foo/**'), - [join(link, 'public')], - ) - - expect(mounts).toContain(link) - expect(mounts).not.toContain(join(link, 'index.js')) - expect(mounts).toContain(join(link, 'public')) - expect(mounts).toContain(join(link, 'public', 'ok.txt')) - // The real tree, matched in its own right, keeps its carve-out too. - expect(mounts).toContain(real) - expect(mounts).toContain(join(real, 'public', 'ok.txt')) - }) - - it('keeps the carve-out written against the resolved spelling', () => { - const mounts = expandReadDenyGlobLinux( - join(pnpmRoot, '**/node_modules/foo/**'), - [join(real, 'public')], - ) + for (const spelling of ['link', 'resolved'] as const) { + it(`keeps what lies beneath a carve-out written against the ${spelling} spelling`, () => { + const mounts = expandReadDenyGlobLinux( + join(pnpmRoot, '**/node_modules/foo/**'), + [join(spelling === 'link' ? link : real, 'public')], + ) - expect(mounts).toContain(link) - expect(mounts).not.toContain(join(link, 'index.js')) - expect(mounts).toContain(real) - expect(mounts).toContain(join(real, 'public')) - expect(mounts).toContain(join(real, 'public', 'ok.txt')) - }) + // The package, the carve-out and what lies beneath it, where they + // are; index.js is hidden by the package's tmpfs. + expect(mounts).toEqual([ + real, + join(real, 'public'), + join(real, 'public', 'ok.txt'), + ]) + }) + } it('is not defeated by a re-exposer above the covering directory', () => { // allowWrite ['.'] (the README's example) names the project root, @@ -327,9 +346,49 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { [pnpmRoot], ) - expect(mounts).toEqual([real, link]) + expect(mounts).toEqual([real]) }) }) + + it('keeps a match beneath a carve-out that only a second link leads to', () => { + // proj/build -> store/nm and store/nm/keep -> ../keep: y.txt is matched + // as proj/build/keep/pub/y.txt but lives at store/keep/pub/y.txt, which + // no spelled ancestor of the match contains. Collapsing along the + // spelling would drop it under proj/build and lose the mask beneath the + // carve-out. + const chain = join(ROOT, 'chain') + const proj = join(chain, 'proj') + const store = join(chain, 'store') + mkdirSync(proj, { recursive: true }) + mkdirSync(join(store, 'nm'), { recursive: true }) + mkdirSync(join(store, 'keep', 'pub'), { recursive: true }) + writeFileSync(join(store, 'keep', 'pub', 'y.txt'), '') + writeFileSync(join(store, 'nm', 'z.out'), '') + symlinkSync(join(store, 'nm'), join(proj, 'build')) + symlinkSync(join('..', 'keep'), join(store, 'nm', 'keep')) + + const mounts = expandReadDenyGlobLinux(join(proj, '**/build/**'), [ + join(store, 'keep', 'pub'), + ]) + + expect(mounts).toEqual([ + join(store, 'keep'), + join(store, 'keep', 'pub'), + join(store, 'keep', 'pub', 'y.txt'), + join(store, 'nm'), + ]) + }) + + it('drops a matched link that does not resolve', () => { + const dangling = join(ROOT, 'dangling') + mkdirSync(join(dangling, 'build'), { recursive: true }) + writeFileSync(join(dangling, 'build', '1.out'), '') + symlinkSync(join(dangling, 'gone'), join(dangling, 'build', 'lost')) + + expect(expandReadDenyGlobLinux(join(dangling, '**/build/lo*'), [])).toEqual( + [], + ) + }) }) describe.if(isLinux)( @@ -510,27 +569,12 @@ describe.if(isLinux)( // One tmpfs on the target, and the mask is the last word on the file: // no carve-out re-bind after it. expect(wrapped.split(`--tmpfs ${realdir} `)).toHaveLength(2) + expect(wrapped).toContain(carveOut) expect(wrapped.lastIndexOf(mask)).toBeGreaterThan( wrapped.lastIndexOf(carveOut), ) }) - it('binds a carve-out that is itself a symlink at its target', async () => { - // allowRead names the link; the tmpfs is on the target, so the re-bind - // goes there too (bwrap refuses a symlink destination) and the link, - // still present, leads to it. - const wrapped = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: { denyOnly: [real], allowWithinDeny: [link] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, - }) - - expect(wrapped).toContain(`--tmpfs ${real}`) - expect(wrapped).toContain(`--ro-bind ${link} ${real}`) - expect(wrapped).not.toContain(`--ro-bind ${link} ${link}`) - }) - it('re-binds a literal carve-out written against the target of a denied link', async () => { const viaLink = await wrapCommandWithSandboxLinux({ command: 'true', @@ -547,148 +591,482 @@ describe.if(isLinux)( ) }) - it('re-binds a literal carve-out written against a link to the denied directory', async () => { - const viaTarget = await wrapCommandWithSandboxLinux({ + it('re-binds a carve-out written beneath the link a directory is denied by, at its target', async () => { + const viaLink = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, readConfig: { - denyOnly: [real], + denyOnly: [link], allowWithinDeny: [join(link, 'public')], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(viaTarget).toContain(`--tmpfs ${real}`) - expect(viaTarget).toContain( + expect(viaLink).toContain(`--tmpfs ${real}`) + expect(viaLink).toContain( `--ro-bind ${join(link, 'public')} ${join(real, 'public')}`, ) }) - it('mounts a deny beneath a recreated symlinked carve-out where the carve-out was recreated', async () => { - // denyRead [D, D/lnk/sub] + allowRead [D/lnk], D/lnk -> ../e: D's tmpfs - // wipes the link, the carve-out re-bind recreates D/lnk as a plain - // directory showing e, so the deny must land at D/lnk/sub — a tmpfs - // at e/sub (the host realpath) would leave D/lnk/sub/secret readable. - const D = join(ROOT, 's1', 'D') - const e = join(ROOT, 's1', 'e') - mkdirSync(D, { recursive: true }) - mkdirSync(join(e, 'sub'), { recursive: true }) - writeFileSync(join(e, 'sub', 'secret'), 'secret') - symlinkSync(join('..', 'e'), join(D, 'lnk')) + it('re-binds a carve-out written through a symlinked directory outside the denied one, at its target', async () => { + // denyRead [real] + allowRead [link/public]: the name `public` lives in + // the denied directory whichever way it is reached, and the bind goes + // where it is (bwrap refuses a symlink as a destination and, before + // 0.12, an absolute one anywhere in it). const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(D, 'lnk', 'sub', 'secret')} || echo HIDDEN`, + command: `cat ${join(link, 'public', 'ok.txt')}; cat ${join(real, 'index.js')} || echo HIDDEN`, needsNetworkRestriction: false, readConfig: { - denyOnly: [D, join(D, 'lnk', 'sub')], - allowWithinDeny: [join(D, 'lnk')], + denyOnly: [real], + allowWithinDeny: [join(link, 'public')], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).toContain(`--tmpfs ${join(D, 'lnk', 'sub')}`) + expect(wrapped).toContain(`--tmpfs ${real}`) + expect(wrapped).toContain( + `--ro-bind ${join(link, 'public')} ${join(real, 'public')}`, + ) if (hasBwrap) { const run = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 15000, }) - expect(run.stdout).not.toContain('secret') - expect(run.stdout).toContain('HIDDEN') + expect(run.stdout).toBe('publicHIDDEN\n') + } + }) + + describe('an allowRead that only leads into a denied directory', () => { + // What an allowRead entry resolves to never decides which deny it + // carves out of: a sandboxed command with write access to where the + // entry lives can point it anywhere. + function run(wrapped: string): string { + return spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }).stdout + } + + it('does not bind a denied directory back through an allowRead symlink to it', async () => { + const home = join(ROOT, 'g1', 'home') + const proj = join(ROOT, 'g1', 'proj') + mkdirSync(join(home, '.ssh'), { recursive: true }) + writeFileSync(join(home, '.ssh', 'id_rsa'), 'KEY') + mkdirSync(proj, { recursive: true }) + symlinkSync(join(home, '.ssh'), join(proj, 'docs')) + + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(home, '.ssh', 'id_rsa')} ${join(proj, 'docs', 'id_rsa')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [join(home, '.ssh')], + allowWithinDeny: [join(proj, 'docs')], + }, + writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + + expect(wrapped).toContain(`--tmpfs ${join(home, '.ssh')}`) + expect(wrapped).not.toContain(`--ro-bind ${join(proj, 'docs')}`) + if (hasBwrap) { + const stdout = run(wrapped) + expect(stdout).not.toContain('KEY') + expect(stdout).toContain('HIDDEN') + } + }) + + it('keeps the mask on a denied file an allowRead symlink points at', async () => { + const aws = join(ROOT, 'g2', 'aws') + const proj = join(ROOT, 'g2', 'proj') + mkdirSync(aws, { recursive: true }) + writeFileSync(join(aws, 'credentials'), 'CREDS') + mkdirSync(proj, { recursive: true }) + symlinkSync(join(aws, 'credentials'), join(proj, 'cfg.json')) + + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(aws, 'credentials')} ${join(proj, 'cfg.json')}; echo END`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [join(aws, 'credentials')], + allowWithinDeny: [join(proj, 'cfg.json')], + }, + writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + + expect(wrapped).toContain( + `--ro-bind /dev/null ${join(aws, 'credentials')}`, + ) + if (hasBwrap) expect(run(wrapped)).not.toContain('CREDS') + }) + + it('does not lift a file mask for an allowRead symlink that a pattern also matches', async () => { + // denyRead **/.env* with allowRead **/.env.example, and + // sub/.env.example -> ../.env planted: both patterns match the link, + // which names the link, not .env. + const proj = join(ROOT, 'g3', 'proj') + mkdirSync(join(proj, 'sub'), { recursive: true }) + writeFileSync(join(proj, '.env'), 'ENVSECRET') + writeFileSync(join(proj, '.env.example'), 'EXAMPLE') + symlinkSync(join('..', '.env'), join(proj, 'sub', '.env.example')) + const allowWithinDeny = expandGlobPattern(join(proj, '**/.env.example')) + expect(allowWithinDeny).toContain(join(proj, 'sub', '.env.example')) + + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(proj, '.env.example')}; cat ${join(proj, 'sub', '.env.example')} ${join(proj, '.env')}; echo END`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux( + join(proj, '**/.env*'), + allowWithinDeny, + ), + allowWithinDeny, + }, + writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + + expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) + expect(wrapped).not.toContain(`/dev/null ${join(proj, '.env.example')}`) + if (hasBwrap) { + const stdout = run(wrapped) + expect(stdout).toContain('EXAMPLE') + expect(stdout).not.toContain('ENVSECRET') + } + }) + + it('does not show a tree outside the denied directory under a name inside it', async () => { + // denyRead [D, e/sub] + allowRead [D/lnk], D/lnk -> ../e: bound back + // at D/lnk, e would be readable there whatever is denied inside it. + // e was never hidden by D's tmpfs, so there is nothing to restore. + const D = join(ROOT, 's1', 'D') + const e = join(ROOT, 's1', 'e') + mkdirSync(D, { recursive: true }) + mkdirSync(join(e, 'sub'), { recursive: true }) + writeFileSync(join(e, 'sub', 'secret'), 'secret') + symlinkSync(join('..', 'e'), join(D, 'lnk')) + for (const denyOnly of [ + [D, join(e, 'sub')], + [join(D, 'lnk', 'sub'), D], + ]) { + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(D, 'lnk', 'sub', 'secret')} ${join(e, 'sub', 'secret')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { denyOnly, allowWithinDeny: [join(D, 'lnk')] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).toContain(`--tmpfs ${D}`) + expect(wrapped).toContain(`--tmpfs ${join(e, 'sub')}`) + expect(wrapped).not.toContain(`--ro-bind ${join(D, 'lnk')}`) + if (hasBwrap) { + const stdout = run(wrapped) + expect(stdout).not.toContain('secret') + expect(stdout).toContain('HIDDEN') + } + } + }) + + it('leaves a link inside a carve-out to lead where it leads, without a mount of its own', async () => { + // denyRead [t/private, D] + allowRead [D/x, D/x/lnk], D/x/lnk -> t: + // D/x is bound back from the host, live link included, so t is + // reached through it as on the host and t/private stays denied. A + // bind of D/x/lnk would land on t and bury that deny. + const D = join(ROOT, 's3', 'D') + const t = join(ROOT, 's3', 't') + mkdirSync(join(D, 'x'), { recursive: true }) + mkdirSync(join(t, 'private'), { recursive: true }) + writeFileSync(join(t, 'f'), 'T') + writeFileSync(join(t, 'private', 'key'), 'KEY') + symlinkSync(join('..', '..', 't'), join(D, 'x', 'lnk')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(D, 'x', 'lnk', 'f')}; cat ${join(t, 'private', 'key')} ${join(D, 'x', 'lnk', 'private', 'key')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [join(t, 'private'), D], + allowWithinDeny: [join(D, 'x'), join(D, 'x', 'lnk')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).not.toContain(`--ro-bind ${join(D, 'x', 'lnk')}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stderr ?? '').not.toContain('symlink destination') + expect(run.stdout).toBe('THIDDEN\n') + } + }) + }) + + it('denies a path the same way whatever order and spelling name it', async () => { + // l1 -> a/b, a/b/l2 -> t: l1/l2/f is t/f. Mounted where it really is, + // it is denied under every name, in either order of the two entries. + const R = join(ROOT, 's7') + mkdirSync(join(R, 'a', 'b'), { recursive: true }) + mkdirSync(join(R, 't')) + writeFileSync(join(R, 't', 'f'), 'FCONTENT') + symlinkSync(join('a', 'b'), join(R, 'l1')) + symlinkSync(join('..', '..', 't'), join(R, 'a', 'b', 'l2')) + const denied = [join(R, 'l1', 'l2', 'f'), join(R, 'a', 'b')] + for (const denyOnly of [denied, [...denied].reverse()]) { + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(R, 'l1', 'l2', 'f')} ${join(R, 't', 'f')}; echo END`, + needsNetworkRestriction: false, + readConfig: { denyOnly, allowWithinDeny: [join(R, 'a', 'b', 'l2')] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + expect(wrapped).toContain(`--ro-bind /dev/null ${join(R, 't', 'f')}`) + expect(wrapped).toContain(`--tmpfs ${join(R, 'a', 'b')}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).toBe('END\n') + } } }) - it('mounts a deny spelled beneath a denied directory after it, however shallow its target', async () => { - // denyRead [a/b/D/lnk/sub, a/b/D] + allowRead [a/b/D/lnk], lnk -> x: - // the deny beneath the carve-out resolves shallower than D. Mounted - // before D, it would go to x/sub alone, and D's tmpfs plus the - // carve-out bound back over it would show x/sub/secret at D/lnk/sub. - const D = join(ROOT, 's2', 'a', 'b', 'D') - const target = join(ROOT, 's2', 'x') + it('mounts a directory that a link inside a denied directory leads back up to before that directory', async () => { + // x/secrets/latest -> .. (x, an allowed write root): the deny of the + // link is a deny of x, which the write bind cancels; mounted after + // x/secrets it would wipe that tmpfs and bind the secrets back. + const x = join(ROOT, 's5', 'x') + mkdirSync(join(x, 'secrets'), { recursive: true }) + writeFileSync(join(x, 'secrets', 'key'), 'KEY') + symlinkSync('..', join(x, 'secrets', 'latest')) + const denied = [join(x, 'secrets'), join(x, 'secrets', 'latest')] + for (const denyOnly of [denied, [...denied].reverse()]) { + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(x, 'secrets', 'key')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { denyOnly }, + writeConfig: { allowOnly: [x], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + expect( + wrapped.lastIndexOf(`--tmpfs ${join(x, 'secrets')} `), + ).toBeGreaterThan(wrapped.lastIndexOf(`--bind ${x} ${x} `)) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).toBe('HIDDEN\n') + } + } + }) + + it('binds an allowed write path back only where it really is', async () => { + // D/L -> ../T with denyRead [D, D/L/sub] and allowWrite [T/sub/w]: + // T/sub/w is bound back once, at T/sub/w, under the deny binds and + // masks that protect it. Bound a second time beneath D/L it would be + // writable there with none of them on top. + const D = join(ROOT, 's6', 'D') + const T = join(ROOT, 's6', 'T') + const w = join(T, 'sub', 'w') mkdirSync(D, { recursive: true }) - mkdirSync(join(target, 'sub'), { recursive: true }) - writeFileSync(join(target, 'sub', 'secret'), 'secret') - symlinkSync(target, join(D, 'lnk')) + mkdirSync(join(w, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(w, '.env'), 'ENV') + symlinkSync(join('..', 'T'), join(D, 'L')) const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(D, 'lnk', 'sub', 'secret')} || echo HIDDEN`, + command: `touch ${join(D, 'L', 'sub', 'w', '.git', 'hooks', 'pre-commit')} ${join(w, '.git', 'hooks', 'pre-commit')} && echo WRITTEN; cat ${join(D, 'L', 'sub', 'w', '.env')} ${join(w, '.env')} || echo HIDDEN`, needsNetworkRestriction: false, readConfig: { - denyOnly: [join(D, 'lnk', 'sub'), D], - allowWithinDeny: [join(D, 'lnk')], + denyOnly: [D, join(D, 'L', 'sub'), join(w, '.env')], + allowWithinDeny: [join(D, 'L')], }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + writeConfig: { + allowOnly: [w], + denyWithinAllow: [join(w, '.git', 'hooks')], + }, + mandatoryDenySearchDepth: 1, }) - expect( - wrapped.indexOf(`--tmpfs ${join(D, 'lnk', 'sub')}`), - ).toBeGreaterThan( - wrapped.indexOf(`--ro-bind ${join(D, 'lnk')} ${join(D, 'lnk')}`), - ) + const binds = wrapped + .split(' --') + .filter(op => op.startsWith(`bind ${w} `)) + expect(binds.every(op => op.trim() === `bind ${w} ${w}`)).toBe(true) if (hasBwrap) { const run = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 15000, }) - expect(run.stdout).not.toContain('secret') + expect(run.stdout).not.toContain('WRITTEN') + expect(run.stdout).not.toContain('ENV') expect(run.stdout).toContain('HIDDEN') } }) - it('binds a symlinked carve-out nested in another carve-out at its target', async () => { - // denyRead [D] + allowRead [D/x, D/x/lnk]: D/x is re-bound from the - // host, so D/x/lnk is a live symlink inside the sandbox and bwrap 0.12 - // refuses it as a destination. - const D = join(ROOT, 's3', 'D') - const t = join(ROOT, 's3', 't') - mkdirSync(join(D, 'x'), { recursive: true }) - mkdirSync(t, { recursive: true }) - writeFileSync(join(t, 'f'), 'T') - symlinkSync(join('..', '..', 't'), join(D, 'x', 'lnk')) + it('keeps a denyWrite bind whose path only passes through a read-denied directory', async () => { + // ln -> real/secretdir and real/secretdir/out -> elsewhere/hooks: + // denyWrite [real/secretdir/out] protects elsewhere/hooks, which the + // tmpfs on real/secretdir does not hide. + const root = join(ROOT, 's8', 'root') + const hooks = join(root, 'elsewhere', 'hooks') + mkdirSync(join(root, 'real', 'secretdir'), { recursive: true }) + mkdirSync(hooks, { recursive: true }) + symlinkSync(join(root, 'real', 'secretdir'), join(root, 'ln')) + symlinkSync(hooks, join(root, 'real', 'secretdir', 'out')) const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(D, 'x', 'lnk', 'f')}`, + command: `touch ${join(hooks, 'x')} && echo WRITTEN || echo DENIED`, needsNetworkRestriction: false, - readConfig: { - denyOnly: [D], - allowWithinDeny: [join(D, 'x'), join(D, 'x', 'lnk')], + readConfig: { denyOnly: [join(root, 'ln')] }, + writeConfig: { + allowOnly: [root], + denyWithinAllow: [join(root, 'real', 'secretdir', 'out')], }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, }) - expect(wrapped).not.toContain(` ${join(D, 'x', 'lnk')} --`) - expect(wrapped).toContain(`--ro-bind ${join(D, 'x', 'lnk')} ${t}`) + expect(wrapped).toContain(`--tmpfs ${join(root, 'real', 'secretdir')}`) + expect(wrapped).toContain(`--ro-bind ${hooks} ${hooks}`) if (hasBwrap) { const run = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 15000, }) - expect(run.stderr ?? '').not.toContain('symlink destination') - expect(run.stdout).toBe('T') + expect(run.stdout).toBe('DENIED\n') } }) - it('re-binds a carve-out through an absolute intermediate symlink without a symlink in the destination', async () => { - // denyRead [target] + allowRead [parent/link/sub], parent/link -> - // target absolute: bubblewrap before 0.12 aborts on an absolute link - // inside a destination. - const target = join(ROOT, 's4', 'target') - const parent = join(ROOT, 's4', 'parent') - mkdirSync(join(target, 'sub'), { recursive: true }) - mkdirSync(parent, { recursive: true }) - writeFileSync(join(target, 'sub', 'f'), 'F') - symlinkSync(target, join(parent, 'link')) - const carveOut = join(parent, 'link', 'sub') + it('re-applies one mask for a file denied through a symlinked directory', async () => { + // W/lnk -> real, denyRead [W/lnk/secret], denyWrite [W]: the mask sits + // on W/real/secret alone, so the bind of W re-exposes one file and one + // mask goes back (a second on the same inode aborts bwrap before 0.5). + const W = join(ROOT, 's10', 'W') + mkdirSync(join(W, 'real'), { recursive: true }) + writeFileSync(join(W, 'real', 'secret'), 'S') + symlinkSync('real', join(W, 'lnk')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(W, 'lnk', 'secret')] }, + writeConfig: { allowOnly: [W], denyWithinAllow: [W] }, + mandatoryDenySearchDepth: 1, + }) + const mask = `--ro-bind /dev/null ${join(W, 'real', 'secret')}` + const afterDenyBind = wrapped.slice( + wrapped.lastIndexOf(`--ro-bind ${W} ${W}`), + ) + expect(afterDenyBind.split(mask)).toHaveLength(2) + expect(wrapped).not.toContain(`/dev/null ${join(W, 'lnk', 'secret')}`) + }) + + it.if(process.getuid?.() !== 0)( + 'restores nothing beneath a read-denied directory it cannot list', + async () => { + // denyRead **/.env with the working directory an allowed write root + // and mode 0311 (what a sandboxed command can leave behind): the + // .env files beneath it cannot be enumerated, so the directory is + // denied whole, and binding the write root back over that tmpfs + // would show every one of them unmasked. + const cwd = join(ROOT, 's11', 'cwd') + mkdirSync(join(cwd, 'svc'), { recursive: true }) + writeFileSync(join(cwd, '.env'), 'ENV1') + writeFileSync(join(cwd, 'svc', '.env'), 'ENV2') + chmodSync(cwd, 0o311) + try { + const denyOnly = expandReadDenyGlobLinux(join(cwd, '**/.env'), [cwd]) + expect(denyOnly).toEqual([cwd]) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${join(cwd, '.env')} ${join(cwd, 'svc', '.env')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { denyOnly }, + writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + expect( + wrapped.slice(wrapped.indexOf(`--tmpfs ${cwd}`)), + ).not.toContain(`--bind ${cwd} ${cwd}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).not.toContain('ENV') + expect(run.stdout).toContain('HIDDEN') + } + } finally { + chmodSync(cwd, 0o755) + } + }, + ) + + it.if(process.getuid?.() !== 0)( + 'hides the nearest directory it can inspect when an entry cannot be looked at', + async () => { + // proj/pkg is readable but not searchable (0600): its entries can be + // listed, so the pattern matches pkg/.env and finds pkg/build, but + // neither can be stat'ed. Skipped as absent, both would be readable + // once the mode is put back. + const proj = join(ROOT, 's12', 'proj') + const pkg = join(proj, 'pkg') + mkdirSync(join(pkg, 'build'), { recursive: true }) + writeFileSync(join(pkg, '.env'), 'ENV') + writeFileSync(join(pkg, 'build', 'o'), 'OUT') + chmodSync(pkg, 0o600) + try { + const denyOnly = [ + ...expandReadDenyGlobLinux(join(proj, '**/build/**'), [proj]), + ...expandReadDenyGlobLinux(join(proj, '**/.env'), [proj]), + ] + expect(denyOnly).toContain(join(pkg, '.env')) + const wrapped = await wrapCommandWithSandboxLinux({ + command: `chmod 755 ${pkg}; cat ${join(pkg, '.env')} ${join(pkg, 'build', 'o')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { denyOnly }, + writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + expect(wrapped).toContain(`--tmpfs ${pkg}`) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).not.toContain('ENV') + expect(run.stdout).not.toContain('OUT') + expect(run.stdout).toContain('HIDDEN') + } + } finally { + chmodSync(pkg, 0o755) + } + }, + ) + + it('starts with a matched link to / in the tree', async () => { + // A sandboxed command with write access under the pattern's base can + // plant such a link; a mount on it would stop every later command. + const proj = join(ROOT, 's13', 'proj') + mkdirSync(join(proj, 'img'), { recursive: true }) + symlinkSync('/', join(proj, 'img', 'build')) const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(carveOut, 'f')}`, + command: 'echo STARTED', needsNetworkRestriction: false, - readConfig: { denyOnly: [target], allowWithinDeny: [carveOut] }, + readConfig: { + denyOnly: [ + ...expandReadDenyGlobLinux(join(proj, '**/build/**'), []), + // Named literally, the link is skipped by the loop as well. + join(proj, 'img', 'build'), + ], + }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).toContain(`--ro-bind ${carveOut} ${join(target, 'sub')}`) + expect(wrapped).not.toContain(`--tmpfs ${join(proj, 'img', 'build')}`) if (hasBwrap) { const run = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 15000, }) - expect(run.status).toBe(0) - expect(run.stdout).toBe('F') + expect(run.stdout).toBe('STARTED\n') } }) diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index e704598dd..b844b2f11 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, realpathSync, rmSync, symlinkSync, @@ -169,6 +170,41 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { expect(result).not.toContain(`--ro-bind ${resolved} ${resolved}`) }) + // /bin -> usr/bin and friends on a usr-merged system. + const rootLinks = readdirSync('/', { withFileTypes: true }) + .filter(entry => entry.isSymbolicLink()) + .map(entry => realpathSync('/' + entry.name)) + .filter(target => target.startsWith('/usr/')) + + it.if(rootLinks.length > 0)( + "does not deny the root's symlinks in their own right under a denyRead of /", + async () => { + // A '/' deny stands for the root's children. /bin, /lib and /sbin are + // links into /usr: denied as entries of their own they are mounted + // where they lead, after /usr's tmpfs and the allowRead of /usr bound + // back over it, and empty the very directories that allowRead names. + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo STARTED', + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/'], allowWithinDeny: ['/usr', '/etc'] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + + expect(wrapped).toContain('--tmpfs /usr --ro-bind /usr /usr') + for (const target of rootLinks) { + expect(wrapped).not.toContain(`--tmpfs ${target} `) + } + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + expect(run.stdout).toBe('STARTED\n') + } + }, + ) + it('resolves the mandatory .claude deny paths when cwd/.claude is a symlink', async () => { const claudeLink = join(PROJ, '.claude') symlinkSync(join('..', 'dotfiles', 'claude'), claudeLink) From 1e40743d76438b3697294fd28867551ec21b37ca Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 02:35:29 +0000 Subject: [PATCH 11/23] fix(linux): keep no deny stub on account of a read-deny tmpfs beneath a write-denied directory The stub-skip guard kept the stub of an absent deny path whenever a read-denied directory sat at or beneath the covering write-denied directory. Kept, the stub makes bwrap create a mount point inside that directory's read-only bind and abort, and with denyRead patterns collapsed to directory mounts any `**/build/**` matching inside a write-denied checkout did that to every command. What the re-applied tmpfs makes writable again is the allowed write paths beneath it, which the guard vetoes on their own, and the tmpfs itself, whose contents never reach the host. The veto is removed; an existing deny path beneath such a directory likewise needs no bind of its own. --- src/sandbox/linux-sandbox-utils.ts | 51 +++++++++---------- test/sandbox/read-deny-glob.test.ts | 52 ++++++++++++++++++++ test/sandbox/readonly-deny-dir-binds.test.ts | 33 +++++++++++-- test/sandbox/readonly-deny-dir-stubs.test.ts | 45 ++++++++++++----- 4 files changed, 140 insertions(+), 41 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 17aaec9ad..5f9e5c5d2 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1301,10 +1301,8 @@ async function generateFilesystemArgs( // a directory or for the ancestor standing in for an entry that cannot // be inspected) — as named and as resolved, which is where the tmpfs // goes. The named form over-predicts, which only keeps a stub. A - // read-denied tmpfs at or under a covering deny dir is the TRIGGER for the - // post-denyWrite writable re-application, and a deny bind whose dest sits - // under one can be dropped by the emission filter — both facts feed the - // guard. + // read-denied tmpfs containing a covering deny dir drops that dir's own + // bind at emission, which is what the guard needs it for. let stubSkipVetoInputs: | { allowedWritePathsBothForms: string[] @@ -1411,8 +1409,8 @@ async function generateFilesystemArgs( // gate as the --ro-bind emission, and it records every raw spelling each // directory is reached through. A recorded directory is EVIDENCE for // skipping a stub only if it also passes the guard's vetoes below (no - // allowed write path strictly beneath it; incomparable with every - // read-deny tmpfs in any spelling), which exclude every way its subtree + // allowed write path strictly beneath it; contained by no read-deny + // tmpfs in any spelling), which exclude every way its subtree // could be writable in the sandbox. Keep the two passes in lockstep: a // directory recorded here but never re-bound read-only AND not vetoed // would suppress stubs unsafely, while an emitted one missing from the @@ -1460,26 +1458,33 @@ async function generateFilesystemArgs( // deny entry. // INVARIANT: a stub, or an existing deny path's own bind, is skipped // only under a recorded covering deny directory that has no allowed - // write path strictly beneath it and is INCOMPARABLE with every - // read-deny tmpfs directory (neither at-or-beneath it nor containing it - // or any spelling it was reached through). Containment is root-aware - // (isAtOrUnder): '/' is a recordable covering directory when allowOnly - // and denyWithinAllow both name it, and '/' + '/' is a prefix of - // nothing, so a string-prefix test would judge it safe for every path + // write path strictly beneath it and that no read-deny tmpfs directory + // contains (it, or any spelling it was reached through). Containment is + // root-aware (isAtOrUnder): '/' is a recordable covering directory when + // allowOnly and denyWithinAllow both name it, and '/' + '/' is a prefix + // of nothing, so a string-prefix test would judge it safe for every path // and drop the binds the re-application passes below key off. // Rationale: the only writable emissions that land after the // buffered read-only binds are the denyRead re-applications // (pushReadDenyDirMounts), which mount a tmpfs and re-bind allowed write - // paths beneath it WITHOUT re-emitting the binds it buries — so a - // comparable tmpfs is both the re-opening vector (beneath or around the - // dir) and the only way the dir's own --ro-bind gets dropped at emission - // as hidden-by-a-tmpfs. (The emission filter's other drop condition, + // paths beneath it WITHOUT re-emitting the binds it buries. A tmpfs + // beneath the dir is re-applied after the dir's bind, and what that makes + // writable again is exactly the allowed write paths beneath the tmpfs, + // hence beneath the dir: veto (i). The tmpfs itself needs no veto of its + // own: a path created inside it never reaches the host, so a deny path + // beneath it needs no stub, and one elsewhere under the dir is not + // affected by it. (Vetoing on it would abort every command of a + // write-denied checkout as soon as a denyRead pattern such as + // `**/build/**` matched a directory inside it.) A tmpfs containing the + // dir is the only way the dir's own --ro-bind gets dropped at emission as + // hidden-by-a-tmpfs, and can re-bind an allowed path around it: veto + // (ii). (The emission filter's other drop condition, // maskedFiles, holds file dests only — /dev/null read-deny masks and // credential-mask fakes — while the pre-pass stat-verifies every // recorded dir as a directory, so it cannot drop a recorded dir short of // a dir→file race, which ends in bwrap refusing to start, not a silent // gap.) Only existing read-deny directories become a tmpfs: absent and - // file-level read-denies count for nothing. If any condition could + // file-level read-denies count for nothing. If either condition could // apply, keep the stub — the pre-existing abort is preferable to a // silently creatable deny path. (An allow path bound before the // denyWrite binds is not a vector by itself: the later read-only re-bind @@ -1496,16 +1501,12 @@ async function generateFilesystemArgs( } = getStubSkipVetoInputs() const unsafe = // (i) an allowed write path strictly beneath the dir: the - // re-application's effect would re-bind it writable. + // re-application of a read-deny tmpfs above it would re-bind it + // writable. allowedWritePathsBothForms.some(writePath => isStrictlyUnder(writePath, denyDir), ) || - // (ii) a read-deny tmpfs at or beneath the dir: the re-application's - // trigger. - prospectiveReadDenyTmpfsDirsBothForms.some(tmpfsDir => - isAtOrUnder(tmpfsDir, denyDir), - ) || - // (iii) a read-deny tmpfs CONTAINING the dir or any raw spelling it + // (ii) a read-deny tmpfs CONTAINING the dir or any raw spelling it // was reached through: the dir's own --ro-bind can be dropped as // hidden-by-the-tmpfs at emission, and a tmpfs above it can // re-bind an allowed path around it — either way the directory @@ -1713,7 +1714,7 @@ async function generateFilesystemArgs( if (isWithinAllowedPath) { // Already unwritable under a read-only denied directory (the - // existing-path twin of the stub skip above). Veto (iii) keeps the + // existing-path twin of the stub skip above). Veto (ii) 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. diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 8c19f1874..ea848944c 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1070,6 +1070,58 @@ describe.if(isLinux)( } }) + it('starts in a write-denied checkout with a denyRead pattern matching a directory inside it', async () => { + // denyWrite [proj, proj/.claude/settings.json (absent)] + denyRead + // proj/**/build/**: collapsed, the pattern is a tmpfs beneath the + // write-denied directory. The absent deny path is uncreatable under + // proj's read-only bind either way, and a stub for it would have bwrap + // create a mount point inside that bind and abort. + const work = join(ROOT, 's14', 'work') + const proj = join(work, 'proj') + mkdirSync(join(proj, 'pkg', 'build'), { recursive: true }) + writeFileSync(join(proj, 'pkg', 'build', '1.out'), 'OUT') + const cwd = process.cwd() + process.chdir(proj) + try { + const wrapped = await wrapCommandWithSandboxLinux({ + command: `mkdir ${join(proj, '.claude')} || echo UNCREATABLE; cat ${join(proj, 'pkg', 'build', '1.out')} || echo HIDDEN`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(proj, '**/build/**'), [ + work, + ]), + }, + writeConfig: { + allowOnly: [work], + denyWithinAllow: [proj, join(proj, '.claude', 'settings.json')], + }, + mandatoryDenySearchDepth: 1, + }) + const projBind = wrapped.lastIndexOf(`--ro-bind ${proj} ${proj}`) + expect(projBind).toBeGreaterThan(-1) + // No stub: nothing is mounted at or beneath proj/.claude. + expect( + wrapped + .slice(0, wrapped.indexOf(' --dev ')) + .split(' --') + .filter(op => op.includes(` ${join(proj, '.claude')}`)), + ).toEqual([]) + expect( + wrapped.lastIndexOf(`--tmpfs ${join(proj, 'pkg', 'build')}`), + ).toBeGreaterThan(projBind) + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(run.stdout).toBe('UNCREATABLE\nHIDDEN\n') + } + } finally { + process.chdir(cwd) + } + }) + it('still masks the target of a file symlink listed beneath a denied directory', async () => { // denyRead [cfg, cfg/token], cfg/token -> ../secrets/token: the link // vanishes with cfg's tmpfs, but the file it named is the target, diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index bf1b4ee1d..1f22b4e42 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -156,14 +156,37 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) }) - it('keeps the descendant bind when a denyRead tmpfs sits under the covering dir (veto)', async () => { + it('skips the descendant bind when only a denyRead tmpfs sits under the covering dir', async () => { + // The tmpfs is re-applied after PROJ's bind, and re-binds writable only + // the allowed write paths beneath it (the veto above): with none, all it + // adds under PROJ is a tmpfs whose contents never reach the host, and + // FILE stays under the read-only bind. const readDenied = join(PROJ, 'secrets') mkdirSync(readDenied) - const command = await wrap([PROJ, FILE], [readDenied]) + const command = await wrap( + [PROJ, FILE], + [readDenied], + [AREA], + `sh -c 'echo x >> ${FILE}'`, + ) - expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) - expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + const projBind = command.lastIndexOf(`--ro-bind ${PROJ} ${PROJ}`) + expect(projBind).toBeGreaterThan(-1) + expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) + expect(command.lastIndexOf(`--tmpfs ${readDenied}`)).toBeGreaterThan( + projBind, + ) + if (BWRAP_CAN_NAMESPACE) { + const write = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 15000, + cwd: BASE, + }) + expect(write.status).not.toBe(0) + expect(readFileSync(FILE, 'utf8')).toBe('{}\n') + } }) it('does not trust a recorded "/" as a covering directory', async () => { @@ -230,7 +253,7 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { it('vetoes "/" for an allowed write path beneath it even with no read policy', async () => { // Veto (i) alone must be root-aware: with readConfig undefined there is - // no read-deny tmpfs for veto (ii) to catch '/' with. + // no read-deny tmpfs for the other veto to fire on. const command = await wrapCommandWithSandboxLinux({ command: 'echo hello', needsNetworkRestriction: false, diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index 1b270a25b..769371e5c 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -37,8 +37,7 @@ import { isLinux } from '../helpers/platform.js' * the pre-existing abort to a silently creatable deny path) whenever the * denyRead re-application machinery could make that subtree writable * again: an allowed write path strictly beneath the covering directory, or - * a read-deny tmpfs comparable with it (at/beneath it, or containing it or - * any spelling it was reached through). + * a read-deny tmpfs containing it or any spelling it was reached through. */ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { // realpathSync so exact-string assertions hold even when tmpdir itself @@ -245,21 +244,45 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).not.toContain(`--ro-bind /dev/null ${absentDeny}`) }) - it('keeps the stub when a denyRead directory sits under the covering deny dir (trigger without nested allow)', async () => { - // A read-denied directory strictly inside the write-denied dir is the - // TRIGGER for the post-deny writable re-application, so the subtree is - // treated as re-openable and the stub is kept even with no nested - // allowWrite — a config a later allowWrite addition would otherwise - // silently weaken. + it('skips the stub when a denyRead directory sits under the covering deny dir with no allowed write path beneath it', async () => { + // A read-denied directory strictly inside the write-denied dir is + // re-applied as a tmpfs after that dir's read-only bind. What the + // re-application makes writable again is the allowed write paths beneath + // the tmpfs (vetoed separately) and the tmpfs itself, whose contents + // never reach the host: the absent dotfile stays uncreatable, and a stub + // for it would abort bwrap inside the read-only bind — for every command + // of a write-denied checkout, as soon as a denyRead pattern such as + // **/build/** matches a directory in it. process.chdir(PROJ) const readDenied = join(PROJ, 'secrets') mkdirSync(readDenied) writeFileSync(join(readDenied, 'token.txt'), 'x\n') - const command = await wrap([PROJ], [readDenied]) + const command = await wrap( + [PROJ], + [readDenied], + [AREA], + `touch ${join(PROJ, '.gitconfig')} || echo UNCREATABLE`, + ) - expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) - expect(command).toContain(`--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`) + const projBind = command.lastIndexOf(`--ro-bind ${PROJ} ${PROJ}`) + expect(projBind).toBeGreaterThan(-1) + expect(command).not.toContain( + `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`, + ) + expect(command.lastIndexOf(`--tmpfs ${readDenied}`)).toBeGreaterThan( + projBind, + ) + if (BWRAP_CAN_NAMESPACE) { + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 15000, + cwd: PROJ, + }) + expect(run.stdout).toBe('UNCREATABLE\n') + expect(existsSync(join(PROJ, '.gitconfig'))).toBe(false) + } }) it('skips the stub when only a file-level denyRead sits under the covering dir (no tmpfs, no re-open)', async () => { From 2e7295ed159a5b99758c9b9fc0750e6f8868b2d5 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 02:35:39 +0000 Subject: [PATCH 12/23] docs: say where Linux read-deny mounts and the paths bound back over them land --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a72625eb0..5d795ae41 100644 --- a/README.md +++ b/README.md @@ -375,12 +375,12 @@ Examples: bubblewrap binds concrete paths, so glob support is narrower than on macOS: - `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. -- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one whose first wildcard comes straight after `/`, such as `/**/*.pem`, is skipped on Linux. -- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it never reach the host, and a file added to it later on the host is hidden too. A matched directory that is empty at that point gets no mount. An `allowRead` beneath a mounted directory is bound back over the tmpfs, and matched entries beneath that carve-out keep their own masks. -- A directory the expansion cannot list is denied as a whole. -- Symlinked directories are descended. An entry reached through a symlink is denied at the link's target as well, and a link back up the tree denies everything it reaches, as a literal deny of the link would. Nothing is denied at `/` through a link that resolves to it. -- A carve-out beneath a link applies in whichever spelling it is written; an `allowRead` that is itself a symlink is bound at its target. -- A directory `denyRead` whose path is a symlink (listed literally, or matched by a pattern) is mounted at the link's target: bubblewrap 0.12 and later refuse to mount on a symlink. +- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone). +- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it never reach the host, and a file added to it later on the host is hidden too. A matched directory that is empty at that point gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable. +- A directory the expansion cannot list is denied as a whole, and nothing beneath it is bound back, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say) hides the nearest directory above it that can, in the same way. +- Symlinked directories are descended. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to `/` or to nothing is skipped. +- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox, and only when its name lives inside that directory (symlinked directories on the way to it resolved, its last component taken as written) and it resolves to somewhere inside it. What a symlink at an allowed path points to is not re-allowed on that account: replacing `docs` with a link to `~/.ssh` does not turn `allowRead: ["docs"]` into an exception to `denyRead: ["~/.ssh"]`. The same holds for a file: an `allowRead` entry lifts its mask only when it names that very file, not a symlink to it. +- `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) needs no mount of its own, since what it leads to is denied with the directory that holds it. Examples: From f3330176424020cecc3deb96000c3383e82da16e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 02:54:02 +0000 Subject: [PATCH 13/23] test(linux): list the root's symlinks inside the test, and keep the seccomp helper out of its command The suite is collected on every platform, so listing / while it is collected ran on macOS too and threw on a dangling root symlink. The listing moves into the test and skips a link that does not resolve. Where the apply-seccomp helper has been built, the wrapped command runs through it, and it lives in the checkout, which a denyRead of / hides: the shell could not exec it and nothing was printed. The test passes allowAllUnixSockets, as the '/' denies in allow-read.test.ts do, and asserts on status, stdout and stderr together so a failure explains itself. --- test/sandbox/symlinked-deny-paths.test.ts | 80 ++++++++++++++--------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index b844b2f11..112693983 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -170,40 +170,56 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { expect(result).not.toContain(`--ro-bind ${resolved} ${resolved}`) }) - // /bin -> usr/bin and friends on a usr-merged system. - const rootLinks = readdirSync('/', { withFileTypes: true }) - .filter(entry => entry.isSymbolicLink()) - .map(entry => realpathSync('/' + entry.name)) - .filter(target => target.startsWith('/usr/')) - - it.if(rootLinks.length > 0)( - "does not deny the root's symlinks in their own right under a denyRead of /", - async () => { - // A '/' deny stands for the root's children. /bin, /lib and /sbin are - // links into /usr: denied as entries of their own they are mounted - // where they lead, after /usr's tmpfs and the allowRead of /usr bound - // back over it, and empty the very directories that allowRead names. - const wrapped = await wrapCommandWithSandboxLinux({ - command: 'echo STARTED', - needsNetworkRestriction: false, - readConfig: { denyOnly: ['/'], allowWithinDeny: ['/usr', '/etc'] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + it("does not deny the root's symlinks in their own right under a denyRead of /", async () => { + // /bin -> usr/bin and friends on a usr-merged system. Listed here, not + // while the suite is collected: that happens on every platform, and a + // root symlink may dangle (macOS runners have one). + const rootLinks = readdirSync('/', { withFileTypes: true }) + .filter(entry => entry.isSymbolicLink()) + .flatMap(entry => { + try { + return [realpathSync('/' + entry.name)] + } catch { + return [] + } }) + .filter(target => target.startsWith('/usr/')) + if (rootLinks.length === 0) return // not usr-merged: nothing to tell apart + + // A '/' deny stands for the root's children. /bin, /lib and /sbin are + // links into /usr: denied as entries of their own they are mounted + // where they lead, after /usr's tmpfs and the allowRead of /usr bound + // back over it, and empty the very directories that allowRead names. + // allowAllUnixSockets keeps the apply-seccomp helper out of the command: + // where it has been built it lives in the checkout, which the '/' deny + // hides, and the shell would fail to exec it (allow-read.test.ts does the + // same for its '/' denies). + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo STARTED', + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/'], allowWithinDeny: ['/usr', '/etc'] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + allowAllUnixSockets: true, + }) - expect(wrapped).toContain('--tmpfs /usr --ro-bind /usr /usr') - for (const target of rootLinks) { - expect(wrapped).not.toContain(`--tmpfs ${target} `) - } - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - expect(run.stdout).toBe('STARTED\n') - } - }, - ) + expect(wrapped).toContain('--tmpfs /usr --ro-bind /usr /usr') + for (const target of rootLinks) { + expect(wrapped).not.toContain(`--tmpfs ${target} `) + } + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + // The whole outcome, so a failure says why the sandbox did not start. + expect({ + status: run.status, + stdout: run.stdout, + stderr: run.stderr, + }).toEqual({ status: 0, stdout: 'STARTED\n', stderr: '' }) + } + }) it('resolves the mandatory .claude deny paths when cwd/.claude is a symlink', async () => { const claudeLink = join(PROJ, '.claude') From cf0d5cd1e7c6dae8dc34f23796ec088d664551a5 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 12:00:35 +0000 Subject: [PATCH 14/23] test(linux): let the covering-directory tests fail, and gate their runtime arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the order-independence tests could not fail for the reason their comments give. Both placed the inner covering directory under the read-deny tmpfs, where veto (iii) keeps the stub on its own account, so a guard that consulted only the first covering directory, or only the directories seen so far in deny order, still kept the stub and both tests passed. The inner directory now sits outside the tmpfs, unvetoed, so only the outer directory's veto can keep the stub — and each test now fails under the bug its comment names. The runtime halves were `if (BWRAP_CAN_NAMESPACE)` arms inside the test bodies, so a host without namespaces reported a pass over a check that never ran. Each is its own it.skipIf test now, visible as a skip, and two cases that KEEP a stub gained one: a kept stub over a writable cwd boots, and a kept stub emitted inside a covering read-only bind aborts bwrap at startup — the fail-closed tradeoff the guard is written around, which nothing exercised before. --- test/sandbox/readonly-deny-dir-binds.test.ts | 50 +++--- test/sandbox/readonly-deny-dir-stubs.test.ts | 175 ++++++++++++++----- 2 files changed, 158 insertions(+), 67 deletions(-) diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index 1f22b4e42..bf59f9148 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -96,11 +96,13 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { expect(countOccurrences(command, `--ro-bind ${PROJ} ${PROJ}`)).toBe(1) expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) + }) - // Where the host can run bwrap, prove the covering bind alone still - // holds: the file reads, and a write through it fails and changes - // nothing on the host. - if (BWRAP_CAN_NAMESPACE) { + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'holds the skipped file under the covering bind alone at runtime', + async () => { + // The covering bind is all that protects FILE: it must still read, and + // a write through it must fail and change nothing on the host. const run = (wrapped: string) => spawnSync(wrapped, { shell: true, @@ -117,8 +119,8 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { ) expect(write.status).not.toBe(0) expect(readFileSync(FILE, 'utf8')).toBe('{}\n') - } - }) + }, + ) it('is independent of the order the denies are listed in', async () => { const command = await wrap([FILE, PROJ], [], [PROJ]) @@ -164,12 +166,7 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { const readDenied = join(PROJ, 'secrets') mkdirSync(readDenied) - const command = await wrap( - [PROJ, FILE], - [readDenied], - [AREA], - `sh -c 'echo x >> ${FILE}'`, - ) + const command = await wrap([PROJ, FILE], [readDenied]) const projBind = command.lastIndexOf(`--ro-bind ${PROJ} ${PROJ}`) expect(projBind).toBeGreaterThan(-1) @@ -177,17 +174,28 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { expect(command.lastIndexOf(`--tmpfs ${readDenied}`)).toBeGreaterThan( projBind, ) - if (BWRAP_CAN_NAMESPACE) { - const write = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 15000, - cwd: BASE, - }) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'keeps the file unwritable when only a denyRead tmpfs sits under the covering dir', + async () => { + const readDenied = join(PROJ, 'secrets') + mkdirSync(readDenied) + + const write = spawnSync( + await wrap( + [PROJ, FILE], + [readDenied], + [AREA], + `sh -c 'echo x >> ${FILE}'`, + ), + { shell: true, encoding: 'utf8', timeout: 15000, cwd: BASE }, + ) + expect(write.status).not.toBe(0) expect(readFileSync(FILE, 'utf8')).toBe('{}\n') - } - }) + }, + ) it('does not trust a recorded "/" as a covering directory', async () => { // allowOnly and denyWithinAllow both naming '/' records it as a diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index 769371e5c..13d6919ca 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -104,7 +104,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { }) } - it('skips stubs for absent mandatory-deny dotfiles inside a write-denied cwd, and bwrap still boots', async () => { + it('skips stubs for absent mandatory-deny dotfiles inside a write-denied cwd', async () => { // The real-world shape: cwd is write-denied, so the mandatory dotfile // denies at cwd (.gitconfig, .bashrc, …) are all absent stub candidates. process.chdir(PROJ) @@ -130,12 +130,17 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(afterReadOnlyRebind).not.toMatch( /--ro-bind \S*claude-empty-\S+ \S*\/proj\//, ) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'boots under a write-denied cwd and still blocks the absent dotfile', + async () => { + // The runtime half: the pre-fix symptom was a startup abort with no + // command executed, and the deny must still hold (the absent dotfile + // stays uncreatable). + process.chdir(PROJ) - // Where the host can run bwrap, prove the sandbox actually boots — the - // pre-fix symptom was a startup abort with no command executed — and - // that the deny still holds (the absent dotfile stays uncreatable). - if (BWRAP_CAN_NAMESPACE) { - const run = spawnSync(command, { + const run = spawnSync(await wrap([PROJ]), { shell: true, encoding: 'utf8', timeout: 15000, @@ -151,8 +156,8 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { ) expect(denied.status).not.toBe(0) expect(existsSync(join(PROJ, '.gitconfig'))).toBe(false) - } - }) + }, + ) it('still stubs an absent mandatory-deny dotfile when the cwd remains writable (no over-broad skip)', async () => { // Control: without the covering denyWrite, cwd stays writable, so the @@ -165,6 +170,27 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).toContain(`--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`) }) + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'boots with the stub kept over a writable cwd', + async () => { + // A KEPT stub bwrap can still mount: nothing re-binds cwd read-only, so + // the /dev/null mount point is created in a writable tree and the + // sandbox starts. Where a kept stub lands inside a read-only bind it + // aborts instead — the fail-closed case pinned further down. + process.chdir(PROJ) + + const run = spawnSync(await wrap([]), { + shell: true, + encoding: 'utf8', + timeout: 15000, + cwd: PROJ, + }) + expect(run.stderr ?? '').not.toMatch(/Can't create file/i) + expect(run.status).toBe(0) + expect(run.stdout).toContain('hello') + }, + ) + it('keeps the stub (fails closed) when an allowed write path beneath the denied dir is re-opened by denyRead', async () => { // The one shape where "the ancestor is under a read-only deny" is not // reliable: a denyRead directory inside the write-denied dir plus an @@ -188,16 +214,48 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).toContain(`--ro-bind /dev/null ${absentDeny}`) }) + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'aborts at startup rather than leaving the kept stub creatable (fail closed)', + async () => { + // The runtime half of the case above, and the tradeoff the guard is + // written around: the kept stub is emitted after the covering read-only + // bind, so bwrap has to creat() its mount point inside that bind and + // refuses to start. That is the pre-existing abort, deliberately + // preferred to a silently creatable deny path — and a regression that + // kept the stub AND ran would show up here as a created file. + const readDenied = join(PROJ, 'ro') + const nestedAllow = join(readDenied, 'w') + mkdirSync(nestedAllow, { recursive: true }) + const absentDeny = join(nestedAllow, '.secret') + + const run = spawnSync( + await wrap( + [PROJ, absentDeny], + [readDenied], + [AREA, nestedAllow], + `touch ${absentDeny}`, + ), + { shell: true, encoding: 'utf8', timeout: 15000, cwd: PROJ }, + ) + + expect(run.status).not.toBe(0) + expect(run.stderr ?? '').toMatch(/Read-only file system/i) + expect(existsSync(absentDeny)).toBe(false) + }, + ) + it('keeps the stub when ANY covering deny dir has an allowed write path re-opened beneath it', async () => { // The read-only conclusion must hold across EVERY deny dir covering the - // ancestor, not just one. Here the absent deny's ancestor d is covered - // by both PROJ (which has the allowWrite t/w strictly beneath it, - // re-opened by the denyRead re-application of t) and d itself (with no - // re-opener beneath it). A per-dir check would skip on d and leave the - // path creatable through the t/w re-bind. + // ancestor, not just one. Here the absent deny's ancestor d is covered by + // both PROJ — vetoed, because the allowWrite t/w beneath it sits under + // the read-deny tmpfs t and is bound back writable when that tmpfs is + // re-applied — and d itself, which nothing vetoes: no allowWrite lies + // beneath d and no read-deny tmpfs contains it. A per-dir check would + // skip on d and leave the path creatable through the t/w re-bind. const readDenied = join(PROJ, 't') const nestedAllow = join(readDenied, 'w') - const innerDenied = join(nestedAllow, 'd') + mkdirSync(nestedAllow, { recursive: true }) + const innerDenied = join(PROJ, 'other', 'd') mkdirSync(innerDenied, { recursive: true }) writeFileSync(join(innerDenied, 'keep.txt'), 'x\n') const absentDeny = join(innerDenied, '.secret') @@ -214,12 +272,14 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { it('keeps the stub regardless of where the vetoed covering dir appears in the deny ordering', async () => { // Same shape, but the vetoed covering dir PROJ is listed AFTER the - // absent entry. A decision that only consults deny dirs seen so far - // would miss PROJ's re-opener and skip unsafely; the pre-pass collects - // deny dirs order-independently, so the stub is kept. + // absent entry, and the unvetoed d before it. A decision that only + // consults deny dirs seen so far would find d alone, conclude read-only + // and skip unsafely; the pre-pass collects deny dirs order-independently, + // so PROJ's veto is visible here too and the stub is kept. const readDenied = join(PROJ, 't') const nestedAllow = join(readDenied, 'w') - const innerDenied = join(nestedAllow, 'd') + mkdirSync(nestedAllow, { recursive: true }) + const innerDenied = join(PROJ, 'other', 'd') mkdirSync(innerDenied, { recursive: true }) writeFileSync(join(innerDenied, 'keep.txt'), 'x\n') const absentDeny = join(innerDenied, '.secret') @@ -258,12 +318,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { mkdirSync(readDenied) writeFileSync(join(readDenied, 'token.txt'), 'x\n') - const command = await wrap( - [PROJ], - [readDenied], - [AREA], - `touch ${join(PROJ, '.gitconfig')} || echo UNCREATABLE`, - ) + const command = await wrap([PROJ], [readDenied]) const projBind = command.lastIndexOf(`--ro-bind ${PROJ} ${PROJ}`) expect(projBind).toBeGreaterThan(-1) @@ -273,17 +328,30 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command.lastIndexOf(`--tmpfs ${readDenied}`)).toBeGreaterThan( projBind, ) - if (BWRAP_CAN_NAMESPACE) { - const run = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 15000, - cwd: PROJ, - }) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'leaves the dotfile uncreatable with a denyRead tmpfs under the covering deny dir', + async () => { + process.chdir(PROJ) + const readDenied = join(PROJ, 'secrets') + mkdirSync(readDenied) + writeFileSync(join(readDenied, 'token.txt'), 'x\n') + + const run = spawnSync( + await wrap( + [PROJ], + [readDenied], + [AREA], + `touch ${join(PROJ, '.gitconfig')} || echo UNCREATABLE`, + ), + { shell: true, encoding: 'utf8', timeout: 15000, cwd: PROJ }, + ) + expect(run.stdout).toBe('UNCREATABLE\n') expect(existsSync(join(PROJ, '.gitconfig'))).toBe(false) - } - }) + }, + ) it('skips the stub when only a file-level denyRead sits under the covering dir (no tmpfs, no re-open)', async () => { // Only an existing DIRECTORY in denyRead becomes a tmpfs and can @@ -357,18 +425,27 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).not.toContain( `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`, ) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'boots with the read-denied dirs as unrelated siblings of the write-denied dir', + async () => { + const homeDir = join(BASE, 'home') + mkdirSync(join(homeDir, '.ssh'), { recursive: true }) + writeFileSync(join(homeDir, '.ssh', 'id_test.pub'), 'ssh-test AAAA\n') + const runDir = join(BASE, 'run') + mkdirSync(runDir) + process.chdir(PROJ) + + const run = spawnSync( + await wrap([PROJ], [join(homeDir, '.ssh')], [AREA, runDir]), + { shell: true, encoding: 'utf8', timeout: 15000, cwd: PROJ }, + ) - if (BWRAP_CAN_NAMESPACE) { - const run = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 15000, - cwd: PROJ, - }) expect(run.stderr ?? '').not.toMatch(/Read-only file system/i) expect(run.status).toBe(0) - } - }) + }, + ) it('enforces denyWithinAllow under a trailing-slash allowOnly spelling', async () => { // A trailing-slash allowOnly entry survives normalizePathForSandbox and @@ -403,18 +480,24 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).not.toContain( `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`, ) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'boots under a trailing-slash allow spelled at the denied dir', + async () => { + process.chdir(PROJ) - if (BWRAP_CAN_NAMESPACE) { - const run = spawnSync(command, { + const run = spawnSync(await wrap([PROJ], [], [`${PROJ}/`]), { shell: true, encoding: 'utf8', timeout: 15000, cwd: PROJ, }) + expect(run.stderr ?? '').not.toMatch(/Read-only file system/i) expect(run.status).toBe(0) - } - }) + }, + ) it('re-applies a denyWithinAllow bind under a trailing-slash allow re-bound over a denyRead tmpfs', async () => { // The emission filter drops deny binds hidden by a denyRead tmpfs From 8419c307843ed16ec5f207b8c6923a1d1df08de0 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 12:00:35 +0000 Subject: [PATCH 15/23] fix(linux): keep a deny stub only where a read-deny tmpfs can re-open the covering directory An allowed write path strictly beneath a write-denied directory vetoed the stub skip whether or not anything could re-open it, so `allowWrite: [area, /out]` with `denyWrite: []` and no denyRead at all still aborted every command at startup: the mandatory dotfile denies kept their stubs, and bubblewrap had to create the mount point inside the read-only re-bind of the checkout. That is the ordinary "write-protect the checkout, let the build write to /out" profile. The veto now also requires the write path to be at or under a prospective read-deny tmpfs, because that is the only thing that can re-open it. The one emission after the covering --ro-bind that is both host-backed and writable is pushReadDenyDirMounts' `--bind `; everything else that follows is a tmpfs, whose contents never reach the host, a --ro-bind file mask, or the read-only fake-file store bind. And that re-bind covers a write path only when the path is at or under the tmpfs. A write path with no tmpfs over it was bound before the covering bind and stays buried by it, which the new tests check at runtime: the nested allow is unwritable inside the sandbox too. --- src/sandbox/linux-sandbox-utils.ts | 63 +++++++++------- test/sandbox/readonly-deny-dir-binds.test.ts | 76 +++++++++++++++----- test/sandbox/readonly-deny-dir-stubs.test.ts | 68 +++++++++++++++++- 3 files changed, 163 insertions(+), 44 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 5f9e5c5d2..18e5148a5 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1409,9 +1409,10 @@ async function generateFilesystemArgs( // gate as the --ro-bind emission, and it records every raw spelling each // directory is reached through. A recorded directory is EVIDENCE for // skipping a stub only if it also passes the guard's vetoes below (no - // allowed write path strictly beneath it; contained by no read-deny - // tmpfs in any spelling), which exclude every way its subtree - // could be writable in the sandbox. Keep the two passes in lockstep: a + // allowed write path both strictly beneath it and under a read-deny + // tmpfs; contained by no read-deny tmpfs in any spelling), which exclude + // every way its subtree could be writable in the sandbox. Keep the two + // passes in lockstep: a // directory recorded here but never re-bound read-only AND not vetoed // would suppress stubs unsafely, while an emitted one missing from the // record only costs a spurious abort. @@ -1458,27 +1459,35 @@ async function generateFilesystemArgs( // deny entry. // INVARIANT: a stub, or an existing deny path's own bind, is skipped // only under a recorded covering deny directory that has no allowed - // write path strictly beneath it and that no read-deny tmpfs directory + // write path both strictly beneath it and at or under a prospective + // read-deny tmpfs directory, and that no read-deny tmpfs directory // contains (it, or any spelling it was reached through). Containment is // root-aware (isAtOrUnder): '/' is a recordable covering directory when // allowOnly and denyWithinAllow both name it, and '/' + '/' is a prefix // of nothing, so a string-prefix test would judge it safe for every path // and drop the binds the re-application passes below key off. - // Rationale: the only writable emissions that land after the - // buffered read-only binds are the denyRead re-applications - // (pushReadDenyDirMounts), which mount a tmpfs and re-bind allowed write - // paths beneath it WITHOUT re-emitting the binds it buries. A tmpfs - // beneath the dir is re-applied after the dir's bind, and what that makes - // writable again is exactly the allowed write paths beneath the tmpfs, - // hence beneath the dir: veto (i). The tmpfs itself needs no veto of its - // own: a path created inside it never reaches the host, so a deny path - // beneath it needs no stub, and one elsewhere under the dir is not - // affected by it. (Vetoing on it would abort every command of a - // write-denied checkout as soon as a denyRead pattern such as - // `**/build/**` matched a directory inside it.) A tmpfs containing the - // dir is the only way the dir's own --ro-bind gets dropped at emission as - // hidden-by-a-tmpfs, and can re-bind an allowed path around it: veto - // (ii). (The emission filter's other drop condition, + // Rationale: the only emission that lands after the buffered read-only + // binds and is both host-backed and writable is the denyRead + // re-application's `--bind ` (pushReadDenyDirMounts): + // everything else that follows is a tmpfs (its contents never reach the + // host), a --ro-bind file mask, or the read-only fake-file store bind. + // That re-application mounts a tmpfs and re-binds allowed write paths + // beneath it WITHOUT re-emitting the binds it buries, and it re-binds an + // allowed write path only when that path is AT OR UNDER the tmpfs. So the + // dir's subtree can be re-opened only by an allowed write path that is + // both strictly beneath the dir and at or under a prospective read-deny + // tmpfs: veto (i). An allowed write path beneath the dir with no such + // tmpfs over it is bound BEFORE the dir's read-only bind and stays buried + // by it — vetoing on it alone aborted every command of the common + // "write-protect the checkout, let the build write to /out" + // profile. The tmpfs itself needs no veto of its own: a path created + // inside it never reaches the host, so a deny path beneath it needs no + // stub, and one elsewhere under the dir is not affected by it. (Vetoing + // on it would abort every command of a write-denied checkout as soon as a + // denyRead pattern such as `**/build/**` matched a directory inside it.) A + // tmpfs containing the dir is the only way the dir's own --ro-bind gets + // dropped at emission as hidden-by-a-tmpfs, and can re-bind an allowed + // path around it: veto (ii). (The emission filter's other drop condition, // maskedFiles, holds file dests only — /dev/null read-deny masks and // credential-mask fakes — while the pre-pass stat-verifies every // recorded dir as a directory, so it cannot drop a recorded dir short of @@ -1500,11 +1509,17 @@ async function generateFilesystemArgs( prospectiveReadDenyTmpfsDirsBothForms, } = getStubSkipVetoInputs() const unsafe = - // (i) an allowed write path strictly beneath the dir: the - // re-application of a read-deny tmpfs above it would re-bind it - // writable. - allowedWritePathsBothForms.some(writePath => - isStrictlyUnder(writePath, denyDir), + // (i) an allowed write path strictly beneath the dir AND at or under + // a prospective read-deny tmpfs: re-applying that tmpfs after the + // dir's read-only bind re-binds exactly such a path, writable. + // Without a tmpfs over it the path's own --bind was emitted + // before the dir's bind and stays buried by it. + allowedWritePathsBothForms.some( + writePath => + isStrictlyUnder(writePath, denyDir) && + prospectiveReadDenyTmpfsDirsBothForms.some(tmpfsDir => + isAtOrUnder(writePath, tmpfsDir), + ), ) || // (ii) a read-deny tmpfs CONTAINING the dir or any raw spelling it // was reached through: the dir's own --ro-bind can be dropped as diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index bf59f9148..45cd1215c 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -145,17 +145,33 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { 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. + it('keeps the descendant bind when an allowed write path under a read-deny tmpfs sits beneath the covering dir (veto)', async () => { + // Same veto as the stub skip: an allowWrite beneath PROJ and at or under + // a denyRead directory is bound back writable when that tmpfs is + // re-applied after PROJ's read-only bind, so the covering bind is not + // trusted and the explicit deny keeps its own. + const readDenied = join(PROJ, 'ro') + const nestedAllow = join(readDenied, 'w') + mkdirSync(nestedAllow, { recursive: true }) + + const command = await wrap([PROJ, FILE], [readDenied], [AREA, nestedAllow]) + + expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) + }) + + it('skips the descendant bind when the allowed write path beneath the covering dir is buried', async () => { + // The veto needs a read-deny tmpfs over the nested allow. With none, the + // allow's --bind is emitted before the covering read-only bind and stays + // buried by it, so FILE is already unwritable and needs no bind of its + // own — and stubbing its absent siblings would abort bwrap. 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}`) + expect(command).not.toContain(`--ro-bind ${FILE} ${FILE}`) }) it('skips the descendant bind when only a denyRead tmpfs sits under the covering dir', async () => { @@ -203,10 +219,19 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { // veto ('/' + '/') could never fire, so PROJ's own bind would be dropped // as covered; the recursive --ro-bind / / emitted later then shadows the // FILE mask with no bind left to key its re-application off, and the - // read-denied file is readable. Root-aware containment vetoes '/' (AREA - // is an allowed write path beneath it), keeps PROJ's bind, and re-applies - // the mask after the root bind. - const command = await wrap(['/', PROJ], [FILE], ['/', AREA]) + // read-denied file is readable. Root-aware containment vetoes '/' (the + // allowed write path inside the read-denied dir is re-bound writable + // beneath it), keeps PROJ's bind, and re-applies the mask after the root + // bind. + const readDenied = join(AREA, 'ro') + const nestedAllow = join(readDenied, 'w') + mkdirSync(nestedAllow, { recursive: true }) + + const command = await wrap( + ['/', PROJ], + [FILE, readDenied], + ['/', AREA, nestedAllow], + ) expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) const rootBind = command.lastIndexOf('--ro-bind / /') @@ -216,13 +241,22 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { }) it('skips the stubs under a write-denied cwd even when a recorded "/" is vetoed', async () => { - // '/' recorded and vetoed (AREA is writable beneath it). A veto that - // disqualified every skip would stub each absent mandatory-deny dotfile - // of the write-denied cwd after the cwd's own bind — the startup abort + // '/' recorded and vetoed (the allow inside the read-denied dir is + // re-bound writable beneath it). A veto that disqualified every skip + // would stub each absent mandatory-deny dotfile of the write-denied cwd + // after the cwd's own bind — the startup abort // readonly-deny-dir-stubs.test.ts documents. The cwd's recorded bind - // decides instead, as on main. + // decides instead, as on main: nothing read-denied sits under it. + const readDenied = join(AREA, 'ro') + const nestedAllow = join(readDenied, 'w') + mkdirSync(nestedAllow, { recursive: true }) process.chdir(PROJ) - const command = await wrap(['/', PROJ], [], ['/', AREA]) + + const command = await wrap( + ['/', PROJ], + [readDenied], + ['/', AREA, nestedAllow], + ) expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) expect(command).not.toContain(`/dev/null ${PROJ}/`) @@ -259,9 +293,12 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { expect(command).toContain(`--ro-bind ${sibling} ${sibling}`) }) - it('vetoes "/" for an allowed write path beneath it even with no read policy', async () => { - // Veto (i) alone must be root-aware: with readConfig undefined there is - // no read-deny tmpfs for the other veto to fire on. + it('skips the per-path denies under a recorded "/" when no read policy can re-open anything', async () => { + // With readConfig undefined there is no read-deny tmpfs at all — not even + // the implicit ssh_config.d one — so nothing is re-bound writable after + // the deny binds and a recorded '/' survives both vetoes. It then covers + // every path beneath it: the per-path denies are skipped, and the root's + // own read-only bind, emitted after every allow bind, is what holds them. const command = await wrapCommandWithSandboxLinux({ command: 'echo hello', needsNetworkRestriction: false, @@ -269,7 +306,10 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { writeConfig: { allowOnly: ['/', AREA], denyWithinAllow: ['/', PROJ] }, }) - expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).not.toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command.lastIndexOf('--ro-bind / /')).toBeGreaterThan( + command.indexOf(`--bind ${AREA} ${AREA}`), + ) }) it('does not re-apply a tmpfs over the bind that denies the same directory', async () => { diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index 13d6919ca..9b3c817c1 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -36,8 +36,11 @@ import { isLinux } from '../helpers/platform.js' * already uncreatable there — and keeps the stub (fail closed, preferring * the pre-existing abort to a silently creatable deny path) whenever the * denyRead re-application machinery could make that subtree writable - * again: an allowed write path strictly beneath the covering directory, or - * a read-deny tmpfs containing it or any spelling it was reached through. + * again: an allowed write path that is both strictly beneath the covering + * directory and at or under a read-deny tmpfs (the tmpfs re-application + * binds exactly those back, writable, after the covering bind), or a + * read-deny tmpfs containing the covering directory or any spelling it was + * reached through. */ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { // realpathSync so exact-string assertions hold even when tmpdir itself @@ -191,6 +194,67 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { }, ) + it('skips stubs when a nested allow under the write-denied checkout is buried, not re-opened', async () => { + // The "write-protect the checkout, let the build write to /out" + // profile, with nothing read-denied. The nested allow's --bind is emitted + // BEFORE the covering read-only bind, and with no read-deny tmpfs over it + // there is no re-application to bind it back on top — so the whole + // checkout is read-only in the sandbox and the absent dotfile denies need + // no stub. Vetoing on the nested allow alone kept them and aborted every + // command at startup inside the read-only bind. + process.chdir(PROJ) + const out = join(PROJ, 'out') + mkdirSync(out) + + const command = await wrap([PROJ], [], [AREA, out]) + + expect(command).toContain(`--ro-bind ${PROJ} ${PROJ}`) + expect(command).not.toContain( + `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`, + ) + // The nested allow is bound before the covering bind buries it, and + // nothing re-binds it afterwards. + const projBind = command.lastIndexOf(`--ro-bind ${PROJ} ${PROJ}`) + expect(command.indexOf(`--bind ${out} ${out}`)).toBeLessThan(projBind) + expect(command.indexOf(`--bind ${out} ${out}`, projBind)).toBe(-1) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'boots with a buried nested allow, and both the dotfile and the nested allow stay unwritable', + async () => { + // The runtime half of the test above: bwrap starts (no startup abort), + // the absent dotfile is uncreatable, the nested allow is unwritable too + // — the covering bind buries it, which is what makes skipping the stub + // sound — and the rest of the allowed area is still writable. + process.chdir(PROJ) + const out = join(PROJ, 'out') + mkdirSync(out) + const control = join(AREA, 'control.txt') + + const command = await wrap( + [PROJ], + [], + [AREA, out], + `touch ${join(PROJ, '.gitconfig')} 2>/dev/null || echo NO-DOTFILE; ` + + `touch ${join(out, 'x')} 2>/dev/null || echo NO-NESTED-ALLOW; ` + + `touch ${control} 2>/dev/null && echo AREA-WRITABLE`, + ) + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 15000, + cwd: PROJ, + }) + + // A kept stub aborts bwrap here, before the command runs at all. + expect(run.stderr ?? '').not.toMatch(/Read-only file system/i) + expect(run.stdout).toBe('NO-DOTFILE\nNO-NESTED-ALLOW\nAREA-WRITABLE\n') + expect(existsSync(join(PROJ, '.gitconfig'))).toBe(false) + expect(existsSync(join(out, 'x'))).toBe(false) + expect(existsSync(control)).toBe(true) + }, + ) + it('keeps the stub (fails closed) when an allowed write path beneath the denied dir is re-opened by denyRead', async () => { // The one shape where "the ancestor is under a read-only deny" is not // reliable: a denyRead directory inside the write-denied dir plus an From c618b1794fde2b7520b626cdd24cf2fcb67498cb Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 12:00:35 +0000 Subject: [PATCH 16/23] fix(linux): read a deny path named with glob characters and spelled with a trailing slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePathForSandbox keeps a trailing slash on any spelling it takes for a glob, so a literal path named with glob characters — a route directory such as `[id]`, or a file such as `[id].env` — reaches the read section spelled `/`. stat() of `/` is ENOTDIR, which the target derivation read as "the entry is not there" and dropped the deny outright; the directory form entered the tmpfs prediction as `

/`, a spelling no `/` prefix test can match. Non-glob spellings were already handled, so the failure was silent and limited to this one shape. The slash is dropped where the read section turns an entry into a mount target, which is also the single place that decides whether the target stands in for an entry it could not inspect — the stand-in verdict now comes from there rather than from comparing against the raw entry. Also drops the second trailing-slash strip in the write-allow loop, which compared a realpath against an already-stripped spelling and could not differ, and says what the remaining one is for: not the non-glob spellings normalizePathForSandbox handles, but the glob-exempt ones it does not. --- src/sandbox/linux-sandbox-utils.ts | 42 +++++++++++--------- test/sandbox/readonly-deny-dir-stubs.test.ts | 35 ++++++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 18e5148a5..d1b244362 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -996,16 +996,24 @@ class SandboxMountView { * What the read section mounts for one denyRead entry: the entry itself, or, * when it cannot be inspected (a parent that is readable but not searchable * hides whether it exists), the nearest ancestor that can, standing in for - * it. Undefined when the entry is not there. + * it (`isStandIn`). Undefined when the entry is not there. + * + * The trailing slash is dropped first. normalizePathForSandbox keeps one on + * any spelling it takes for a glob, so a literal path named with glob + * characters arrives with it — and stat() of '/' is ENOTDIR, which + * reads as "not there" and silently drops the deny, while '/' would + * enter the prediction in a spelling no `/` prefix test can match. */ function readDenyTargetOf( entry: string, -): { path: string; isDirectory: boolean } | undefined { - for (let candidate = entry; ; candidate = path.dirname(candidate)) { +): { path: string; isDirectory: boolean; isStandIn: boolean } | undefined { + const named = entry.replace(/\/+$/, '') || '/' + for (let candidate = named; ; candidate = path.dirname(candidate)) { try { return { path: candidate, isDirectory: fs.statSync(candidate).isDirectory(), + isStandIn: candidate !== named, } } catch (err) { if (isAbsenceErrno(err) || candidate === '/') return undefined @@ -1033,7 +1041,7 @@ function readDenyUnitsOf( ) continue } - const isStandIn = target.path !== entry + const isStandIn = target.isStandIn if (isStandIn) { logForDebugging( `[Sandbox Linux] Read deny path ${entry} cannot be inspected; hiding ${target.path} instead`, @@ -1215,15 +1223,17 @@ async function generateFilesystemArgs( // Allow writes to specific paths for (const pathPattern of writeConfig.allowOnly || []) { - // Trailing slashes are stripped HERE, at the single point where allow - // paths are bound and recorded, because every downstream comparison — - // the deny loop's within-allowlist gate, findSymlinkInPath's mask - // scoping, the emission filter's re-expose check, the denyRead - // re-bind and its allowRead skip, and the stub-skip vetoes — matches - // by `allowedPath + '/'` prefix, which a preserved trailing slash - // ('//') silently defeats. bwrap binds 'dir' and 'dir/' - // identically, so normalizing the recorded spelling fixes every - // consumer at once instead of per-predicate. ('/' itself is kept.) + // normalizePathForSandbox already strips a trailing slash from every + // spelling it does not take for a glob; this strip covers the ones it + // exempts — a literal directory named with glob characters, spelled + // '/[id]/'. Allow paths are recorded slash-free because every + // downstream comparison — the deny loop's within-allowlist gate, + // findSymlinkInPath's mask scoping, the emission filter's re-expose + // check, the denyRead re-bind and its allowRead skip, and the stub-skip + // vetoes — matches by `allowedPath + '/'` prefix, which '//' + // silently defeats. bwrap binds 'dir' and 'dir/' identically, so + // normalizing the recorded spelling fixes every consumer at once + // instead of per-predicate. ('/' itself is kept.) const normalizedPath = normalizePathForSandbox(pathPattern).replace(/\/+$/, '') || '/' @@ -1249,12 +1259,8 @@ async function generateFilesystemArgs( // This could unexpectedly expose paths the user didn't intend to allow try { const resolvedPath = fs.realpathSync(normalizedPath) - // Trim trailing slashes before comparing: realpathSync never returns - // a trailing slash, but normalizedPath may have one, which would cause - // a false mismatch and incorrectly treat the path as a symlink. - const normalizedForComparison = normalizedPath.replace(/\/+$/, '') if ( - resolvedPath !== normalizedForComparison && + resolvedPath !== normalizedPath && isSymlinkOutsideBoundary(normalizedPath, resolvedPath) ) { logForDebugging( diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index 9b3c817c1..52ddda564 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -563,6 +563,41 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { }, ) + it('enforces denyWithinAllow under a glob-character trailing-slash allowOnly spelling', async () => { + // normalizePathForSandbox leaves the trailing slash on any spelling it + // takes for a glob, so a literal directory named with glob characters is + // the one shape the allow loop's own strip still has to handle: recorded + // as '/', it defeats every `allowedPath + '/'` comparison — starting + // with the gate that decides whether a deny is inside the allowlist at + // all, which would drop the deny and leave the tree writable. + const area = join(BASE, '[id]') + const secrets = join(area, 'secrets') + mkdirSync(secrets, { recursive: true }) + writeFileSync(join(secrets, 'token.txt'), 'x\n') + + const command = await wrap([secrets], [], [`${area}/`]) + + expect(command).toContain(`--bind '${area}' '${area}'`) + expect(command).toContain(`--ro-bind '${secrets}' '${secrets}'`) + }) + + it('denies a glob-character read-deny spelled with a trailing slash', async () => { + // Same exemption on the read side. stat() of '/' is ENOTDIR, which + // read as "the entry is not there" and dropped the deny outright — + // silently, and only for this spelling; the directory form entered the + // tmpfs prediction as '/', which no '/' prefix test can match. + const secretFile = join(PROJ, '[id].env') + writeFileSync(secretFile, 'SECRET=1\n') + const secretDir = join(PROJ, '[id]') + mkdirSync(secretDir) + writeFileSync(join(secretDir, 'token.txt'), 'x\n') + + const command = await wrap([], [`${secretFile}/`, `${secretDir}/`]) + + expect(command).toContain(`--ro-bind /dev/null '${secretFile}'`) + expect(command).toContain(`--tmpfs '${secretDir}'`) + }) + it('re-applies a denyWithinAllow bind under a trailing-slash allow re-bound over a denyRead tmpfs', async () => { // The emission filter drops deny binds hidden by a denyRead tmpfs // UNLESS a write re-bind re-exposes them (reExposedByWriteBind). That From 856895bb312368e6092e253ea5b1136454cec3fe Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 15:49:18 +0000 Subject: [PATCH 17/23] test(linux): pin what a read-deny glob reaches behind a directory link The walk descends symlinked directories and reports each match where it really lives, so one mount covers a file whichever spelling found it. These are the cases a walk that does not follow links loses outright, and the ones where the mount must land on the target rather than on the link: a pattern whose own wildcard segment names the link, a tail match below it, a link whose target is outside the pattern's base (relative and absolute, including a package linked out of node_modules), a link to its own directory and two links into each other, and a build directory that is a link or sits behind one. A live arm drives the three link-named patterns through bubblewrap and reads the file by both spellings. Also replaces the local `bwrap --version` probe with the memoised bwrapCanNamespace helper, which checks the namespace surface the wrapped commands actually use. --- test/sandbox/read-deny-glob.test.ts | 183 +++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 3ae1fc8b0..845796a56 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -20,6 +20,7 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { let ROOT: string @@ -402,7 +403,7 @@ describe.if(isLinux)( let real: string let link: string const savedCwd = process.cwd() - const hasBwrap = spawnSync('bwrap', ['--version']).status === 0 + const hasBwrap = bwrapCanNamespace() beforeAll(() => { ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bwrap-'))) @@ -1309,3 +1310,183 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { } }) }) + +describe.if(isLinux)( + 'expandReadDenyGlobLinux (coverage behind a directory link)', + () => { + // The walk descends symlinked directories and reports each match where it + // really lives, so a deny glob covers what it matches whichever spelling + // found it, and one mount stands for every spelling. These cases are the + // ones a walk that does not follow links loses outright. + let ROOT: string + let BASE: string + let LNK: string + let ABS: string + let REALKEY: string + const CAN_RUN = bwrapCanNamespace() + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-links-'))) + // A link named by the pattern's own wildcard segment. + BASE = join(ROOT, 'base') + mkdirSync(join(BASE, 'real', 'cfg'), { recursive: true }) + REALKEY = join(BASE, 'real', 'cfg', 'key.txt') + writeFileSync(REALKEY, 'KEYBYTES\n') + LNK = join(BASE, 'lnk') + symlinkSync(join('real', 'cfg'), LNK) + ABS = join(BASE, 'abs') + symlinkSync(join(BASE, 'real', 'cfg'), ABS) + // A link whose target is outside the pattern's base directory. + mkdirSync(join(ROOT, 'out', 'config', 'dev'), { recursive: true }) + mkdirSync(join(ROOT, 'out', 'shared', 'prod'), { recursive: true }) + writeFileSync(join(ROOT, 'out', 'config', 'dev', 'key'), 'DEVKEY\n') + writeFileSync(join(ROOT, 'out', 'shared', 'prod', 'key'), 'PRODKEY\n') + symlinkSync( + join('..', 'shared', 'prod'), + join(ROOT, 'out', 'config', 'prod'), + ) + // The same, with an absolute target. + mkdirSync(join(ROOT, 'absout', 'config'), { recursive: true }) + mkdirSync(join(ROOT, 'absout', 'elsewhere', 'prod'), { recursive: true }) + writeFileSync(join(ROOT, 'absout', 'elsewhere', 'prod', 'key'), 'ABS\n') + symlinkSync( + join(ROOT, 'absout', 'elsewhere', 'prod'), + join(ROOT, 'absout', 'config', 'prod'), + ) + // A package linked out of node_modules, as a workspace install leaves it. + mkdirSync(join(ROOT, 'store', 'node_modules', 'other'), { + recursive: true, + }) + mkdirSync(join(ROOT, 'store', 'packages', 'pkg'), { recursive: true }) + writeFileSync( + join(ROOT, 'store', 'node_modules', 'other', 'index.js'), + 'OTHER\n', + ) + writeFileSync(join(ROOT, 'store', 'packages', 'pkg', 'index.js'), 'PKG\n') + symlinkSync( + join('..', 'packages', 'pkg'), + join(ROOT, 'store', 'node_modules', 'pkg'), + ) + // A link to its own directory, and two links into each other. + mkdirSync(join(ROOT, 'cycles', 'a'), { recursive: true }) + mkdirSync(join(ROOT, 'cycles', 'b'), { recursive: true }) + writeFileSync(join(ROOT, 'cycles', 'a', 'key.txt'), 'AKEY\n') + writeFileSync(join(ROOT, 'cycles', 'b', 'key.txt'), 'BKEY\n') + symlinkSync('.', join(ROOT, 'cycles', 'self')) + symlinkSync(join('..', 'b'), join(ROOT, 'cycles', 'a', 'l1')) + symlinkSync(join('..', 'a'), join(ROOT, 'cycles', 'b', 'l2')) + // build/ as a link, and a build/ directory behind a link. + mkdirSync(join(ROOT, 'builds', 'pkg1', 'build'), { recursive: true }) + mkdirSync(join(ROOT, 'builds', 'linked'), { recursive: true }) + mkdirSync(join(ROOT, 'builds', 'pkg2'), { recursive: true }) + mkdirSync(join(ROOT, 'builds', 'pkg3'), { recursive: true }) + mkdirSync(join(ROOT, 'builds', 'realpkg', 'build'), { recursive: true }) + writeFileSync(join(ROOT, 'builds', 'pkg1', 'build', 'f'), 'ONE\n') + writeFileSync(join(ROOT, 'builds', 'linked', 'f'), 'LINKED\n') + writeFileSync(join(ROOT, 'builds', 'realpkg', 'build', 'f'), 'BEHIND\n') + symlinkSync(join('..', 'linked'), join(ROOT, 'builds', 'pkg2', 'build')) + symlinkSync(join('..', 'realpkg'), join(ROOT, 'builds', 'pkg3', 'lnk')) + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + it.each([ + ['the wildcard segment names the link', 'l*/key.txt'], + ['a bare star names the link', '*/key.txt'], + ['a globstar reaches the link by name', '**/lnk/key.txt'], + ['a globstar tail matches below the link', '**/key.txt'], + ['the tail names the target directory', '**/cfg/key.txt'], + ['the pattern spans depths through the link', 'l*/**/key.txt'], + ['the link has an absolute target inside the base', 'a*/key.txt'], + ])('covers the file behind a directory link when %s', (_why, tail) => { + expect(expandReadDenyGlobLinux(join(BASE, tail), [])).toEqual([REALKEY]) + }) + + it('mounts a match behind a link whose target is outside the pattern base, at its target', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'out', 'config', '*', 'key'), + [], + ) + + expect(mounts).toEqual([ + join(ROOT, 'out', 'config', 'dev', 'key'), + join(ROOT, 'out', 'shared', 'prod', 'key'), + ]) + }) + + it('mounts a match behind an absolute-target link outside the base, at its target', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'absout', 'config', '*', 'key'), + [], + ) + + expect(mounts).toEqual([join(ROOT, 'absout', 'elsewhere', 'prod', 'key')]) + }) + + it('mounts a package linked out of node_modules where the package really is', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'store', 'node_modules', '**', 'index.js'), + [], + ) + + expect(mounts).toEqual([ + join(ROOT, 'store', 'node_modules', 'other', 'index.js'), + join(ROOT, 'store', 'packages', 'pkg', 'index.js'), + ]) + }) + + it('terminates on a link to its own directory and on two links into each other', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'cycles', '**', 'key.txt'), + [], + ) + + expect(mounts).toEqual([ + join(ROOT, 'cycles', 'a', 'key.txt'), + join(ROOT, 'cycles', 'b', 'key.txt'), + ]) + }) + + it('collapses a linked build directory, and one behind a link, onto their targets', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'builds', '**', 'build', '**'), + [], + ) + + expect(mounts).toEqual([ + join(ROOT, 'builds', 'linked'), + join(ROOT, 'builds', 'pkg1', 'build'), + join(ROOT, 'builds', 'realpkg', 'build'), + ]) + }) + + it.skipIf(!CAN_RUN)( + 'serves the file through neither spelling for a pattern that names the link', + async () => { + for (const tail of ['l*/key.txt', '*/key.txt', '**/lnk/key.txt']) { + const wrapped = await wrapCommandWithSandboxLinux({ + command: `sh -c 'echo BOOTED; cat ${join(LNK, 'key.txt')} 2>&1; cat ${REALKEY} 2>&1; cat ${join(ABS, 'key.txt')} 2>&1'`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(BASE, tail), []), + allowWithinDeny: [], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + allowAllUnixSockets: true, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).not.toContain('KEYBYTES') + } + }, + ) + }, +) From 36f53f47ddc0f98a8bf0e32519ed286059d36d3d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 15:49:52 +0000 Subject: [PATCH 18/23] test(linux): cover the collapse against the passes the merge brought in The tmpfs mounts a collapsed read-deny glob adds are ordinary read-deny units, so the passes #514 and #537 added have to treat them like any other: the directories between one and the allowed write root are pinned (renaming the package directory above a collapsed build/ fails with EBUSY under real bubblewrap); a write deny inside a collapsed directory, dropped as hidden by its tmpfs, puts the allowed write path back read-only; a masked file inside one is left to its own mask rather than re-bound above it; and the placeholder skip that the narrowed veto allows for such a profile is kept only while the read-deny prediction can be derived at all. Also the review follow-ups this push owes: the start-up abort arm now tells bwrap's refusal from touch's own error (a payload that ran leaves RAN on stdout), the covering-directory comments say what the fixtures really pin (a vetoed directory disqualifies the skip for every candidate beneath it), the trailing-slash comments say that a non-glob spelling already arrives slash-free and name the glob-character spelling the strip is still for, and the newly enabled drop of a per-path deny under a recorded '/' gains the runtime arm it had only at argument level. --- src/sandbox/linux-sandbox-utils.ts | 20 +-- test/sandbox/read-deny-glob.test.ts | 130 +++++++++++++++++++ test/sandbox/readonly-deny-dir-binds.test.ts | 27 ++++ test/sandbox/readonly-deny-dir-stubs.test.ts | 44 ++++++- 4 files changed, 211 insertions(+), 10 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 2ced3b21c..786332947 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1498,15 +1498,17 @@ async function generateFilesystemArgs( // Allow writes to specific paths for (const pathPattern of writeConfig.allowOnly || []) { - // Trailing slashes are stripped HERE, at the single point where allow - // paths are bound and recorded, because every downstream comparison — - // the deny loop's within-allowlist gate, findSymlinkInPath's mask - // scoping, the emission filter's re-expose check, the denyRead - // re-bind and its allowRead skip, and the stub-skip vetoes — matches - // by `allowedPath + '/'` prefix, which a preserved trailing slash - // ('//') silently defeats. bwrap binds 'dir' and 'dir/' - // identically, so normalizing the recorded spelling fixes every - // consumer at once instead of per-predicate. ('/' itself is kept.) + // normalizePathForSandbox already strips a trailing slash from every + // spelling it does not take for a glob; this strip covers the ones it + // exempts — a literal directory named with glob characters, spelled + // '/[id]/'. Allow paths are recorded slash-free because every + // downstream comparison — the deny loop's within-allowlist gate, + // findSymlinkInPath's mask scoping, the emission filter's re-expose + // check, the denyRead re-bind and its allowRead skip, and the stub-skip + // vetoes — matches by `allowedPath + '/'` prefix, which '//' + // silently defeats. bwrap binds 'dir' and 'dir/' identically, so + // normalizing the recorded spelling fixes every consumer at once + // instead of per-predicate. ('/' itself is kept.) const normalizedPath = normalizePathForSandbox(pathPattern).replace(/\/+$/, '') || '/' diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 845796a56..8bfa6d1e5 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -1273,6 +1274,135 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { } }) + it('seeds the ancestor pins from a collapsed directory, and pins hold at runtime', async () => { + // Every tmpfs the collapse adds is an ordinary read-deny unit, so the + // directories between it and the allowed write root are pinned: the + // package directory above a collapsed build/ cannot be renamed aside to + // strip the deny off it. + const pkgDir = join(ROOT, 'pkg', 'a') + const build = join(pkgDir, 'build') + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []), + allowWithinDeny: [], + }, + writeConfig: { allowOnly: [ROOT], denyWithinAllow: [] }, + }) + + const pin = `--ro-bind ${pkgDir} ${pkgDir}` + expect(wrapped).toContain(pin) + // Beneath the write root's own bind, and so beneath the tmpfs too. + expect(wrapped.indexOf(pin)).toBeLessThan( + wrapped.indexOf(`--bind ${ROOT} ${ROOT}`), + ) + expect(wrapped).toContain(`--tmpfs ${build}`) + }) + + it.skipIf(!bwrapCanNamespace())( + 'refuses to rename the package directory above a collapsed build directory', + async () => { + const pkgDir = join(ROOT, 'pkg', 'b') + const wrapped = await wrapCommandWithSandboxLinux({ + command: `sh -c 'echo BOOTED; mv ${pkgDir} ${pkgDir}-moved 2>&1; echo DONE'`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []), + allowWithinDeny: [], + }, + writeConfig: { allowOnly: [ROOT], denyWithinAllow: [] }, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).toMatch(/busy/i) + expect(existsSync(`${pkgDir}-moved`)).toBe(false) + }, + ) + + it('restores a write path read-only when a write deny inside a collapsed directory is dropped', async () => { + // The collapsed tmpfs hides the write deny's own destination, so that + // bind is dropped rather than re-exposing the read-denied directory + // around it — and the allowed write path the tmpfs restored beneath that + // destination comes back read-only, where the deny leaves it. + const build = join(ROOT, 'pkg', 'c', 'build') + const denied = join(build, 'nested') + const writable = join(denied, 'out') + mkdirSync(writable, { recursive: true }) + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(ROOT, '**/build/**'), []), + allowWithinDeny: [], + }, + writeConfig: { + allowOnly: [ROOT, writable], + denyWithinAllow: [denied], + }, + }) + + const tmpfs = wrapped.lastIndexOf(`--tmpfs ${build}`) + expect(tmpfs).toBeGreaterThan(-1) + // The deny's own bind is dropped (its ancestor pin, spelled the same, + // sits beneath the write root's bind, so only what follows the tmpfs + // counts). + expect(wrapped.indexOf(`--ro-bind ${denied} ${denied}`, tmpfs)).toBe(-1) + // The tmpfs put the write path back writable; the dropped bind puts it + // back read-only on top, which is where the deny leaves it. + const writableBind = wrapped.indexOf( + `--bind ${writable} ${writable}`, + tmpfs, + ) + const readOnlyRestore = wrapped.lastIndexOf( + `--ro-bind ${writable} ${writable}`, + ) + expect(writableBind).toBeGreaterThan(tmpfs) + expect(readOnlyRestore).toBeGreaterThan(writableBind) + }) + + it('leaves a masked file inside a collapsed directory to its mask', async () => { + // The dropped write-deny bind restores what the tmpfs put back beneath + // it, but never a masked file: binding the real file read-only there + // would land above its mask and serve the real bytes. + const build = join(ROOT, 'pkg', 'a', 'build') + const secret = join(build, 'cred.json') + writeFileSync(secret, 'REAL-CREDENTIAL\n') + const store = mkdtempSync(join(tmpdir(), 'deny-glob-store-')) + const fake = join(store, 'cred.json.fake') + writeFileSync(fake, 'SENTINEL\n') + try { + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(ROOT, '**/build/**'), [ + secret, + ]), + allowWithinDeny: [], + }, + writeConfig: { allowOnly: [ROOT, secret], denyWithinAllow: [build] }, + maskedFileBinds: [{ realPath: secret, fakePath: fake }], + maskedFileStoreDir: store, + }) + + expect(wrapped).toContain(`--ro-bind ${fake} ${secret}`) + expect(wrapped).not.toContain(`--ro-bind ${secret} ${secret}`) + const mask = wrapped.lastIndexOf(`--ro-bind ${fake} ${secret}`) + expect(wrapped.indexOf(`--bind ${secret} ${secret}`, mask)).toBe(-1) + } finally { + rmSync(store, { recursive: true, force: true }) + rmSync(secret, { force: true }) + } + }) + 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') diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index 614fca2bf..07b934a65 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -394,6 +394,33 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { ) }) + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'holds the whole tree read-only when the per-path denies under "/" are dropped', + async () => { + // The runtime half of the drop above, which was argv-only: with PROJ's + // own bind gone, the root deny's read-only bind is all that stands + // between the command and the allowed write area, and the denied file + // must still read (it is read-only, not masked). + const result = run( + await wrapCommandWithSandboxLinux({ + command: `sh -c 'echo BOOTED; touch ${join(AREA, 'x')} 2>/dev/null || echo AREA-READONLY; cat ${FILE}'`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { + allowOnly: ['/', AREA], + denyWithinAllow: ['/', PROJ], + }, + }), + ) + + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('AREA-READONLY') + expect(result.stdout).toContain('{}') + expect(existsSync(join(AREA, 'x'))).toBe(false) + }, + ) + it('does not re-apply a tmpfs over the bind that denies the same directory', async () => { // X in allowOnly, denyWithinAllow and denyRead: the read-only bind of X // is not "an ancestor that re-exposes X", so no --tmpfs X --bind X X may diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index a952cdcac..a092a5bb7 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -276,12 +276,15 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { [PROJ, absentDeny], [readDenied], [AREA, nestedAllow], - `touch ${absentDeny}`, + `sh -c 'echo RAN; touch ${absentDeny}'`, ), { shell: true, encoding: 'utf8', timeout: 15000, cwd: PROJ }, ) expect(run.status).not.toBe(0) + // bwrap refused to start, so the payload never ran: the same message + // from touch itself would leave RAN on stdout. + expect(run.stdout).toBe('') expect(run.stderr ?? '').toMatch(/Read-only file system/i) expect(existsSync(absentDeny)).toBe(false) }, @@ -961,6 +964,45 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { ) }) + it('skips the stubs for a collapsed read-deny glob inside the write-denied cwd, and keeps them when the prediction is unusable', async () => { + // The shape the narrowed veto exists for: a denyRead pattern such as + // `**/build/**` collapses to a tmpfs strictly inside the write-denied + // checkout. With nothing writable configured under that checkout the + // covering bind is the last word and the absent dotfile denies need no + // stub — the old veto on any tmpfs beneath the dir aborted every command + // of such a profile. Only an unusable prediction keeps them, because a + // prediction that failed is no evidence about this directory at all. + process.chdir(PROJ) + const build = join(PROJ, 'pkg', 'build') + mkdirSync(build, { recursive: true }) + writeFileSync(join(build, 'out.o'), '') + const stub = `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}` + + const usable = await wrap([PROJ], [build]) + expect(usable).toContain(`--tmpfs ${build}`) + expect(usable).not.toContain(stub) + + const realRealpathSync = fs.realpathSync + using spy = spyOn(fs, 'realpathSync').mockImplementation((( + p: fs.PathLike, + ...rest: unknown[] + ) => { + if (String(p) === build) { + throw Object.assign(new Error('EACCES: cannot resolve'), { + code: 'EACCES', + }) + } + return (realRealpathSync as (...a: unknown[]) => unknown)(p, ...rest) + }) as typeof fs.realpathSync) + const { result: unusable, warnings } = await withCapturedWarnings(() => + wrap([PROJ], [build]), + ) + expect(spy).toHaveBeenCalled() + + expect(warnings.join('\n')).toContain('Read-deny prediction unusable') + expect(unusable).toContain(stub) + }) + it('keeps the stubs when a root child cannot be looked at', async () => { // The other direction: a location that exists but cannot be resolved is // a guess about the prediction's own inputs, so the prediction is From 85221dcd68e2950311457a630bf67d3261103ff7 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 18:18:37 +0000 Subject: [PATCH 19/23] Linux: keep a read deny the glob walk cannot resolve, and bound the walk A glob-matched symlink whose target is there but cannot be looked at (EACCES, EPERM, EIO) was dropped as "does not resolve", so no mount was emitted for it at all: a command that made the target's directory unsearchable had the file readable again through the same name from inside the next sandbox. The walk now classifies the errno the way the mount loop does. Absence keeps the skip; anything else reaches the loop under the link's own spelling, where the stand-in rule hides the nearest directory that can be inspected. The existence pre-check on a pattern's base directory answered false for every errno too, so a base whose ancestor is not searchable expanded to nothing and the deny vanished. It is deleted: the listing's own error path already tells an absent directory from one that has to be covered whole. Nothing bounded how many names the walk followed for one directory, either. n directories linking to each other cost about e*n! frames, per wrapped command and per pattern, for a tree a sandboxed command with write access under the base can plant: 986,409 spellings and 24s for n=9. A directory is now walked under at most eight names per walk, and a further name is recorded as unlisted, which a deny covers whole rather than losing (72 spellings and 2ms for the same tree). Listings are cached per real directory, successes only: a failure can belong to the route (ELOOP, ENAMETOOLONG) rather than to the directory, and would otherwise answer for every other name of it. Descending symlinked directories becomes an option only the read-deny expansion sets. The allowRead expansion shares this walk and had started following links out of the tree, so allowRead ["~/**"] with ~/mnt -> /usr returned every path under /usr as a carve-out; it takes the link itself again, as the Windows path expansion does. Finally, a warning when a matched link makes a read deny land outside the base directory of the pattern that produced it: one link then decides what is hidden for the whole sandbox. --- src/sandbox/read-deny-glob.ts | 128 +++++++++++--- src/sandbox/sandbox-utils.ts | 289 ++++++++++++++++++++----------- test/sandbox/glob-expand.test.ts | 249 +++++++++++++++++++++++--- 3 files changed, 516 insertions(+), 150 deletions(-) diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index f1d02fa64..419b02b21 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,5 +1,6 @@ import { logForDebugging } from '../utils/debug.js' import { + isAtOrUnder, normalizePathForSandbox, pathSpellings, properAncestors, @@ -13,13 +14,16 @@ import { */ const READ_DENY_GLOB_MOUNT_WARN_THRESHOLD = 256 +/** The directory holding `p`, or '' for a root child. */ +function parentOf(p: string): string { + return p.slice(0, p.lastIndexOf('/')) +} + /** * Reduce the places a read-deny glob's matches really live to the ones whose * mount changes what the sandbox can read. A location is dropped only when a * kept proper ancestor's tmpfs already hides it and no re-exposer sits at the - * ancestor or between the two. The denyRead loop mounts each entry where it - * really lives, so this is decided there too: the spelled parent of a match - * reached through a symlink need not contain it. + * ancestor or between the two. */ function collapseReadDenyLocations({ locations, @@ -36,11 +40,11 @@ function collapseReadDenyLocations({ const sorted = [...new Set(locations)].sort() const kept = new Set() for (const location of sorted) { - // A re-exposer at the kept ancestor counts: the deny loop binds it back - // over the tmpfs, so everything beneath needs its own mount. let reExposedBetween = reExposedPaths.has(location) let hidden = false for (const ancestor of properAncestors(location)) { + // A re-exposer at the kept ancestor counts: the deny loop binds it back + // over the tmpfs, so everything beneath needs its own mount. if (reExposedPaths.has(ancestor)) reExposedBetween = true if (kept.has(ancestor)) { hidden = true @@ -60,54 +64,96 @@ function collapseReadDenyLocations({ * through a symlink is listed where it really lives, and a directory the walk * could not list is denied whole. Sorted, so an ancestor precedes its * descendants. + * + * @param unlistableDirs - receives the returned locations that hide something + * the walk could not enumerate, whether by being that directory or by + * covering it. The Linux wrapper binds nothing back beneath one: what the + * pattern matches under an allowed path in there was never found, and would + * come back unmasked. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], + unlistableDirs?: Set, ): string[] { - const walk = walkGlobPattern(globPattern, { withDirectoryForm: true }) + const walk = walkGlobPattern(globPattern, { + withDirectoryForm: true, + followSymlinkedDirectories: true, + }) + // Where a path the walk reported really lives: the denyRead loop mounts an + // entry there, whatever spelling named it. + const locationOf = (p: string): string => walk.realOf.get(p) ?? p // An unlisted directory hides whatever the pattern matches beneath it. const candidates = new Set([...walk.matches, ...walk.unlisted]) 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( - walk.matches.map(m => m.slice(0, m.lastIndexOf('/'))), - ) + // pattern ends in /**), so a directory with something to deny holds one. + // An empty one gets no mount: it has nothing to deny, and as a tmpfs it + // would swallow later writes. Compared where they live, since one + // spelling of a directory is walked and the matches found through it are + // reported at their real locations. + const holdMatches = new Set(walk.matches.map(m => parentOf(locationOf(m)))) for (const dir of walk.directoryMatches) { // A directory-form match that is a symlink counts in its own right: - // one the walk did not descend (a link back into its own ancestry) - // has no match beneath it, yet denies everything it reaches. - if (parents.has(dir) || walk.symlinks.has(dir)) candidates.add(dir) + // one the walk did not list through (a link back into its own + // ancestry, or a second name for a directory already walked) has no + // match beneath it, yet denies everything it reaches. + if (holdMatches.has(locationOf(dir)) || walk.symlinks.has(dir)) { + candidates.add(dir) + } } } - // Where each candidate really lives: the denyRead loop mounts an entry - // there, whatever spelling named it. const locations = new Set() + /** Which spelling first put a location in the list, for the warning below. */ + const namedBy = new Map() + const addLocation = (location: string, candidate: string): void => { + locations.add(location) + if (!namedBy.has(location)) namedBy.set(location, candidate) + } for (const candidate of candidates) { - const location = walk.realOf.get(candidate) ?? candidate if (walk.symlinks.has(candidate) && !walk.realOf.has(candidate)) { - // A link that resolves to nothing denies nothing, and bwrap cannot - // mount on the link itself. + if (!walk.uninspectableLinks.has(candidate)) { + // A link that resolves to nothing denies nothing, and bwrap cannot + // mount on the link itself. + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} does not resolve, skipping`, + ) + continue + } + // A link whose target is there but cannot be looked at: kept under its + // own spelling, where the denyRead loop's stand-in rule hides the + // nearest directory above it that can be inspected. logForDebugging( - `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} does not resolve, skipping`, + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} leads somewhere that cannot be inspected; denying what holds it`, + { level: 'warn' }, ) + addLocation(candidate, candidate) + continue + } + const location = locationOf(candidate) + if (location !== '/') { + addLocation(location, candidate) continue } - if (location === '/') { - // A tmpfs over the root would hide everything, and one on the link is - // refused by bwrap: every later command would fail to start for as - // long as the link exists. + // A link to the root. A tmpfs there would wipe every mount placed before + // it and the pivot would promote it, booting the command on an empty + // tree, and bwrap cannot mount on the link itself. The nearest directory + // above the link stands in for it, as for an entry that cannot be + // inspected — never the root itself. + const standIn = locationOf(parentOf(candidate)) + if (standIn === '' || standIn === '/') { logForDebugging( - `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} resolves to /, skipping`, + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} resolves to / and nothing but / holds it, skipping`, { level: 'warn' }, ) continue } - locations.add(location) + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}": ${candidate} resolves to /; denying ${standIn}, which holds it, instead`, + { level: 'warn' }, + ) + addLocation(standIn, candidate) } const reExposed = new Set( @@ -118,9 +164,37 @@ export function expandReadDenyGlobLinux( reExposedPaths: reExposed, }) + // Which mounts stand for something the walk could not enumerate: the + // unlistable directory itself when it survived the collapse, otherwise the + // kept ancestor that hides it. + for (const unlisted of walk.unlisted) { + const location = locationOf(unlisted) + if (mounts.has(location)) { + unlistableDirs?.add(location) + continue + } + for (const ancestor of properAncestors(location)) { + if (mounts.has(ancestor)) { + unlistableDirs?.add(ancestor) + break + } + } + } + logForDebugging( `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.size} mounts`, ) + for (const mount of mounts) { + // A matched link decides what is hidden for the whole sandbox: a + // `certs/*` entry pointing at a database directory mounts a tmpfs over + // that directory, not over anything the pattern names. + if (walk.baseLocation !== '' && !isAtOrUnder(mount, walk.baseLocation)) { + logForDebugging( + `[Sandbox Linux] denyRead glob "${globPattern}" hides ${mount}, outside ${walk.baseLocation}: reached through ${namedBy.get(mount)}`, + { level: 'warn' }, + ) + } + } if (mounts.size > READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) { logForDebugging( `[Sandbox Linux] denyRead glob "${globPattern}" still needs ${mounts.size} mounts after collapsing ` + diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index d3888d0bd..00a182e7d 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -66,33 +66,44 @@ export function isStrictlyUnder(p: string, dir: string): boolean { } /** The proper ancestors of an absolute POSIX path, nearest first, ending at '/'. */ -export function* properAncestors(p: string): Generator { +export function* properAncestors(absolutePath: string): Generator { for ( - let slash = p.lastIndexOf('/'); + let slash = absolutePath.lastIndexOf('/'); slash > 0; - slash = p.lastIndexOf('/', slash - 1) + slash = absolutePath.lastIndexOf('/', slash - 1) ) { - yield p.slice(0, slash) + yield absolutePath.slice(0, slash) } - if (p !== '/') yield '/' + if (absolutePath !== '/') yield '/' } -/** A path as spelled and, when that differs, with every symlink resolved. */ -export type PathSpellings = readonly [string] | readonly [string, string] - /** - * The spellings that name `p` for a mount comparison. The spelling alone when - * `p` cannot be resolved (dangling, vanished) or resolves to '/': a mount - * over the root would hide everything. + * The spellings that name `candidatePath` for a mount comparison: as spelled, + * plus with every symlink resolved when that differs. A path that cannot be + * resolved (dangling, vanished) is named by its spelling alone. */ -export function pathSpellings(p: string): PathSpellings { +export function pathSpellings(candidatePath: string): string[] { try { - const resolved = fs.realpathSync(p) - if (resolved !== p && resolved !== '/') return [p, resolved] + const resolved = fs.realpathSync(candidatePath) + if (resolved !== candidatePath) return [candidatePath, resolved] } catch { // Dangling or vanished: only the spelling names it. } - return [p] + return [candidatePath] +} + +/** An fs error that means the name resolves to no file — it is missing, or + * the path cannot name one at all — as opposed to one that means a file is + * there but could not be looked at (EACCES, EPERM, EIO, anything + * unrecognised). */ +export function isAbsenceErrno(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return ( + code === 'ENOENT' || + code === 'ENOTDIR' || + code === 'ELOOP' || + code === 'ENAMETOOLONG' + ) } /** @@ -932,15 +943,23 @@ export interface ExpandGlobOptions { /** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */ export interface GlobWalk { + /** Where the walk started, with symlinks resolved (the spelling itself + * when it could not be resolved); '' when the pattern had no literal + * directory to start from. */ + baseLocation: string /** Absolute paths matching the pattern. */ matches: string[] /** With `withDirectoryForm`: directories (a symlink to one included) * matching the pattern without its trailing `/**`. */ directoryMatches: string[] - /** Every visited entry that is a symbolic link, by full path. The walk - * descends into symlinked directories, so a match beneath one really - * lives outside the tree it was found in. */ + /** Every visited entry that is a symbolic link, by full path. With + * `followSymlinkedDirectories` a match beneath one really lives outside + * the tree it was found in. */ symlinks: Set + /** Symbolic links whose target is there but could not be looked at, so + * `realOf` has no entry for them. Unreadable now is not absent: a deny + * expansion must cover such a link rather than drop it. */ + uninspectableLinks: Set /** Directories the walk reached but could not list (any error but * absence). Whatever the pattern matches beneath them is missing from * `matches`; a deny expansion must cover them whole. */ @@ -953,12 +972,13 @@ export interface GlobWalk { } /** - * Expand a glob pattern into concrete file paths. + * Expand a glob pattern into the concrete paths matching it. * * Used on Linux (where bubblewrap doesn't support glob patterns * natively) and Windows (point-in-time expansion before `srt-win - * acl stamp`). Resolves the static directory prefix, lists files - * recursively, and filters using {@link globToRegex}. + * acl stamp`). A symlink is a match in its own right and is not + * descended into; {@link walkGlobPattern} is the read-deny expansion's + * richer view of the same walk. * * @param globPath - A path pattern containing glob characters (e.g., ~/test/*.env) * @returns Array of absolute paths matching the glob pattern @@ -1001,49 +1021,75 @@ function globDescentFilter( } } +/** + * The literal directory a glob's walk starts from: the static prefix before + * the pattern's first glob character, without its last path component when + * that component is not a directory of its own. '' or '/' means the pattern + * has no literal directory to start from (a wildcard in its first path + * component), which {@link walkGlobPattern} refuses to expand. + * + * @param normalizedPattern - a pattern already through + * {@link normalizePathForSandbox} (and, on Windows, {@link toForwardSlashes}) + */ +export function globPatternBaseDir(normalizedPattern: string): string { + const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] + if (!staticPrefix) return '' + return staticPrefix.endsWith('/') + ? staticPrefix.slice(0, -1) + : path.dirname(staticPrefix) +} + +/** + * Normalize to `/` separators so {@link globToRegex} (which treats `/` as the + * segment boundary) and the static-prefix split work on Windows paths. Gated + * to win32: `\` is a valid filename byte on POSIX, so rewriting it there + * would change the path (e.g. a Linux directory literally named `app\creds`). + */ +export function toForwardSlashes(s: string): string { + return process.platform === 'win32' ? s.replace(/\\/g, '/') : s +} + +/** + * How many names for one real directory a single walk lists. Every name + * lists the same entries, and every match is reported where it really lives, + * so a further name adds only the spelling each match was found under — while + * n directories linking to each other offer about e*n! of them (n=10: 9.9M), + * which a sandboxed command with write access under the pattern's base can + * plant. Past this count a directory is recorded as unlisted rather than + * walked, so a deny expansion covers it whole instead of losing it. + */ +const GLOB_WALK_MAX_NAMES_PER_DIRECTORY = 8 + /** * The walk behind {@link expandGlobPattern}: one listing of the pattern's * static prefix, filtered by `globPath` and, with `withDirectoryForm`, by * `globPath` without its trailing `/**`, with the symlinks seen recorded. + * With `followSymlinkedDirectories` it also lists through a symlinked + * directory and reports every match where it really lives. */ export function walkGlobPattern( globPath: string, - opts: ExpandGlobOptions & { withDirectoryForm?: boolean } = {}, + opts: ExpandGlobOptions & { + withDirectoryForm?: boolean + followSymlinkedDirectories?: boolean + } = {}, ): GlobWalk { const walk: GlobWalk = { + baseLocation: '', matches: [], directoryMatches: [], symlinks: new Set(), + uninspectableLinks: new Set(), unlisted: [], realOf: new Map(), } - // 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 - // filename byte on POSIX, so rewriting it there would change the - // path (e.g. a Linux directory literally named `app\creds`). - const toFwd = (s: string) => - process.platform === 'win32' ? s.replace(/\\/g, '/') : s - const normalizedPattern = toFwd(normalizePathForSandbox(globPath)) - - // Extract the static directory prefix before any glob characters, and the - // base directory from it. A wildcard in the first path component leaves - // the root as the only directory to start from. - const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] - const baseDir = !staticPrefix - ? '' - : staticPrefix.endsWith('/') - ? staticPrefix.slice(0, -1) - : path.dirname(staticPrefix) + const normalizedPattern = toForwardSlashes(normalizePathForSandbox(globPath)) + const baseDir = globPatternBaseDir(normalizedPattern) if (baseDir === '' || baseDir === '/') { - logForDebugging(`[Sandbox] Glob pattern too broad, skipping: ${globPath}`) - return walk - } - - if (!fs.existsSync(baseDir)) { logForDebugging( - `[Sandbox] Base directory for glob does not exist: ${baseDir}`, + `[Sandbox] Glob pattern has no literal directory to start from, skipping: ${globPath}`, + { level: 'warn' }, ) return walk } @@ -1060,13 +1106,9 @@ export function walkGlobPattern( // One readdir per directory rather than readdirSync's `recursive` option, // so an unreadable subtree or a symlink cycle costs only itself, not the - // whole pattern. Symlinked directories are descended: every spelling the - // sandboxed command could read through is listed, so a target reached - // twice is listed twice (its listing and its links' targets are read - // once, keyed on where they really are). The one exception is a link - // whose target is at or above a directory on the current descent (the real - // directory each earlier link was taken from, and this one), which would - // never terminate. + // whole pattern. A directory that is not there is not a case of its own: + // the listing below tells an absent directory from one that must be + // denied whole. type Frame = { dir: string /** `dir` with every symlink resolved. */ @@ -1074,41 +1116,52 @@ export function walkGlobPattern( /** The real directory each symlink on the way here was taken from. */ linkedFrom: readonly string[] } - type Listing = { entries: fs.Dirent[] } | { errorCode: string | undefined } - const listings = new Map() - const listingOf = (frame: Frame): Listing => { - let listing = listings.get(frame.real) - if (listing === undefined) { - try { - listing = { - entries: fs.readdirSync(frame.dir, { withFileTypes: true }), - } - } catch (err) { - const errorCode = (err as NodeJS.ErrnoException | undefined)?.code - listing = { errorCode } - logForDebugging( - `[Sandbox] Error listing ${frame.dir} for glob pattern ${globPath}: ${err}`, - { level: errorCode === 'ENOENT' ? 'info' : 'warn' }, - ) - } - listings.set(frame.real, listing) - } - return listing + /** Successful listings, by real directory: a directory reached by a second + * name holds the same entries. A failure is never cached — it can belong + * to the route rather than to the directory (ELOOP, ENAMETOOLONG) and + * would then answer for every other name. */ + const listings = new Map() + const namesWalked = new Map() + const pending: Frame[] = [] + /** Queue a directory to list, unless its real location already has + * {@link GLOB_WALK_MAX_NAMES_PER_DIRECTORY} names in this walk. */ + const queue = (frame: Frame): boolean => { + const walked = namesWalked.get(frame.real) ?? 0 + if (walked >= GLOB_WALK_MAX_NAMES_PER_DIRECTORY) return false + namesWalked.set(frame.real, walked + 1) + pending.push(frame) + return true + } + /** Record a directory the walk refused to list under one more name. */ + const refuseToWalk = (dir: string, real: string): void => { + logForDebugging( + `[Sandbox] Not listing ${dir} for glob pattern ${globPath}: ${real} already has ${GLOB_WALK_MAX_NAMES_PER_DIRECTORY} names in this walk, so it is covered whole instead`, + { level: 'warn' }, + ) + walk.unlisted.push(dir) + if (real !== dir) walk.realOf.set(dir, real) } - /** Where a symlink leads and whether that is a directory; undefined for - * one that dangles, vanished or cannot be traversed. */ - type LinkTarget = { real: string; isDirectory: boolean } | undefined + /** Where a symlink leads and whether that is a directory. 'absent' when + * nothing is there to descend into; 'uninspectable' when something is and + * it could not be looked at, which is not the same thing: a same-uid + * command can make a target unsearchable and undo that from inside the + * next sandbox, so a deny must still cover the link. */ + type LinkTarget = + | { real: string; isDirectory: boolean } + | 'absent' + | 'uninspectable' const linkTargets = new Map() const linkTargetOf = (linkPath: string, realLinkPath: string): LinkTarget => { - if (linkTargets.has(realLinkPath)) return linkTargets.get(realLinkPath) + const cached = linkTargets.get(realLinkPath) + if (cached !== undefined) return cached let target: LinkTarget try { target = { isDirectory: fs.statSync(linkPath).isDirectory(), real: fs.realpathSync(linkPath), } - } catch { - // Dangling, vanished or not traversable: nothing to descend into. + } catch (err) { + target = isAbsenceErrno(err) ? 'absent' : 'uninspectable' } linkTargets.set(realLinkPath, target) return target @@ -1118,23 +1171,34 @@ export function walkGlobPattern( try { baseReal = fs.realpathSync(baseDir) } catch { - // Vanished between the existence check and here: list what remains. + // Not there, or a component of it cannot be resolved: list the spelling. } - const pending: Frame[] = [{ dir: baseDir, real: baseReal, linkedFrom: [] }] + walk.baseLocation = baseReal + queue({ dir: baseDir, real: baseReal, linkedFrom: [] }) for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) { const { dir, real, linkedFrom } = frame - const listing = listingOf(frame) - if (!('entries' in listing)) { - if (listing.errorCode !== 'ENOENT') { - walk.unlisted.push(dir) - if (real !== dir) walk.realOf.set(dir, real) + let entries = listings.get(real) + if (entries === undefined) { + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException | undefined)?.code + logForDebugging( + `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, + { level: errorCode === 'ENOENT' ? 'info' : 'warn' }, + ) + if (errorCode !== 'ENOENT') { + walk.unlisted.push(dir) + if (real !== dir) walk.realOf.set(dir, real) + } + continue } - continue + listings.set(real, entries) } - for (const entry of listing.entries) { + for (const entry of entries) { const fullPath = path.join(dir, entry.name) const realPath = path.join(real, entry.name) - const candidate = toFwd(fullPath) + const candidate = toForwardSlashes(fullPath) const isMatch = regex.test(candidate) if (isMatch) walk.matches.push(fullPath) if (entry.isDirectory()) { @@ -1143,8 +1207,11 @@ export function walkGlobPattern( if ((isMatch || isDirectoryMatch) && realPath !== fullPath) { walk.realOf.set(fullPath, realPath) } - if (canHoldMatch(candidate)) { - pending.push({ dir: fullPath, real: realPath, linkedFrom }) + if ( + canHoldMatch(candidate) && + !queue({ dir: fullPath, real: realPath, linkedFrom }) + ) { + refuseToWalk(fullPath, realPath) } continue } @@ -1155,17 +1222,26 @@ export function walkGlobPattern( continue } walk.symlinks.add(fullPath) - if (process.platform === 'win32') { - // The Windows ACL expansion does not follow reparse points - // (junctions, directory symlinks), and the `cycle` check compares - // POSIX-separated paths. - continue - } + // Only the read-deny expansion lists through a symlinked directory: it + // has to cover what the pattern reaches by every name. The allowRead + // expansion and the Windows ACL stamp take the link itself and stop + // there, as the allow bind and the ACL they feed do — Windows does not + // follow reparse points at all, and the cycle check below compares + // POSIX-separated paths. + if (!opts.followSymlinkedDirectories) continue const isDirectoryFormCandidate = directoryRegex?.test(candidate) === true const descends = canHoldMatch(candidate) if (!isMatch && !isDirectoryFormCandidate && !descends) continue const target = linkTargetOf(fullPath, realPath) - if (target === undefined) continue + if (target === 'absent') continue + if (target === 'uninspectable') { + // Where it leads is unknown, so it gets no real location and nothing + // is listed through it — but it is still a match, and a deny + // expansion covers it under its own spelling. + walk.uninspectableLinks.add(fullPath) + if (isDirectoryFormCandidate) walk.directoryMatches.push(fullPath) + continue + } if (isMatch) walk.realOf.set(fullPath, target.real) if (!target.isDirectory) continue if (isDirectoryFormCandidate) { @@ -1173,6 +1249,9 @@ export function walkGlobPattern( walk.realOf.set(fullPath, target.real) } if (!descends) continue + // A link whose target is at or above a directory on the current + // descent (the real directory each earlier link was taken from, and + // this one) is never followed: that walk would not terminate. const cycle = [...linkedFrom, real].some(from => isAtOrUnder(from, target.real), ) @@ -1182,11 +1261,15 @@ export function walkGlobPattern( ) continue } - pending.push({ - dir: fullPath, - real: target.real, - linkedFrom: [...linkedFrom, real], - }) + if ( + !queue({ + dir: fullPath, + real: target.real, + linkedFrom: [...linkedFrom, real], + }) + ) { + refuseToWalk(fullPath, target.real) + } } } diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index ebe5fe8bf..7006be1f9 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' -import fs, { +// The namespace of the same module production binds (sandbox-utils.ts does +// `import * as fs from 'fs'`), so a spy on it is seen by the code under test. +import * as fs from 'fs' +import { chmodSync, mkdirSync, mkdtempSync, @@ -10,11 +13,13 @@ import fs, { symlinkSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { expandGlobPattern, expandTilde, + globPatternBaseDir, globToRegex, + normalizePathForSandbox, walkGlobPattern, } from '../../src/sandbox/sandbox-utils.js' import { @@ -198,6 +203,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { const BASE = realPath(RAW_BASE) const walk = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**'), { withDirectoryForm: true, + followSymlinkedDirectories: true, }) expect(walk.matches).toContain(join(BASE, 'a', 'build', '1.out')) @@ -214,6 +220,69 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(walk.directoryMatches).toEqual([]) }) + it('takes a symlinked directory as the match itself without the link option', () => { + // What the allowRead expansion and the Windows ACL stamp see: the link is + // a match of its own, and nothing under what it points at is listed, so a + // link planted in the tree cannot widen an allow list to another tree. + const BASE = realPath(RAW_BASE) + const link = join(BASE, 'a', 'build', 'link') + writeFileSync(join(BASE, 'elsewhere', 'outside.out'), '') + try { + const plain = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**')) + expect(plain.matches).toContain(link) + expect(plain.matches).not.toContain(join(link, 'outside.out')) + expect(plain.realOf.get(link)).toBeUndefined() + + const followed = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**'), { + followSymlinkedDirectories: true, + }) + expect(followed.matches).toContain(join(link, 'outside.out')) + expect(followed.realOf.get(join(link, 'outside.out'))).toBe( + join(BASE, 'elsewhere', 'outside.out'), + ) + } finally { + rmSync(join(BASE, 'elsewhere', 'outside.out')) + } + }) + + it.if(process.getuid?.() !== 0)( + 'records a symlink whose target cannot be looked at, with no real location', + () => { + // chmod 000 on the directory holding the target, which a sandboxed + // command with write access there can do and undo: stat and realpath + // both answer EACCES, which is not "nothing is there". + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-eacces-'))) + const vault = join(root, 'vault') + const link = join(root, 'certs', 'k') + try { + mkdirSync(join(vault, 'inner'), { recursive: true }) + writeFileSync(join(vault, 'inner', 'k'), 'SECRET') + mkdirSync(join(root, 'certs')) + symlinkSync(join(vault, 'inner', 'k'), link) + chmodSync(vault, 0o000) + + const walk = walkGlobPattern(join(root, 'certs', '*'), { + followSymlinkedDirectories: true, + }) + + expect(walk.matches).toEqual([link]) + expect(walk.symlinks.has(link)).toBe(true) + expect(walk.uninspectableLinks.has(link)).toBe(true) + expect(walk.realOf.get(link)).toBeUndefined() + + // A link that leads nowhere at all stays the other case. + symlinkSync(join(root, 'gone'), join(root, 'certs', 'dangling')) + const withDangling = walkGlobPattern(join(root, 'certs', '*'), { + followSymlinkedDirectories: true, + }) + expect(withDangling.uninspectableLinks).toEqual(new Set([link])) + } finally { + chmodSync(vault, 0o755) + rmSync(root, { recursive: true, force: true }) + } + }, + ) + it('reports where a match beneath a symlinked base really is', () => { // alias -> the tree, sideways: normalizePathForSandbox keeps the link // spelling for the pattern, so every match is spelled through it and @@ -291,6 +360,32 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(walk.unlisted).toEqual([]) }) + it.if(process.getuid?.() !== 0)( + 'reports a base directory it cannot reach as unlisted, not as absent', + () => { + // The pattern's base is there; an ancestor of it is not searchable, so + // nothing under it can be enumerated. Read as absent, the deny would + // vanish for as long as the mode stays that way — and a sandboxed + // command can set it and put it back. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-base-'))) + const closed = join(root, 'closed') + const base = join(closed, 'certs') + try { + mkdirSync(base, { recursive: true }) + writeFileSync(join(base, 'id.pem'), 'KEY') + chmodSync(closed, 0o000) + + const walk = walkGlobPattern(join(base, '*.pem')) + + expect(walk.matches).toEqual([]) + expect(walk.unlisted).toEqual([base]) + } finally { + chmodSync(closed, 0o755) + rmSync(root, { recursive: true, force: true }) + } + }, + ) + it.if(process.getuid?.() !== 0)( 'does not try to list a directory the pattern cannot match beneath', () => { @@ -331,10 +426,35 @@ describe.if(!isWindows)('walkGlobPattern', () => { ) it('skips a pattern whose only literal directory is the root', () => { - // /tm*/... would have to start listing at '/'. - const walk = walkGlobPattern('/tm*/glob-walk-no-such-dir/**') - expect(walk.matches).toEqual([]) - expect(walk.unlisted).toEqual([]) + // A wildcard in the first path component leaves '/' to start from. The + // fixture is real and the pattern matches it, so a walk that started at + // the root would find it: what this pins is the skip, not an empty tree. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-root-'))) + try { + mkdirSync(join(root, 'keys')) + writeFileSync(join(root, 'keys', 'id.pem'), 'KEY') + const under = join(root, 'keys', '*.pem') + expect(expandGlobPattern(under)).toEqual([join(root, 'keys', 'id.pem')]) + + // Same file, named from a first-component wildcard: '/t*/…' on a Linux + // runner, and whatever the first component of the temporary directory + // is elsewhere. + const [, first, ...rest] = under.split('/') + const fromRoot = ['', first!.slice(0, 1) + '*', ...rest].join('/') + expect(globPatternBaseDir(normalizePathForSandbox(fromRoot))).toBe('/') + + const walk = walkGlobPattern(fromRoot) + expect(walk.matches).toEqual([]) + expect(walk.unlisted).toEqual([]) + + // A pattern with no literal component at all is the other half of the + // rule: no directory to start from, not even the root. (The walk + // resolves a relative pattern against the working directory first, so + // this shape reaches it only from a caller that does not.) + expect(globPatternBaseDir('*.pem')).toBe('') + } finally { + rmSync(root, { recursive: true, force: true }) + } }) it('matches a name that holds a line terminator', () => { @@ -350,12 +470,58 @@ describe.if(!isWindows)('walkGlobPattern', () => { } }) - it('reads a directory reached through several links once', () => { - // N packages that each link to every other: a package directory is - // reached along many link chains, and each chain is a spelling of its - // own, but every real directory is listed a single time. - const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-memo-'))) + it('does not let one name that fails to list answer for the others', () => { + // A listing failure can belong to the route rather than to the directory + // (a chain past the ELOOP bound, a name too long), and the directory is + // still there under its own name. Answering for that name too would drop + // every match beneath it: the same fail-open as reading it as absent. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-route-'))) + try { + mkdirSync(join(root, 'pkg', 'certs'), { recursive: true }) + writeFileSync(join(root, 'pkg', 'certs', 'id.pem'), 'KEY') + symlinkSync(join('pkg', 'certs'), join(root, 'lnk')) + + // Whichever of the two names the walk reaches first fails; the other + // has to be listed on its own account. + const names = [join(root, 'pkg', 'certs'), join(root, 'lnk')] + const readdirSync = fs.readdirSync + let failedOnce = false + using spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + if (!failedOnce && names.includes(String(args[0]))) { + failedOnce = true + throw Object.assign(new Error('ELOOP: too many symbolic links'), { + code: 'ELOOP', + }) + } + return readdirSync(...args) + }) as typeof fs.readdirSync) + + const walk = walkGlobPattern(join(root, '**/*.pem'), { + followSymlinkedDirectories: true, + }) + + expect(spy).toHaveBeenCalled() + expect(failedOnce).toBe(true) + expect(walk.unlisted).toHaveLength(1) + expect(walk.matches.map(m => walk.realOf.get(m) ?? m)).toEqual([ + join(root, 'pkg', 'certs', 'id.pem'), + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('bounds the names it walks for one directory, and reads each once', () => { + // N packages that each link to every other. Every chain of distinct + // packages spells the same five files differently, which is about e*N! + // of them (N=10: 9.9M) for a tree a sandboxed command can plant, so the + // walk stops after a fixed number of names per real directory and covers + // the rest whole rather than walking them. + const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-names-'))) const names = ['a', 'b', 'c', 'd', 'e'] + const maxNames = 8 try { for (const name of names) { mkdirSync(join(root, name, 'node_modules'), { recursive: true }) @@ -378,20 +544,35 @@ describe.if(!isWindows)('walkGlobPattern', () => { }) as typeof fs.readdirSync) let walk try { - walk = walkGlobPattern(join(root, '**/index.js')) + walk = walkGlobPattern(join(root, '**/index.js'), { + followSymlinkedDirectories: true, + }) } finally { readdirSpy.mockRestore() } - // Every chain of distinct packages ends in a spelling of index.js … - expect(walk.matches.length).toBeGreaterThan(names.length * 10) - expect(new Set(walk.matches).size).toBe(walk.matches.length) - // … which all resolve to the five real files … - expect(new Set(walk.matches.map(m => walk.realOf.get(m) ?? m)).size).toBe( - names.length, - ) - // … and no real directory was listed twice. + // The spy answered for the walk, and no real directory was read twice. + expect(listed.length).toBeGreaterThan(names.length) expect(new Set(listed).size).toBe(listed.length) + + // Every match is a spelling of one of the five real files … + const spellingsOf = new Map() + for (const match of walk.matches) { + const real = walk.realOf.get(match) ?? match + spellingsOf.set(real, (spellingsOf.get(real) ?? 0) + 1) + } + expect([...spellingsOf.keys()].sort()).toEqual( + names.map(name => join(root, name, 'index.js')).sort(), + ) + expect(new Set(walk.matches).size).toBe(walk.matches.length) + // … under at most one spelling per walked name of the directory + // holding it … + expect(Math.max(...spellingsOf.values())).toBeLessThanOrEqual(maxNames) + // … and the names it did not walk are covered whole instead of lost. + expect(walk.unlisted.length).toBeGreaterThan(0) + for (const unlisted of walk.unlisted) { + expect(names).toContain(basename(unlisted)) + } } finally { rmSync(root, { recursive: true, force: true }) } @@ -820,6 +1001,34 @@ describe.if(isLinux)('getLinuxGlobPatternWarnings after fix', () => { await SandboxManager.reset() }) + + it('warns about a read pattern with no literal directory to start from', async () => { + // Expanded, such a pattern would have to start listing at '/', so it is + // skipped and the entry it came from is silently unenforced. That is the + // one read glob shape a user has to be told about. + const { SandboxManager } = await import( + '../../src/sandbox/sandbox-manager.js' + ) + + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: ['/**/*.pem', '/opt*/keys/**', '/tmp/test/*.env'], + allowRead: ['/et*/ssl'], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + + const warnings = SandboxManager.getLinuxGlobPatternWarnings() + + expect(warnings.sort()).toEqual( + ['/**/*.pem', '/et*/ssl', '/opt*/keys/**'].sort(), + ) + + await SandboxManager.reset() + }) }) // ============================================================================ From 43811b8246d19003cf4fd3a1c5795f4ea4779f56 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 18:18:37 +0000 Subject: [PATCH 20/23] Linux: keep the carve-outs of a directory only a glob expansion could not read The read loop probed every read-denied directory with access(R_OK) and bound nothing back beneath one that failed, literal entries included. A directory this process may not list but may search (mode 0711, or 0750 outside the group) then lost both its allowWrite and its allowRead carve-outs, silently: a build writing to an allowed output directory inside it wrote into the tmpfs and lost that output when the command exited. The rule only ever followed from a glob expansion that could not enumerate what it matched, so the expansion now reports the mounts that stand for such a directory, whether the directory itself or the kept ancestor that hides it, and the loop consults that set rather than re-deriving a weaker fact. A read deny whose location is '/' (a symlink to the root) denied nothing at all. It now hides the directory holding the link, the rule that already covers an entry this wrap cannot inspect; only a link directly beneath '/' has nothing above it to hide and is skipped. The landing rules live in one helper the mount loop and the stub-skip prediction share, so the prediction cannot fall behind the loop and judge a covering directory against mounts that are not the ones it will make. A pattern with no literal directory to start from is reported at warn level, and the manager lists such a read pattern among its glob warnings: expanded, it would have to list from the root, so the entry it came from is silently unenforced. Also: hoist the write config out of the per-pattern callback; drop the trailing-slash strip in the read-deny target resolver, which no caller can reach; and state the standing reason in the covering-directory comments instead of the shape they replaced. --- src/sandbox/linux-sandbox-utils.ts | 235 ++++++++++++++--------------- src/sandbox/sandbox-manager.ts | 36 ++++- src/sandbox/sandbox-schemas.ts | 8 + 3 files changed, 154 insertions(+), 125 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 786332947..b0ec888e0 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -17,6 +17,7 @@ import { isSymlinkOutsideBoundary, encodeSandboxedCommand, DANGEROUS_FILES, + isAbsenceErrno, isAtOrUnder, isStrictlyUnder, getDangerousDirectories, @@ -1082,32 +1083,6 @@ function buildSandboxCommand( } } -/** An fs error that means the name resolves to no file — it is missing, or - * the path cannot name one at all — as opposed to one that means a file is - * there but could not be looked at (EACCES, EPERM, EIO, anything - * unrecognised). */ -function isAbsenceErrno(err: unknown): boolean { - const code = (err as NodeJS.ErrnoException | undefined)?.code - return ( - code === 'ENOENT' || - code === 'ENOTDIR' || - code === 'ELOOP' || - code === 'ENAMETOOLONG' - ) -} - -/** Whether the process may list `dir`. A read-denied directory it cannot - * list cannot be enumerated either, so a glob's matches beneath an allowed - * path inside it are unknown and nothing is bound back over its tmpfs. */ -function canListDirectory(dir: string): boolean { - try { - fs.accessSync(dir, fs.constants.R_OK) - return true - } catch { - return false - } -} - /** The top-level directories this wrap leaves to the kernel and to the * caller's own remounts. A cover must never take one — --proc and --dev * replace two of them after the pins are spliced in, and /sys is kernel @@ -1329,11 +1304,11 @@ async function generateFilesystemArgs( // beside its resolved dest, so the stub-skip guard tests a covering // directory in its canonical form AND every recorded spelling. const readOnlyDenyDirSpellings = new Map>() - // dest → the pre-resolution deny path it came from. denyWrite dests are - // canonicalized through symlinks but the denyRead tmpfs dirs and the write - // binds they are later compared against are not, so a dest reached via a - // symlink no longer matches them by string prefix. Both spellings name the - // same inode once bwrap resolves them, so the comparisons below test both. + // dest → the pre-resolution deny path it came from. A bind at the resolved + // dest also re-exposes whatever the symlinked spelling leads to, so the + // re-application passes below compare a read deny's landing against both + // spellings. Landings and allowed write paths are canonical, so the extra + // spelling can only match more of them, never fewer. const denyWriteRawDests = new Map() // Where a mount given `p` lands. One resolution per path per wrap, so every // predicate below sees the same answer, and none before the mandatory-deny @@ -1411,33 +1386,69 @@ async function generateFilesystemArgs( // directory above it that can. That hides more than was asked, never less: // a same-uid command can make a parent unsearchable (chmod 000) and undo it // again from inside the next sandbox, so "unreadable now" is not "absent". - // '/' is never the stand-in: a --tmpfs / would wipe every mount before it - // and the pivot would promote it, booting the command on an empty tree. - // - // A trailing slash is dropped first. It guards a direct caller of - // wrapCommandWithSandboxLinux: normalizePathForSandbox keeps one on any - // spelling it takes for a glob, so a literal path named with glob - // characters ('/[id]/') arrives with it, and stat('/') answers - // ENOTDIR, which reads as "not there" and silently drops the deny. The - // manager routes every glob-character read entry through the glob - // expansion, which never returns one. const readDenyTargetOf = ( entry: string, ): { path: string; isDirectory: boolean; isStandIn: boolean } | undefined => { - const named = entry.replace(/\/+$/, '') || '/' - for (let candidate = named; ; candidate = path.dirname(candidate)) { - if (candidate === '/' && candidate !== named) return undefined + for (let candidate = entry; ; candidate = path.dirname(candidate)) { + if (candidate === '/' && candidate !== entry) return undefined try { return { path: candidate, isDirectory: fs.statSync(candidate).isDirectory(), - isStandIn: candidate !== named, + isStandIn: candidate !== entry, } } catch (err) { if (isAbsenceErrno(err) || candidate === '/') return undefined } } } + // Where that mount lands, which is where the entry really is: one mount + // then covers every spelling of that place; the real contents are hidden + // wherever they are reachable, even when the route that named them is + // itself inside an earlier tmpfs; and no mount is ever asked to land on a + // symlink, which bubblewrap refuses outright from 0.12 on ("Can't mount on + // symlink destination"). + // + // '/' is never a landing. Only a symlink to the root resolves there (a '/' + // entry itself is expanded into the root's children), and a --tmpfs / + // would wipe every mount placed before it while the pivot promoted it, + // booting the command on an empty tree. The directory holding the link + // stands in for it, on the same rule as an entry that cannot be inspected, + // and `undefined` means nothing at all is mounted for this entry. + const readDenyMountOf = ( + entry: string, + ): + | { + /** Where the mount goes. */ + landing: string + /** As the entry resolved, before the landing rules below. */ + named: string + isDirectory: boolean + /** The landing is not where the entry names: nothing beneath it can + * be vouched for, so nothing is bound back over it. */ + isStandIn: boolean + } + | undefined => { + const target = readDenyTargetOf(entry) + if (target === undefined) return undefined + const landing = canonicalForm(target.path) + if (landing !== '/') { + return { + landing, + named: target.path, + isDirectory: target.isDirectory, + isStandIn: target.isStandIn, + } + } + const holder = canonicalForm(path.dirname(target.path)) + if (holder === '/') return undefined + return { + landing: holder, + named: target.path, + isDirectory: true, + isStandIn: true, + } + } // The directories and files the read section denies, as configured. A '/' // entry is expanded into the root's children, because --tmpfs / would wipe // every prior mount (ro-bind /, write binds, deny binds). /proc and /dev are @@ -1589,14 +1600,18 @@ async function generateFilesystemArgs( */ allowedWritePathsBothForms: string[] /** - * The tmpfs targets the denyRead loop below will mount, from the - * same readDenyEntries() it uses, keeping only entries that exist - * as directories (the loop skips absent entries and gives file - * entries a read-only /dev/null mask instead of a tmpfs), in raw - * and canonical spellings. A read-denied tmpfs at or under a - * covering deny dir is the TRIGGER for the post-denyWrite - * re-application, and a deny bind whose dest sits under one can be - * dropped by the emission filter — both facts feed the guard. + * Where the denyRead loop below would mount a tmpfs, from the same + * readDenyEntries() and the same landing rules it uses, keeping + * only the entries that are directories (the loop skips absent + * ones and gives a file entry a read-only /dev/null mask instead), + * in raw and canonical spellings. It over-predicts: the loop also + * skips an entry whose landing an earlier tmpfs already hides. + * That direction is the safe one, since every veto below turns a + * predicted tmpfs into a KEPT placeholder. A read-denied tmpfs at + * or under a covering deny dir is the TRIGGER for the + * post-denyWrite re-application, and a deny bind whose dest sits + * under one can be dropped by the emission filter — both facts + * feed the guard. */ prospectiveReadDenyTmpfsDirsBothForms: string[] } @@ -1636,14 +1651,17 @@ async function generateFilesystemArgs( allowedWritePathsBothForms = allowedWritePaths.flatMap(mountForms) prospectiveReadDenyTmpfsDirsBothForms = readDenyEntries().flatMap( entry => { - const target = readDenyTargetOf(normalizePathForSandbox(entry)) - if (!target?.isDirectory) return [] - const forms = mountForms(target.path) - // An entry that resolves to '/' (a symlink to the root) is - // skipped by the loop rather than mounted. Predicting a tmpfs - // at '/' would put every covering directory under one and veto - // every skip on the whole host. - return forms.includes('/') ? [] : forms + const mount = readDenyMountOf(normalizePathForSandbox(entry)) + if (!mount?.isDirectory) return [] + // The tmpfs hides its landing, and the spelling the entry + // resolved to when that differs. '/' is never a landing, and + // must not enter the prediction as a spelling either (a link + // to the root resolves there, and its holder is the landing): + // every covering directory lies under '/', so predicting a + // tmpfs there would veto every skip on the whole host. + return [ + ...new Set([...mountForms(mount.named), mount.landing]), + ].filter(form => form !== '/') }, ) } catch (err) { @@ -1776,12 +1794,10 @@ async function generateFilesystemArgs( // mode; neither is a deny path's subtree.) So the covering bind is the // last word on its subtree unless a tmpfs ABOVE it drops that bind at // emission as hidden-by-a-tmpfs and restores an allowed path around it: - // veto (ii). A tmpfs at or beneath the dir is NOT a veto — a path - // created inside a tmpfs never reaches the host, so a deny path beneath - // it needs no stub, and one elsewhere under the dir is unaffected by it. - // (Vetoing on it aborted every command of a write-denied checkout as - // soon as a denyRead pattern such as `**/build/**` matched a directory - // inside it.) (The emission filter's + // veto (ii). A tmpfs at or beneath the dir is NOT a veto: a path created + // inside a tmpfs never reaches the host, so a deny path beneath it needs + // no stub, and one elsewhere under the dir is unaffected by it. (The + // emission filter's // other drop condition, fileMasks, holds file dests only — /dev/null // read-deny masks and credential-mask fakes — while the pre-pass stat-verifies every // recorded dir as a directory, so it cannot drop a recorded dir short of @@ -1808,16 +1824,12 @@ async function generateFilesystemArgs( // (i) an allowed write path both strictly beneath the dir and at or // under a prospective read-deny tmpfs. Re-applying that tmpfs // after the dir's read-only bind is the one pass that mounts - // anything inside such a path at all; it restores read-only, so - // this names no live re-opening route today and no configuration - // is known that needs it. It is kept, narrowed, as the only veto - // that speaks about a writable path under the dir: should a pass - // after the deny binds ever restore one writable again, the skip - // fails closed here. Vetoing on an allowed write path with NO - // tmpfs over it — what this replaced — aborted every command of - // the common "write-protect the checkout, let the build write to - // /out" profile, whose write bind is emitted before the - // dir's read-only bind and stays buried by it. + // anything inside such a path at all, and it restores read-only, + // so this names no live re-opening route today and no + // configuration is known that needs it. It is the only veto that + // speaks about a writable path under the dir: should a pass after + // the deny binds ever restore one writable again, the skip fails + // closed here. vetoInputs.allowedWritePathsBothForms.some( writePath => isStrictlyUnder(writePath, denyDir) && @@ -1871,11 +1883,10 @@ async function generateFilesystemArgs( // nothing and keeps the stub. Without this branch, `allowOnly: // ['/']` with `denyWithinAllow: ['/']` stubs each absent cwd // dotfile on the read-only root it just mounted — the startup - // abort — whenever anything vetoes '/'. Since the vetoes were - // narrowed, what still does is an unusable prediction and an - // allowed write path under a read-deny tmpfs. The branch stays - // because it is strictly stricter than covering on the root's - // own bind, and it is what decides those two cases. + // abort — whenever anything vetoes '/', which an unusable + // prediction or an allowed write path under a read-deny tmpfs + // does. The branch is strictly stricter than covering on the + // root's own bind, and it is what decides those two cases. const vetoInputs = getStubSkipVetoInputs() if ( vetoInputs.usable && @@ -2166,34 +2177,21 @@ async function generateFilesystemArgs( const normalizedDenyPaths = readDenyEntries() .map(p => normalizePathForSandbox(p)) .sort((a, b) => canonicalDepth(a) - canonicalDepth(b)) + // Entries a glob expansion produced for a directory it could not list. + // What the pattern matches beneath an allowed path in there was never + // found, so binding that path back over the tmpfs would show every one of + // those matches unmasked. + const unlistableDenyDirs = new Set(readConfig?.unlistableDenyDirs ?? []) for (const normalizedPath of normalizedDenyPaths) { - const target = readDenyTargetOf(normalizedPath) - if (target === undefined) { - logForDebugging( - `[Sandbox Linux] Read deny path resolves to nothing this wrap can mount (absent, or uninspectable all the way up to '/'): ${normalizedPath}`, - ) - continue - } - // Where the entry really is. Every read-deny mount goes there rather - // than to the spelling that named it: one mount then covers every - // spelling of that place; the real contents are hidden wherever they are - // reachable, even when the route that named them is itself inside an - // earlier tmpfs; and no mount is ever asked to land on a symlink, which - // bubblewrap 0.12 refuses outright ("Can't mount on symlink destination") - // and 0.11 silently resolved to the same place anyway. - const landing = canonicalForm(target.path) - if (landing === '/') { - // An entry that resolves to the root, which only a symlink to '/' can - // do (a '/' entry itself was expanded into the root's children). A - // --tmpfs there would wipe every mount placed before it and the pivot - // would promote it, booting the command on an empty tree. + const mount = readDenyMountOf(normalizedPath) + if (mount === undefined) { logForDebugging( - `[Sandbox Linux] Skipping read deny path that resolves to /: ${normalizedPath}`, - { level: 'warn' }, + `[Sandbox Linux] Read deny path mounts nothing this wrap can place (absent, or uninspectable, or a link to '/' with nothing but '/' holding it): ${normalizedPath}`, ) continue } + const { landing, isDirectory, isStandIn } = mount // One mount per location. A tmpfs emitted so far already hides this // place, and nothing bound back over it shows the host there again, so a // mount here would be created inside that tmpfs and change nothing. This @@ -2206,28 +2204,30 @@ async function generateFilesystemArgs( continue } - if (target.isDirectory) { - // A stand-in for an entry that could not be inspected hides everything - // beneath it: nothing under it can be vouched for. So does a denied - // directory this process may not list: what a glob matches beneath an - // allowed path inside it cannot be enumerated. - if (target.isStandIn) { + if (isDirectory) { + // A stand-in for an entry this wrap cannot vouch for hides everything + // beneath it: nothing under it can be vouched for either. So does a + // directory a glob expansion could not enumerate. + if (isStandIn) { logForDebugging( - `[Sandbox Linux] Read deny path ${normalizedPath} cannot be inspected; hiding ${target.path} instead`, + `[Sandbox Linux] Read deny path ${normalizedPath} cannot be mounted where it names; hiding ${landing} instead`, { level: 'warn' }, ) } - const listable = !target.isStandIn && canListDirectory(target.path) - if (!target.isStandIn && !listable) { + const unlistable = + unlistableDenyDirs.has(normalizedPath) || + unlistableDenyDirs.has(landing) + if (unlistable) { logForDebugging( - `[Sandbox Linux] Read-denied directory cannot be listed; restoring nothing beneath it: ${target.path}`, + `[Sandbox Linux] Read-denied directory could not be listed when the glob was expanded; restoring nothing beneath it: ${landing}`, { level: 'warn' }, ) } + const restoresNothing = isStandIn || unlistable const restored = pushReadDenyDirMounts(args, { landing, - allowedWritePaths: listable ? allowedWritePaths : [], - readAllowPaths: listable ? readAllowPaths() : [], + allowedWritePaths: restoresNothing ? [] : allowedWritePaths, + readAllowPaths: restoresNothing ? [] : readAllowPaths(), resolved: canonicalForm, nameLocation: nameLocationOf, }) @@ -2312,8 +2312,7 @@ async function generateFilesystemArgs( // denyRead loop re-bound it (the .git/hooks case) and the write-deny bind // is still required on top. // Both are decided by where the mask or tmpfs landed against the deny's - // canonical dest, which is where its bind lands: a symlink-spelled read - // deny that mounted on an earlier tmpfs never covered its host target. + // canonical dest, which is where its bind lands. const emittedDenyWriteDests: string[] = [] // Write paths already restored read-only by a dropped deny bind, so two // denies covering the same path emit one --ro-bind. diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index fe6decd5f..55dcd75cd 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -79,6 +79,8 @@ import { import { getDefaultWritePaths, containsGlobChars, + globPatternBaseDir, + normalizePathForSandbox, removeTrailingGlobSuffix, expandGlobPattern, decodeSandboxedCommand, @@ -1265,19 +1267,19 @@ function getFsReadConfig(): FsReadRestrictionConfig { config.filesystem.allowRead ?? [], expandAllowReadGlob, ) + const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly] + const unlistableDenyDirs = new Set() const denyPaths = resolveReadPathEntries( unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), pattern => - expandReadDenyGlobLinux(pattern, [ - ...allowPaths, - ...getFsWriteConfig().allowOnly, - ]), + expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) return { denyOnly: denyPaths, allowWithinDeny: allowPaths, + unlistableDenyDirs: [...unlistableDenyDirs], } } @@ -1670,17 +1672,20 @@ async function wrapWithSandbox( expandedAllowRead.push(javaAgentJarPath) } const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly] + const unlistableDenyDirs = new Set() const expandedDenyRead = resolveReadPathEntries( unionDenyReadPaths( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ), - pattern => expandReadDenyGlobLinux(pattern, reExposedPaths), + pattern => + expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, + unlistableDenyDirs: [...unlistableDenyDirs], } } @@ -2321,8 +2326,8 @@ function getLinuxGlobPatternWarnings(): string[] { const globPatterns: string[] = [] - // Check filesystem paths for glob patterns - // Note: denyRead is excluded because globs are now expanded to concrete paths on Linux + // Write paths take no globs at all on Linux: bubblewrap binds concrete + // paths, and nothing expands them. const allPaths = [ ...config.filesystem.allowWrite, ...config.filesystem.denyWrite, @@ -2338,6 +2343,23 @@ function getLinuxGlobPatternWarnings(): string[] { } } + // Read paths are expanded, so a glob there is supported — unless the + // pattern has no literal directory for the walk to start from (a wildcard + // in its first path component, `/**/*.pem`), which expands to nothing and + // leaves the entry unenforced. + for (const path of [ + ...config.filesystem.denyRead, + ...(config.filesystem.allowRead ?? []), + ]) { + const baseDir = globPatternBaseDir(normalizePathForSandbox(path)) + if ( + containsGlobChars(removeTrailingGlobSuffix(path)) && + (baseDir === '' || baseDir === '/') + ) { + globPatterns.push(path) + } + } + return globPatterns } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 35ef0e32b..ce4243109 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -20,6 +20,14 @@ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] + /** + * The `denyOnly` entries that stand for a directory a glob expansion could + * not list. Nothing is bound back beneath one — neither an + * `allowWithinDeny` path nor an allowed write path — because what the + * pattern matches under such a path was never found and would come back + * unmasked. Linux only: the other backends match globs natively. + */ + unlistableDenyDirs?: string[] } /** From ec077121a3232bd4106e7623e95f5e1fe0cd8b8e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 18:18:37 +0000 Subject: [PATCH 21/23] test(linux): split the runtime arms out and pin the cases the fixes add Every `if (bwrap is available)` arm inside a wrapper test became a test of its own that is skipped when it cannot run, and each of those commands echoes a sentinel the test asserts: a sandbox that refuses to start prints nothing, so without one every assertion about what is hidden passed for free. The cases that ran a hand loop over two or three inputs are now one test per input, so a red line says which one failed. New cases: a link whose target cannot be looked at, driven end to end with the error injected through fs (a root container sees no real EACCES); a link to the root, which must not put a tmpfs on '/' and must not enter the stub-skip prediction as one either; a literal deny of a directory this process cannot list, which keeps its write path; the mount-count warning threshold; and a read pattern with no literal directory to start from, over a real tree a walk from the root would have matched. The suite that globs a shared tree gives each case that plants links a directory of its own, so one case's fixture is no longer another's input, and the `--tmpfs ` assertions match a whole argv token where the fixture has a sibling sharing the prefix (build next to build-cache). --- test/sandbox/read-deny-glob.test.ts | 1347 ++++++++++++------ test/sandbox/readonly-deny-dir-stubs.test.ts | 40 +- test/sandbox/symlinked-deny-paths.test.ts | 109 +- 3 files changed, 1000 insertions(+), 496 deletions(-) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 8bfa6d1e5..82ae5f954 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' +// The namespace the library binds, so a spy on it is seen by the code under +// test (see linux-ancestor-pin-errno.test.ts). +import * as fs from 'fs' import { chmodSync, existsSync, @@ -22,6 +25,7 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { let ROOT: string @@ -109,6 +113,63 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { } }, ) + + it('warns once per mount that lands outside the pattern it came from', async () => { + // A matched link decides what is hidden for the whole sandbox: the + // pattern names config/, the mount goes on a database directory nothing + // in the configuration mentions. + const outside = join(ROOT, 'pgdata') + const config = join(ROOT, 'config') + mkdirSync(join(outside, 'base'), { recursive: true }) + mkdirSync(config, { recursive: true }) + writeFileSync(join(config, 'app.conf'), '') + symlinkSync(outside, join(config, 'data')) + try { + const { result, warnings } = await withCapturedWarnings(async () => + expandReadDenyGlobLinux(join(config, '*'), []), + ) + + expect(result).toContain(outside) + const escaped = warnings.filter(line => line.includes(outside)) + expect(escaped).toHaveLength(1) + expect(escaped[0]).toContain(join(config, '*')) + expect(escaped[0]).toContain(join(config, 'data')) + // What stays inside the pattern's own base is not worth a warning. + expect( + warnings.filter(line => line.includes(join(config, 'app.conf'))), + ).toEqual([]) + } finally { + rmSync(outside, { recursive: true, force: true }) + rmSync(config, { recursive: true, force: true }) + } + }) + + it('warns when a pattern still needs more mounts than the threshold after collapsing', async () => { + // The expansion is never truncated — that would silently un-deny paths — + // so a broad pattern is reported rather than cut short. + const many = join(ROOT, 'many') + mkdirSync(many, { recursive: true }) + for (let i = 0; i <= 256; i++) writeFileSync(join(many, `${i}.key`), '') + try { + const { result, warnings } = await withCapturedWarnings(async () => + expandReadDenyGlobLinux(join(many, '*.key'), []), + ) + + expect(result).toHaveLength(257) + expect( + warnings.filter(line => line.includes('after collapsing')), + ).toHaveLength(1) + + const { warnings: quiet } = await withCapturedWarnings(async () => + expandReadDenyGlobLinux(join(many, '1?.key'), []), + ) + expect(quiet.filter(line => line.includes('after collapsing'))).toEqual( + [], + ) + } finally { + rmSync(many, { recursive: true, force: true }) + } + }) }) describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { @@ -140,8 +201,22 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { ) }) + /** + * A fixture directory of its own for a case that plants a tree: four of + * the tests here glob the whole of ROOT, and would otherwise see whatever + * another case left in it. + */ + const caseRoots: string[] = [] + function caseRoot(name: string): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), `deny-glob-${name}-`))) + caseRoots.push(dir) + return dir + } + afterAll(() => { - rmSync(ROOT, { recursive: true, force: true }) + for (const dir of [ROOT, ...caseRoots]) { + rmSync(dir, { recursive: true, force: true }) + } }) it('mounts a symlink beneath a collapsed directory at its target', () => { @@ -204,11 +279,10 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { it('lists a link named like the pattern segment with its target', () => { // 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). The target is where the - // mount lands. - const shal = join(ROOT, 'shal') + // walk also reaches by its own name, which matches nothing; the link is + // the only spelling the pattern matches, so both names have to be + // listed. The target is where the mount lands. + const shal = caseRoot('shallow') mkdirSync(join(shal, 'proj', 'vault'), { recursive: true }) writeFileSync(join(shal, 'proj', 'vault', 'secret.out'), '') mkdirSync(join(shal, 'proj', 'config')) @@ -256,11 +330,13 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { } }) - it('never denies the root through a link to it', () => { + it('denies what holds a link to the root, never the root', () => { // build/root -> /: denied where it resolves, that would be a tmpfs over // every top-level directory; denied at the link, a mount bwrap refuses, - // so that no later command starts while the link exists. It is dropped. - const rooted = join(ROOT, 'rooted') + // so that no later command starts while the link exists. The directory + // holding it is denied instead, as for an entry that cannot be + // inspected — here build, which the pattern denies anyway. + const rooted = caseRoot('rooted') mkdirSync(join(rooted, 'build'), { recursive: true }) writeFileSync(join(rooted, 'build', '1.out'), '') symlinkSync('/', join(rooted, 'build', 'root')) @@ -269,33 +345,34 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { expect(mounts).toEqual([join(rooted, 'build')]) - // The same when the link is itself the matched directory. + // The same when the link is itself the matched directory: the mount goes + // on the directory holding it. mkdirSync(join(rooted, 'img')) symlinkSync('/', join(rooted, 'img', 'build')) expect( expandReadDenyGlobLinux(join(rooted, 'img', '**/build/**'), []), - ).toEqual([]) + ).toEqual([join(rooted, 'img')]) }) it('denies the target of a directory-form link the walk did not descend', () => { - // u/x/y/build -> u/x names a directory on its own descent chain, so the - // walk lists nothing beneath it; it is still a match, and its target is - // what a literal deny of the link would deny. - const u = join(ROOT, 'u') - mkdirSync(join(u, 'x', 'y'), { recursive: true }) - writeFileSync(join(u, 'x', 'src.ts'), '') - symlinkSync(join('..'), join(u, 'x', 'y', 'build')) + // pkg/x/y/build -> pkg/x names a directory on its own descent chain, so + // the walk lists nothing beneath it; it is still a match, and its target + // is what a literal deny of the link would deny. + const ancestry = caseRoot('ancestry') + mkdirSync(join(ancestry, 'x', 'y'), { recursive: true }) + writeFileSync(join(ancestry, 'x', 'src.ts'), '') + symlinkSync(join('..'), join(ancestry, 'x', 'y', 'build')) - const mounts = expandReadDenyGlobLinux(join(u, '**/build/**'), []) + const mounts = expandReadDenyGlobLinux(join(ancestry, '**/build/**'), []) - expect(mounts).toContain(join(u, 'x')) + expect(mounts).toContain(join(ancestry, 'x')) }) it('denies through a link back to the tree', () => { // build/up -> ..: the target is the whole tree the link reaches, as a // literal deny of the link would have it; the walk itself stops at the // link. - const esc = join(ROOT, 'esc') + const esc = caseRoot('escape') mkdirSync(join(esc, 'build'), { recursive: true }) writeFileSync(join(esc, 'build', '1.out'), '') symlinkSync('..', join(esc, 'build', 'up')) @@ -312,7 +389,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { let real: string let link: string beforeAll(() => { - pnpmRoot = join(ROOT, 'pnpm') + pnpmRoot = caseRoot('pnpm') real = join(pnpmRoot, '.pnpm', 'foo@1', 'node_modules', 'foo') link = join(pnpmRoot, 'node_modules', 'foo') mkdirSync(join(real, 'public'), { recursive: true }) @@ -358,7 +435,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // no spelled ancestor of the match contains. Collapsing along the // spelling would drop it under proj/build and lose the mask beneath the // carve-out. - const chain = join(ROOT, 'chain') + const chain = caseRoot('chain') const proj = join(chain, 'proj') const store = join(chain, 'store') mkdirSync(proj, { recursive: true }) @@ -382,7 +459,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { }) it('drops a matched link that does not resolve', () => { - const dangling = join(ROOT, 'dangling') + const dangling = caseRoot('dangling') mkdirSync(join(dangling, 'build'), { recursive: true }) writeFileSync(join(dangling, 'build', '1.out'), '') symlinkSync(join(dangling, 'gone'), join(dangling, 'build', 'lost')) @@ -406,6 +483,32 @@ describe.if(isLinux)( const savedCwd = process.cwd() const hasBwrap = bwrapCanNamespace() + /** + * Run a wrapped command and hold it to having started. A sandbox that + * refuses to start prints nothing, which every assertion about what it + * hides would otherwise read as a pass, so each command under test says + * BOOTED first. + */ + function runBooted(wrapped: string): string { + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15000, + }) + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout).toContain('BOOTED') + return result.stdout + } + + /** `ln -s` a case's second test can call again over its own fixture. */ + function ensureLink(target: string, linkPath: string): void { + try { + symlinkSync(target, linkPath) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + } + beforeAll(() => { ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bwrap-'))) pnpmRoot = join(ROOT, 'pnpm') @@ -441,8 +544,9 @@ describe.if(isLinux)( } for (const spelling of ['link', 'target'] as const) { + const carveOutOf = () => join(spelling === 'link' ? link : real, 'public') it(`mounts no tmpfs on a symlink and re-binds the carve-out last (allowRead in ${spelling} spelling)`, async () => { - const carveOut = join(spelling === 'link' ? link : real, 'public') + const carveOut = carveOutOf() const wrapped = await wrap('echo hello', carveOut) const ops = wrapped.split(' --').map(op => op.trim()) @@ -466,32 +570,32 @@ describe.if(isLinux)( `ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) expect(reBind).toBeGreaterThan(lastTmpfs) + }) - if (hasBwrap) { - // Inside: the package is empty but for the carve-out, in both - // spellings; the entries beneath the carve-out keep their masks. - const run = spawnSync( + it.skipIf(!hasBwrap)( + `serves the package empty but for the carve-out, in both spellings (allowRead in ${spelling} spelling)`, + async () => { + const stdout = runBooted( await wrap( [ + 'echo BOOTED', `ls ${link}`, `ls ${real}`, `cat ${join(link, 'public', 'ok.txt')} | wc -c`, `[ -e ${join(link, 'index.js')} ] && echo INDEX_VISIBLE || echo INDEX_HIDDEN`, ].join('; '), - carveOut, + carveOutOf(), ), - { shell: true, encoding: 'utf8', timeout: 15000, cwd: ROOT }, ) - expect(run.stderr ?? '').not.toContain('symlink destination') - expect(run.status).toBe(0) - expect(run.stdout.trim().split('\n')).toEqual([ + expect(stdout.trim().split('\n')).toEqual([ + 'BOOTED', 'public', 'public', '0', 'INDEX_HIDDEN', ]) - } - }) + }, + ) } it('honours a file carve-out written in the link spelling', async () => { @@ -538,7 +642,7 @@ describe.if(isLinux)( const storeBind = wrapped.lastIndexOf(`--ro-bind ${store} ${store}`) expect(storeBind).toBeGreaterThan(-1) - expect(wrapped.lastIndexOf(`--tmpfs ${real}`)).toBeGreaterThan(storeBind) + expect(wrapped.lastIndexOf(`--tmpfs ${real} `)).toBeGreaterThan(storeBind) }) it('does not re-apply a tmpfs over its carve-out when a denyWrite bind covers only the link spelling', async () => { @@ -547,23 +651,26 @@ describe.if(isLinux)( // (realdir), so the bind re-exposes nothing; re-applying the tmpfs // there anyway would re-bind realdir/pub over the mask on // realdir/pub/secret.txt and leave the file readable. - const R = join(ROOT, 'f4') - const realdir = join(R, 'realdir') - const W = join(R, 'w') - const S = join(W, 'd', 'link') + const caseRoot = join(ROOT, 'f4') + const realdir = join(caseRoot, 'realdir') + const writeRoot = join(caseRoot, 'w') + const linkInWriteRoot = join(writeRoot, 'd', 'link') mkdirSync(join(realdir, 'pub'), { recursive: true }) writeFileSync(join(realdir, 'pub', 'secret.txt'), 'secret') - mkdirSync(join(W, 'd'), { recursive: true }) - symlinkSync(realdir, S) + mkdirSync(join(writeRoot, 'd'), { recursive: true }) + symlinkSync(realdir, linkInWriteRoot) const wrapped = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, readConfig: { - denyOnly: [S, join(realdir, 'pub', 'secret.txt')], + denyOnly: [linkInWriteRoot, join(realdir, 'pub', 'secret.txt')], allowWithinDeny: [join(realdir, 'pub')], }, - writeConfig: { allowOnly: [W], denyWithinAllow: [join(W, 'd')] }, + writeConfig: { + allowOnly: [writeRoot], + denyWithinAllow: [join(writeRoot, 'd')], + }, mandatoryDenySearchDepth: 1, }) @@ -589,7 +696,7 @@ describe.if(isLinux)( }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(viaLink).toContain(`--tmpfs ${real}`) + expect(viaLink).toContain(`--tmpfs ${real} `) expect(viaLink).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) @@ -605,19 +712,19 @@ describe.if(isLinux)( }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(viaLink).toContain(`--tmpfs ${real}`) + expect(viaLink).toContain(`--tmpfs ${real} `) expect(viaLink).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) }) - it('re-binds a carve-out written through a symlinked directory outside the denied one, at its target', async () => { - // denyRead [real] + allowRead [link/public]: the name `public` lives in - // the denied directory whichever way it is reached, and the bind goes - // where it is (bwrap refuses a symlink as a destination and, before - // 0.12, an absolute one anywhere in it). - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(link, 'public', 'ok.txt')}; cat ${join(real, 'index.js')} || echo HIDDEN`, + // denyRead [real] + allowRead [link/public]: the name `public` lives in + // the denied directory whichever way it is reached, and the bind goes + // where it is (bwrap refuses a symlink as a destination and, before 0.12, + // an absolute one anywhere in it). + function wrapCarveOutThroughOutsideLink(command: string): Promise { + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: [real], @@ -625,42 +732,42 @@ describe.if(isLinux)( }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).toContain(`--tmpfs ${real}`) + } + + it('re-binds a carve-out written through a symlinked directory outside the denied one, at its target', async () => { + const wrapped = await wrapCarveOutThroughOutsideLink('true') + expect(wrapped).toContain(`--tmpfs ${real} `) expect(wrapped).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('publicHIDDEN\n') - } }) + it.skipIf(!hasBwrap)( + 'serves that carve-out and nothing else of the denied directory', + async () => { + const stdout = runBooted( + await wrapCarveOutThroughOutsideLink( + `echo BOOTED; cat ${join(link, 'public', 'ok.txt')}; cat ${join(real, 'index.js')} || echo HIDDEN`, + ), + ) + expect(stdout).toBe('BOOTED\npublicHIDDEN\n') + }, + ) + describe('an allowRead that only leads into a denied directory', () => { // What an allowRead entry resolves to never decides which deny it // carves out of: a sandboxed command with write access to where the // entry lives can point it anywhere. - function run(wrapped: string): string { - return spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }).stdout - } - it('does not bind a denied directory back through an allowRead symlink to it', async () => { + function wrapAllowReadLinkToDeniedDir(command: string): Promise { const home = join(ROOT, 'g1', 'home') const proj = join(ROOT, 'g1', 'proj') mkdirSync(join(home, '.ssh'), { recursive: true }) writeFileSync(join(home, '.ssh', 'id_rsa'), 'KEY') mkdirSync(proj, { recursive: true }) - symlinkSync(join(home, '.ssh'), join(proj, 'docs')) - - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(home, '.ssh', 'id_rsa')} ${join(proj, 'docs', 'id_rsa')} || echo HIDDEN`, + ensureLink(join(home, '.ssh'), join(proj, 'docs')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: [join(home, '.ssh')], @@ -669,26 +776,43 @@ describe.if(isLinux)( writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, mandatoryDenySearchDepth: 1, }) + } + + it('does not bind a denied directory back through an allowRead symlink to it', async () => { + const wrapped = await wrapAllowReadLinkToDeniedDir('true') + + expect(wrapped).toContain( + `--tmpfs ${join(ROOT, 'g1', 'home', '.ssh')} `, + ) + expect(wrapped).not.toContain( + `--ro-bind ${join(ROOT, 'g1', 'proj', 'docs')}`, + ) + }) - expect(wrapped).toContain(`--tmpfs ${join(home, '.ssh')}`) - expect(wrapped).not.toContain(`--ro-bind ${join(proj, 'docs')}`) - if (hasBwrap) { - const stdout = run(wrapped) + it.skipIf(!hasBwrap)( + 'serves nothing of that denied directory under either name', + async () => { + const home = join(ROOT, 'g1', 'home') + const proj = join(ROOT, 'g1', 'proj') + const stdout = runBooted( + await wrapAllowReadLinkToDeniedDir( + `echo BOOTED; cat ${join(home, '.ssh', 'id_rsa')} ${join(proj, 'docs', 'id_rsa')} || echo HIDDEN`, + ), + ) expect(stdout).not.toContain('KEY') expect(stdout).toContain('HIDDEN') - } - }) + }, + ) - it('keeps the mask on a denied file an allowRead symlink points at', async () => { + function wrapAllowReadLinkToDeniedFile(command: string): Promise { const aws = join(ROOT, 'g2', 'aws') const proj = join(ROOT, 'g2', 'proj') mkdirSync(aws, { recursive: true }) writeFileSync(join(aws, 'credentials'), 'CREDS') mkdirSync(proj, { recursive: true }) - symlinkSync(join(aws, 'credentials'), join(proj, 'cfg.json')) - - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(aws, 'credentials')} ${join(proj, 'cfg.json')}; echo END`, + ensureLink(join(aws, 'credentials'), join(proj, 'cfg.json')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: [join(aws, 'credentials')], @@ -697,27 +821,44 @@ describe.if(isLinux)( writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, mandatoryDenySearchDepth: 1, }) + } + + it('keeps the mask on a denied file an allowRead symlink points at', async () => { + const wrapped = await wrapAllowReadLinkToDeniedFile('true') expect(wrapped).toContain( - `--ro-bind /dev/null ${join(aws, 'credentials')}`, + `--ro-bind /dev/null ${join(ROOT, 'g2', 'aws', 'credentials')}`, ) - if (hasBwrap) expect(run(wrapped)).not.toContain('CREDS') }) - it('does not lift a file mask for an allowRead symlink that a pattern also matches', async () => { - // denyRead **/.env* with allowRead **/.env.example, and - // sub/.env.example -> ../.env planted: both patterns match the link, - // which names the link, not .env. + it.skipIf(!hasBwrap)( + 'serves that file through neither name', + async () => { + const aws = join(ROOT, 'g2', 'aws') + const proj = join(ROOT, 'g2', 'proj') + const stdout = runBooted( + await wrapAllowReadLinkToDeniedFile( + `echo BOOTED; cat ${join(aws, 'credentials')} ${join(proj, 'cfg.json')}; echo END`, + ), + ) + expect(stdout).not.toContain('CREDS') + expect(stdout).toContain('END') + }, + ) + + // denyRead **/.env* with allowRead **/.env.example, and + // sub/.env.example -> ../.env planted: both patterns match the link, + // which names the link, not .env. + function wrapPlantedExampleLink(command: string): Promise { const proj = join(ROOT, 'g3', 'proj') mkdirSync(join(proj, 'sub'), { recursive: true }) writeFileSync(join(proj, '.env'), 'ENVSECRET') writeFileSync(join(proj, '.env.example'), 'EXAMPLE') - symlinkSync(join('..', '.env'), join(proj, 'sub', '.env.example')) + ensureLink(join('..', '.env'), join(proj, 'sub', '.env.example')) const allowWithinDeny = expandGlobPattern(join(proj, '**/.env.example')) expect(allowWithinDeny).toContain(join(proj, 'sub', '.env.example')) - - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(proj, '.env.example')}; cat ${join(proj, 'sub', '.env.example')} ${join(proj, '.env')}; echo END`, + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: expandReadDenyGlobLinux( @@ -729,357 +870,708 @@ describe.if(isLinux)( writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, mandatoryDenySearchDepth: 1, }) + } + + it('does not lift a file mask for an allowRead symlink that a pattern also matches', async () => { + const proj = join(ROOT, 'g3', 'proj') + const wrapped = await wrapPlantedExampleLink('true') expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) expect(wrapped).not.toContain(`/dev/null ${join(proj, '.env.example')}`) - if (hasBwrap) { - const stdout = run(wrapped) - expect(stdout).toContain('EXAMPLE') - expect(stdout).not.toContain('ENVSECRET') - } }) - it('does not show a tree outside the denied directory under a name inside it', async () => { - // denyRead [D, e/sub] + allowRead [D/lnk], D/lnk -> ../e: bound back - // at D/lnk, e would be readable there whatever is denied inside it. - // e was never hidden by D's tmpfs, so there is nothing to restore. - const D = join(ROOT, 's1', 'D') - const e = join(ROOT, 's1', 'e') - mkdirSync(D, { recursive: true }) - mkdirSync(join(e, 'sub'), { recursive: true }) - writeFileSync(join(e, 'sub', 'secret'), 'secret') - symlinkSync(join('..', 'e'), join(D, 'lnk')) - for (const denyOnly of [ - [D, join(e, 'sub')], - [join(D, 'lnk', 'sub'), D], - ]) { - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(D, 'lnk', 'sub', 'secret')} ${join(e, 'sub', 'secret')} || echo HIDDEN`, - needsNetworkRestriction: false, - readConfig: { denyOnly, allowWithinDeny: [join(D, 'lnk')] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, - }) - expect(wrapped).toContain(`--tmpfs ${D}`) - expect(wrapped).toContain(`--tmpfs ${join(e, 'sub')}`) - expect(wrapped).not.toContain(`--ro-bind ${join(D, 'lnk')}`) - if (hasBwrap) { - const stdout = run(wrapped) - expect(stdout).not.toContain('secret') - expect(stdout).toContain('HIDDEN') - } - } - }) + it.skipIf(!hasBwrap)( + 'serves the example file and not what the planted link points at', + async () => { + const proj = join(ROOT, 'g3', 'proj') + const stdout = runBooted( + await wrapPlantedExampleLink( + `echo BOOTED; cat ${join(proj, '.env.example')}; cat ${join(proj, 'sub', '.env.example')} ${join(proj, '.env')}; echo END`, + ), + ) + expect(stdout).toContain('EXAMPLE') + expect(stdout).not.toContain('ENVSECRET') + }, + ) - it('leaves a link inside a carve-out to lead where it leads, without a mount of its own', async () => { - // denyRead [t/private, D] + allowRead [D/x, D/x/lnk], D/x/lnk -> t: - // D/x is bound back from the host, live link included, so t is - // reached through it as on the host and t/private stays denied. A - // bind of D/x/lnk would land on t and bury that deny. - const D = join(ROOT, 's3', 'D') - const t = join(ROOT, 's3', 't') - mkdirSync(join(D, 'x'), { recursive: true }) - mkdirSync(join(t, 'private'), { recursive: true }) - writeFileSync(join(t, 'f'), 'T') - writeFileSync(join(t, 'private', 'key'), 'KEY') - symlinkSync(join('..', '..', 't'), join(D, 'x', 'lnk')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(D, 'x', 'lnk', 'f')}; cat ${join(t, 'private', 'key')} ${join(D, 'x', 'lnk', 'private', 'key')} || echo HIDDEN`, + // denyRead [denied, outside/sub] + allowRead [denied/lnk], denied/lnk -> + // ../outside: bound back at denied/lnk, `outside` would be readable + // there whatever is denied inside it. It was never hidden by the + // tmpfs on `denied`, so there is nothing to restore. + const outsideTreeOrders = [ + ['the denied directory first', 0], + ['the link spelling first', 1], + ] as const + function outsideTreeDenyOnly(order: number): string[] { + const denied = join(ROOT, 's1', 'D') + const outside = join(ROOT, 's1', 'e') + mkdirSync(denied, { recursive: true }) + mkdirSync(join(outside, 'sub'), { recursive: true }) + writeFileSync(join(outside, 'sub', 'secret'), 'secret') + ensureLink(join('..', 'e'), join(denied, 'lnk')) + return order === 0 + ? [denied, join(outside, 'sub')] + : [join(denied, 'lnk', 'sub'), denied] + } + function wrapOutsideTree( + command: string, + order: number, + ): Promise { + const denied = join(ROOT, 's1', 'D') + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { - denyOnly: [join(t, 'private'), D], - allowWithinDeny: [join(D, 'x'), join(D, 'x', 'lnk')], + denyOnly: outsideTreeDenyOnly(order), + allowWithinDeny: [join(denied, 'lnk')], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(wrapped).not.toContain(`--ro-bind ${join(D, 'x', 'lnk')}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stderr ?? '').not.toContain('symlink destination') - expect(run.stdout).toBe('THIDDEN\n') - } - }) - }) + } - it('denies a path the same way whatever order and spelling name it', async () => { - // l1 -> a/b, a/b/l2 -> t: l1/l2/f is t/f. Mounted where it really is, - // it is denied under every name, in either order of the two entries. - const R = join(ROOT, 's7') - mkdirSync(join(R, 'a', 'b'), { recursive: true }) - mkdirSync(join(R, 't')) - writeFileSync(join(R, 't', 'f'), 'FCONTENT') - symlinkSync(join('a', 'b'), join(R, 'l1')) - symlinkSync(join('..', '..', 't'), join(R, 'a', 'b', 'l2')) - const denied = [join(R, 'l1', 'l2', 'f'), join(R, 'a', 'b')] - for (const denyOnly of [denied, [...denied].reverse()]) { - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(R, 'l1', 'l2', 'f')} ${join(R, 't', 'f')}; echo END`, - needsNetworkRestriction: false, - readConfig: { denyOnly, allowWithinDeny: [join(R, 'a', 'b', 'l2')] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, + for (const [orderName, index] of outsideTreeOrders) { + it(`does not show a tree outside the denied directory under a name inside it, with ${orderName}`, async () => { + const denied = join(ROOT, 's1', 'D') + const outside = join(ROOT, 's1', 'e') + const wrapped = await wrapOutsideTree('true', index) + + expect(wrapped).toContain(`--tmpfs ${denied} `) + expect(wrapped).toContain(`--tmpfs ${join(outside, 'sub')} `) + expect(wrapped).not.toContain(`--ro-bind ${join(denied, 'lnk')}`) }) - expect(wrapped).toContain(`--ro-bind /dev/null ${join(R, 't', 'f')}`) - expect(wrapped).toContain(`--tmpfs ${join(R, 'a', 'b')}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('END\n') - } + + it.skipIf(!hasBwrap)( + `serves nothing of that tree under either name, with ${orderName}`, + async () => { + const denied = join(ROOT, 's1', 'D') + const outside = join(ROOT, 's1', 'e') + const stdout = runBooted( + await wrapOutsideTree( + `echo BOOTED; cat ${join(denied, 'lnk', 'sub', 'secret')} ${join(outside, 'sub', 'secret')} || echo HIDDEN`, + index, + ), + ) + expect(stdout).not.toContain('secret') + expect(stdout).toContain('HIDDEN') + }, + ) } - }) - it('mounts a directory that a link inside a denied directory leads back up to before that directory', async () => { - // x/secrets/latest -> .. (x, an allowed write root): the deny of the - // link is a deny of x, which the write bind cancels; mounted after - // x/secrets it would wipe that tmpfs and bind the secrets back. - const x = join(ROOT, 's5', 'x') - mkdirSync(join(x, 'secrets'), { recursive: true }) - writeFileSync(join(x, 'secrets', 'key'), 'KEY') - symlinkSync('..', join(x, 'secrets', 'latest')) - const denied = [join(x, 'secrets'), join(x, 'secrets', 'latest')] - for (const denyOnly of [denied, [...denied].reverse()]) { - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(x, 'secrets', 'key')} || echo HIDDEN`, + // denyRead [target/private, denied] + allowRead [denied/x, + // denied/x/lnk], denied/x/lnk -> target: denied/x is bound back from + // the host, live link included, so `target` is reached through it as on + // the host and target/private stays denied. A bind of denied/x/lnk + // would land on `target` and bury that deny. + function wrapLinkInsideCarveOut(command: string): Promise { + const denied = join(ROOT, 's3', 'D') + const target = join(ROOT, 's3', 't') + mkdirSync(join(denied, 'x'), { recursive: true }) + mkdirSync(join(target, 'private'), { recursive: true }) + writeFileSync(join(target, 'f'), 'T') + writeFileSync(join(target, 'private', 'key'), 'KEY') + ensureLink(join('..', '..', 't'), join(denied, 'x', 'lnk')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, - readConfig: { denyOnly }, - writeConfig: { allowOnly: [x], denyWithinAllow: [] }, - mandatoryDenySearchDepth: 1, + readConfig: { + denyOnly: [join(target, 'private'), denied], + allowWithinDeny: [join(denied, 'x'), join(denied, 'x', 'lnk')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect( - wrapped.lastIndexOf(`--tmpfs ${join(x, 'secrets')} `), - ).toBeGreaterThan(wrapped.lastIndexOf(`--bind ${x} ${x} `)) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('HIDDEN\n') - } } + + it('leaves a link inside a carve-out to lead where it leads, without a mount of its own', async () => { + const wrapped = await wrapLinkInsideCarveOut('true') + + expect(wrapped).not.toContain( + `--ro-bind ${join(ROOT, 's3', 'D', 'x', 'lnk')}`, + ) + }) + + it.skipIf(!hasBwrap)( + 'reads through that link as on the host, and still denies what it leads into', + async () => { + const denied = join(ROOT, 's3', 'D') + const target = join(ROOT, 's3', 't') + const stdout = runBooted( + await wrapLinkInsideCarveOut( + `echo BOOTED; cat ${join(denied, 'x', 'lnk', 'f')}; cat ${join(target, 'private', 'key')} ${join(denied, 'x', 'lnk', 'private', 'key')} || echo HIDDEN`, + ), + ) + expect(stdout).toBe('BOOTED\nTHIDDEN\n') + }, + ) }) - it('binds an allowed write path back only where it really is', async () => { - // D/L -> ../T with denyRead [D, D/L/sub] and allowWrite [T/sub/w]: - // T/sub/w is bound back once, at T/sub/w, under the deny binds and - // masks that protect it. Bound a second time beneath D/L it would be - // writable there with none of them on top. - const D = join(ROOT, 's6', 'D') - const T = join(ROOT, 's6', 'T') - const w = join(T, 'sub', 'w') - mkdirSync(D, { recursive: true }) - mkdirSync(join(w, '.git', 'hooks'), { recursive: true }) - writeFileSync(join(w, '.env'), 'ENV') - symlinkSync(join('..', 'T'), join(D, 'L')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: `touch ${join(D, 'L', 'sub', 'w', '.git', 'hooks', 'pre-commit')} ${join(w, '.git', 'hooks', 'pre-commit')} && echo WRITTEN; cat ${join(D, 'L', 'sub', 'w', '.env')} ${join(w, '.env')} || echo HIDDEN`, + // l1 -> a/b, a/b/l2 -> t: l1/l2/f is t/f. Mounted where it really is, it + // is denied under every name, in either order of the two entries. + function wrapTwoLinkSpellings( + command: string, + reversed: boolean, + ): Promise { + const caseRoot = join(ROOT, 's7') + mkdirSync(join(caseRoot, 'a', 'b'), { recursive: true }) + mkdirSync(join(caseRoot, 't'), { recursive: true }) + writeFileSync(join(caseRoot, 't', 'f'), 'FCONTENT') + ensureLink(join('a', 'b'), join(caseRoot, 'l1')) + ensureLink(join('..', '..', 't'), join(caseRoot, 'a', 'b', 'l2')) + const denied = [join(caseRoot, 'l1', 'l2', 'f'), join(caseRoot, 'a', 'b')] + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { - denyOnly: [D, join(D, 'L', 'sub'), join(w, '.env')], - allowWithinDeny: [join(D, 'L')], + denyOnly: reversed ? [...denied].reverse() : denied, + allowWithinDeny: [join(caseRoot, 'a', 'b', 'l2')], + }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + } + + for (const reversed of [false, true]) { + const order = reversed ? 'the directory first' : 'the file first' + + it(`denies a path the same way whatever spelling names it, with ${order}`, async () => { + const caseRoot = join(ROOT, 's7') + const wrapped = await wrapTwoLinkSpellings('true', reversed) + + expect(wrapped).toContain( + `--ro-bind /dev/null ${join(caseRoot, 't', 'f')}`, + ) + expect(wrapped).toContain(`--tmpfs ${join(caseRoot, 'a', 'b')} `) + }) + + it.skipIf(!hasBwrap)( + `serves that path through neither spelling, with ${order}`, + async () => { + const caseRoot = join(ROOT, 's7') + const stdout = runBooted( + await wrapTwoLinkSpellings( + `echo BOOTED; cat ${join(caseRoot, 'l1', 'l2', 'f')} ${join(caseRoot, 't', 'f')}; echo END`, + reversed, + ), + ) + expect(stdout).toBe('BOOTED\nEND\n') + }, + ) + } + + // x/secrets/latest -> .. (x, an allowed write root): the deny of the link + // is a deny of x, which the write bind cancels; mounted after x/secrets + // it would wipe that tmpfs and bind the secrets back. + function wrapLinkBackUpToWriteRoot( + command: string, + reversed: boolean, + ): Promise { + const writeRoot = join(ROOT, 's5', 'x') + mkdirSync(join(writeRoot, 'secrets'), { recursive: true }) + writeFileSync(join(writeRoot, 'secrets', 'key'), 'KEY') + ensureLink('..', join(writeRoot, 'secrets', 'latest')) + const denied = [ + join(writeRoot, 'secrets'), + join(writeRoot, 'secrets', 'latest'), + ] + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: reversed ? [...denied].reverse() : denied }, + writeConfig: { allowOnly: [writeRoot], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + } + + for (const reversed of [false, true]) { + const order = reversed ? 'the link first' : 'the directory first' + + it(`mounts a directory that a link inside a denied directory leads back up to before that directory, with ${order}`, async () => { + const writeRoot = join(ROOT, 's5', 'x') + const wrapped = await wrapLinkBackUpToWriteRoot('true', reversed) + + expect( + wrapped.lastIndexOf(`--tmpfs ${join(writeRoot, 'secrets')} `), + ).toBeGreaterThan( + wrapped.lastIndexOf(`--bind ${writeRoot} ${writeRoot} `), + ) + }) + + it.skipIf(!hasBwrap)( + `hides those secrets from the write root, with ${order}`, + async () => { + const writeRoot = join(ROOT, 's5', 'x') + const stdout = runBooted( + await wrapLinkBackUpToWriteRoot( + `echo BOOTED; cat ${join(writeRoot, 'secrets', 'key')} || echo HIDDEN`, + reversed, + ), + ) + expect(stdout).toBe('BOOTED\nHIDDEN\n') + }, + ) + } + + // denied/L -> ../T with denyRead [denied, denied/L/sub] and allowWrite + // [T/sub/writable]: T/sub/writable is bound back once, where it is, under + // the deny binds and masks that protect it. Bound a second time beneath + // denied/L it would be writable there with none of them on top. + function wrapWritePathUnderLink(command: string): Promise { + const denied = join(ROOT, 's6', 'D') + const target = join(ROOT, 's6', 'T') + const writable = join(target, 'sub', 'w') + mkdirSync(denied, { recursive: true }) + mkdirSync(join(writable, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(writable, '.env'), 'ENV') + ensureLink(join('..', 'T'), join(denied, 'L')) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [denied, join(denied, 'L', 'sub'), join(writable, '.env')], + allowWithinDeny: [join(denied, 'L')], }, writeConfig: { - allowOnly: [w], - denyWithinAllow: [join(w, '.git', 'hooks')], + allowOnly: [writable], + denyWithinAllow: [join(writable, '.git', 'hooks')], }, mandatoryDenySearchDepth: 1, }) + } + + it('binds an allowed write path back only where it really is', async () => { + const writable = join(ROOT, 's6', 'T', 'sub', 'w') + const wrapped = await wrapWritePathUnderLink('true') + const binds = wrapped .split(' --') - .filter(op => op.startsWith(`bind ${w} `)) - expect(binds.every(op => op.trim() === `bind ${w} ${w}`)).toBe(true) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).not.toContain('WRITTEN') - expect(run.stdout).not.toContain('ENV') - expect(run.stdout).toContain('HIDDEN') - } + .filter(op => op.startsWith(`bind ${writable} `)) + .map(op => op.trim()) + expect(binds.length).toBeGreaterThan(0) + expect([...new Set(binds)]).toEqual([`bind ${writable} ${writable}`]) }) - it('keeps a denyWrite bind whose path only passes through a read-denied directory', async () => { - // ln -> real/secretdir and real/secretdir/out -> elsewhere/hooks: - // denyWrite [real/secretdir/out] protects elsewhere/hooks, which the - // tmpfs on real/secretdir does not hide. - const root = join(ROOT, 's8', 'root') - const hooks = join(root, 'elsewhere', 'hooks') - mkdirSync(join(root, 'real', 'secretdir'), { recursive: true }) + it.skipIf(!hasBwrap)( + 'writes to that path through neither name, and reads its denied file through neither', + async () => { + const denied = join(ROOT, 's6', 'D') + const writable = join(ROOT, 's6', 'T', 'sub', 'w') + const stdout = runBooted( + await wrapWritePathUnderLink( + `echo BOOTED; touch ${join(denied, 'L', 'sub', 'w', '.git', 'hooks', 'pre-commit')} ${join(writable, '.git', 'hooks', 'pre-commit')} && echo WRITTEN; cat ${join(denied, 'L', 'sub', 'w', '.env')} ${join(writable, '.env')} || echo HIDDEN`, + ), + ) + expect(stdout).not.toContain('WRITTEN') + expect(stdout).not.toContain('ENV') + expect(stdout).toContain('HIDDEN') + }, + ) + + // ln -> real/secretdir and real/secretdir/out -> elsewhere/hooks: + // denyWrite [real/secretdir/out] protects elsewhere/hooks, which the + // tmpfs on real/secretdir does not hide. + function wrapDenyWriteThroughDeniedDir(command: string): Promise { + const caseRoot = join(ROOT, 's8', 'root') + const hooks = join(caseRoot, 'elsewhere', 'hooks') + mkdirSync(join(caseRoot, 'real', 'secretdir'), { recursive: true }) mkdirSync(hooks, { recursive: true }) - symlinkSync(join(root, 'real', 'secretdir'), join(root, 'ln')) - symlinkSync(hooks, join(root, 'real', 'secretdir', 'out')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: `touch ${join(hooks, 'x')} && echo WRITTEN || echo DENIED`, + ensureLink(join(caseRoot, 'real', 'secretdir'), join(caseRoot, 'ln')) + ensureLink(hooks, join(caseRoot, 'real', 'secretdir', 'out')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, - readConfig: { denyOnly: [join(root, 'ln')] }, + readConfig: { denyOnly: [join(caseRoot, 'ln')] }, writeConfig: { - allowOnly: [root], - denyWithinAllow: [join(root, 'real', 'secretdir', 'out')], + allowOnly: [caseRoot], + denyWithinAllow: [join(caseRoot, 'real', 'secretdir', 'out')], }, mandatoryDenySearchDepth: 1, }) - expect(wrapped).toContain(`--tmpfs ${join(root, 'real', 'secretdir')}`) + } + + it('keeps a denyWrite bind whose path only passes through a read-denied directory', async () => { + const caseRoot = join(ROOT, 's8', 'root') + const hooks = join(caseRoot, 'elsewhere', 'hooks') + const wrapped = await wrapDenyWriteThroughDeniedDir('true') + + expect(wrapped).toContain( + `--tmpfs ${join(caseRoot, 'real', 'secretdir')} `, + ) expect(wrapped).toContain(`--ro-bind ${hooks} ${hooks}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('DENIED\n') - } + }) + + it.skipIf(!hasBwrap)('refuses the write that bind protects', async () => { + const hooks = join(ROOT, 's8', 'root', 'elsewhere', 'hooks') + const stdout = runBooted( + await wrapDenyWriteThroughDeniedDir( + `echo BOOTED; touch ${join(hooks, 'x')} && echo WRITTEN || echo DENIED`, + ), + ) + expect(stdout).toBe('BOOTED\nDENIED\n') }) it('re-applies one mask for a file denied through a symlinked directory', async () => { - // W/lnk -> real, denyRead [W/lnk/secret], denyWrite [W]: the mask sits - // on W/real/secret alone, so the bind of W re-exposes one file and one - // mask goes back (a second on the same inode aborts bwrap before 0.5). - const W = join(ROOT, 's10', 'W') - mkdirSync(join(W, 'real'), { recursive: true }) - writeFileSync(join(W, 'real', 'secret'), 'S') - symlinkSync('real', join(W, 'lnk')) + // writeRoot/lnk -> real, denyRead [writeRoot/lnk/secret], denyWrite + // [writeRoot]: the mask sits on writeRoot/real/secret alone, so the + // bind of writeRoot re-exposes one file and one mask goes back (a + // second on the same inode aborts bwrap before 0.5). + const writeRoot = join(ROOT, 's10', 'W') + mkdirSync(join(writeRoot, 'real'), { recursive: true }) + writeFileSync(join(writeRoot, 'real', 'secret'), 'S') + symlinkSync('real', join(writeRoot, 'lnk')) const wrapped = await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, - readConfig: { denyOnly: [join(W, 'lnk', 'secret')] }, - writeConfig: { allowOnly: [W], denyWithinAllow: [W] }, + readConfig: { denyOnly: [join(writeRoot, 'lnk', 'secret')] }, + writeConfig: { allowOnly: [writeRoot], denyWithinAllow: [writeRoot] }, mandatoryDenySearchDepth: 1, }) - const mask = `--ro-bind /dev/null ${join(W, 'real', 'secret')}` + const mask = `--ro-bind /dev/null ${join(writeRoot, 'real', 'secret')}` const afterDenyBind = wrapped.slice( - wrapped.lastIndexOf(`--ro-bind ${W} ${W}`), + wrapped.lastIndexOf(`--ro-bind ${writeRoot} ${writeRoot}`), ) expect(afterDenyBind.split(mask)).toHaveLength(2) - expect(wrapped).not.toContain(`/dev/null ${join(W, 'lnk', 'secret')}`) + expect(wrapped).not.toContain( + `/dev/null ${join(writeRoot, 'lnk', 'secret')}`, + ) }) + // denyRead **/.env with the working directory an allowed write root and + // mode 0311 (what a sandboxed command can leave behind): the .env files + // beneath it cannot be enumerated, so the directory is denied whole, and + // binding the write root back over that tmpfs would show every one of + // them unmasked. + function unlistableCwd(): string { + const cwd = join(ROOT, 's11', 'cwd') + mkdirSync(join(cwd, 'svc'), { recursive: true }) + writeFileSync(join(cwd, '.env'), 'ENV1') + writeFileSync(join(cwd, 'svc', '.env'), 'ENV2') + chmodSync(cwd, 0o311) + return cwd + } + async function wrapUnlistableCwd(command: string): Promise { + const cwd = unlistableCwd() + const unlistableDenyDirs = new Set() + const denyOnly = expandReadDenyGlobLinux( + join(cwd, '**/.env'), + [cwd], + unlistableDenyDirs, + ) + expect(denyOnly).toEqual([cwd]) + expect([...unlistableDenyDirs]).toEqual([cwd]) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { + denyOnly, + unlistableDenyDirs: [...unlistableDenyDirs], + }, + writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + } + + it.if(process.getuid?.() !== 0)( + 'restores nothing beneath a directory the glob expansion could not list', + async () => { + try { + const cwd = join(ROOT, 's11', 'cwd') + const wrapped = await wrapUnlistableCwd('true') + + expect( + wrapped.slice(wrapped.indexOf(`--tmpfs ${cwd} `)), + ).not.toContain(`--bind ${cwd} ${cwd}`) + } finally { + chmodSync(join(ROOT, 's11', 'cwd'), 0o755) + } + }, + ) + + it.if(process.getuid?.() !== 0 && hasBwrap)( + 'serves none of the files beneath it', + async () => { + try { + const cwd = join(ROOT, 's11', 'cwd') + const stdout = runBooted( + await wrapUnlistableCwd( + `echo BOOTED; cat ${join(cwd, '.env')} ${join(cwd, 'svc', '.env')} || echo HIDDEN`, + ), + ) + expect(stdout).not.toContain('ENV1') + expect(stdout).not.toContain('ENV2') + expect(stdout).toContain('HIDDEN') + } finally { + chmodSync(join(ROOT, 's11', 'cwd'), 0o755) + } + }, + ) + it.if(process.getuid?.() !== 0)( - 'restores nothing beneath a read-denied directory it cannot list', + 'keeps the carve-outs of a literal deny of a directory it cannot list', async () => { - // denyRead **/.env with the working directory an allowed write root - // and mode 0311 (what a sandboxed command can leave behind): the - // .env files beneath it cannot be enumerated, so the directory is - // denied whole, and binding the write root back over that tmpfs - // would show every one of them unmasked. - const cwd = join(ROOT, 's11', 'cwd') - mkdirSync(join(cwd, 'svc'), { recursive: true }) - writeFileSync(join(cwd, '.env'), 'ENV1') - writeFileSync(join(cwd, 'svc', '.env'), 'ENV2') + // The same directory, denied literally: nothing was enumerated under + // it, so nothing is missing from the deny either, and the allowed + // write path inside it is bound back as on any other denied + // directory. Without that bind the build writes into the tmpfs and + // loses its output when the command exits. + const cwd = join(ROOT, 's15', 'cwd') + const out = join(cwd, 'out') + mkdirSync(out, { recursive: true }) + writeFileSync(join(cwd, '.env'), 'ENV') chmodSync(cwd, 0o311) try { - const denyOnly = expandReadDenyGlobLinux(join(cwd, '**/.env'), [cwd]) - expect(denyOnly).toEqual([cwd]) const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(cwd, '.env')} ${join(cwd, 'svc', '.env')} || echo HIDDEN`, + command: 'true', needsNetworkRestriction: false, - readConfig: { denyOnly }, - writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + readConfig: { denyOnly: [cwd] }, + writeConfig: { allowOnly: [out], denyWithinAllow: [] }, mandatoryDenySearchDepth: 1, }) + + const tmpfs = wrapped.lastIndexOf(`--tmpfs ${cwd} `) + expect(tmpfs).toBeGreaterThan(-1) expect( - wrapped.slice(wrapped.indexOf(`--tmpfs ${cwd}`)), - ).not.toContain(`--bind ${cwd} ${cwd}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).not.toContain('ENV') - expect(run.stdout).toContain('HIDDEN') - } + wrapped.indexOf(`--bind ${out} ${out} `, tmpfs), + ).toBeGreaterThan(tmpfs) } finally { chmodSync(cwd, 0o755) } }, ) + // proj/pkg is readable but not searchable (0600): its entries can be + // listed, so the pattern matches pkg/.env and finds pkg/build, but + // neither can be stat'ed. Skipped as absent, both would be readable once + // the mode is put back. + async function wrapUninspectableEntries(command: string): Promise { + const proj = join(ROOT, 's12', 'proj') + const pkg = join(proj, 'pkg') + mkdirSync(join(pkg, 'build'), { recursive: true }) + writeFileSync(join(pkg, '.env'), 'ENV') + writeFileSync(join(pkg, 'build', 'o'), 'OUT') + chmodSync(pkg, 0o600) + const denyOnly = [ + ...expandReadDenyGlobLinux(join(proj, '**/build/**'), [proj]), + ...expandReadDenyGlobLinux(join(proj, '**/.env'), [proj]), + ] + expect(denyOnly).toContain(join(pkg, '.env')) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly }, + writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + } + it.if(process.getuid?.() !== 0)( 'hides the nearest directory it can inspect when an entry cannot be looked at', async () => { - // proj/pkg is readable but not searchable (0600): its entries can be - // listed, so the pattern matches pkg/.env and finds pkg/build, but - // neither can be stat'ed. Skipped as absent, both would be readable - // once the mode is put back. - const proj = join(ROOT, 's12', 'proj') - const pkg = join(proj, 'pkg') - mkdirSync(join(pkg, 'build'), { recursive: true }) - writeFileSync(join(pkg, '.env'), 'ENV') - writeFileSync(join(pkg, 'build', 'o'), 'OUT') - chmodSync(pkg, 0o600) + const pkg = join(ROOT, 's12', 'proj', 'pkg') try { - const denyOnly = [ - ...expandReadDenyGlobLinux(join(proj, '**/build/**'), [proj]), - ...expandReadDenyGlobLinux(join(proj, '**/.env'), [proj]), - ] - expect(denyOnly).toContain(join(pkg, '.env')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: `chmod 755 ${pkg}; cat ${join(pkg, '.env')} ${join(pkg, 'build', 'o')} || echo HIDDEN`, - needsNetworkRestriction: false, - readConfig: { denyOnly }, - writeConfig: { allowOnly: [proj], denyWithinAllow: [] }, - mandatoryDenySearchDepth: 1, - }) - expect(wrapped).toContain(`--tmpfs ${pkg}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).not.toContain('ENV') - expect(run.stdout).not.toContain('OUT') - expect(run.stdout).toContain('HIDDEN') - } + expect(await wrapUninspectableEntries('true')).toContain( + `--tmpfs ${pkg} `, + ) + } finally { + chmodSync(pkg, 0o755) + } + }, + ) + + it.if(process.getuid?.() !== 0 && hasBwrap)( + 'serves neither entry once the command puts the mode back', + async () => { + const pkg = join(ROOT, 's12', 'proj', 'pkg') + try { + const stdout = runBooted( + await wrapUninspectableEntries( + `echo BOOTED; chmod 755 ${pkg}; cat ${join(pkg, '.env')} ${join(pkg, 'build', 'o')} || echo HIDDEN`, + ), + ) + expect(stdout).not.toContain('ENV') + expect(stdout).not.toContain('OUT') + expect(stdout).toContain('HIDDEN') } finally { chmodSync(pkg, 0o755) } }, ) - it('starts with a matched link to / in the tree', async () => { - // A sandboxed command with write access under the pattern's base can - // plant such a link; a mount on it would stop every later command. + /** + * A matched link whose target is there but cannot be looked at: the + * shape a sandboxed command leaves behind by making the target's + * directory unsearchable, which it can undo from inside the next + * sandbox. Dropped as "does not resolve", the deny would vanish for + * exactly that command. EACCES is injected through fs spies, since a + * root container sees no real one; the sandboxed command itself then + * runs against the real filesystem. + */ + async function wrapUninspectableLinkTarget( + command: string, + ): Promise<{ wrapped: string; certs: string; link: string }> { + const caseRootDir = join(ROOT, 's16') + const certs = join(caseRootDir, 'certs') + const secret = join(caseRootDir, 'secret') + const link = join(certs, 'k') + mkdirSync(certs, { recursive: true }) + mkdirSync(secret, { recursive: true }) + writeFileSync(join(secret, 'k'), 'KEYBYTES') + ensureLink(join('..', 'secret', 'k'), link) + + const unreachable = (p: string): boolean => + p === link || p.startsWith(secret + '/') || p === secret + const eacces = (p: fs.PathLike): never => { + throw Object.assign(new Error(`EACCES: permission denied, '${p}'`), { + code: 'EACCES', + }) + } + const realStat = fs.statSync + const realRealpath = fs.realpathSync + const spies = [ + spyOn(fs, 'statSync').mockImplementation((( + p: fs.PathLike, + ...rest: unknown[] + ) => + unreachable(String(p)) + ? eacces(p) + : (realStat as (...a: unknown[]) => unknown)( + p, + ...rest, + )) as typeof fs.statSync), + spyOn(fs, 'realpathSync').mockImplementation((( + p: fs.PathLike, + ...rest: unknown[] + ) => + unreachable(String(p)) + ? eacces(p) + : (realRealpath as (...a: unknown[]) => unknown)( + p, + ...rest, + )) as typeof fs.realpathSync), + ] + try { + const wrapped = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { + denyOnly: expandReadDenyGlobLinux(join(certs, '*'), []), + }, + writeConfig: { allowOnly: [caseRootDir], denyWithinAllow: [] }, + mandatoryDenySearchDepth: 1, + }) + return { wrapped, certs, link } + } finally { + for (const spy of spies) spy.mockRestore() + } + } + + it('hides what holds a matched link whose target cannot be looked at', async () => { + const { wrapped, certs, link } = await wrapUninspectableLinkTarget('true') + + // The deny reaches the mount loop under the link's own spelling, where + // the stand-in rule hides the nearest directory that can be inspected. + expect(wrapped).toContain(`--tmpfs ${certs} `) + expect(wrapped).not.toContain(`--tmpfs ${link} `) + }) + + it.skipIf(!hasBwrap)( + 'serves that link nothing, even once the command can look at the target again', + async () => { + const { wrapped, link } = await wrapUninspectableLinkTarget( + `echo BOOTED; cat ${join(ROOT, 's16', 'certs', 'k')} || echo HIDDEN; ls ${join(ROOT, 's16', 'certs')}`, + ) + const stdout = runBooted(wrapped) + + expect(stdout).not.toContain('KEYBYTES') + expect(stdout).toContain('HIDDEN') + expect(link).toBe(join(ROOT, 's16', 'certs', 'k')) + }, + ) + + // A sandboxed command with write access under the pattern's base can + // plant a link to the root; a mount on it, or on what it resolves to, + // would stop every later command. The directory holding it is hidden + // instead, by the same rule as an entry that cannot be inspected. + function wrapLinkToRoot(command: string): Promise { const proj = join(ROOT, 's13', 'proj') mkdirSync(join(proj, 'img'), { recursive: true }) - symlinkSync('/', join(proj, 'img', 'build')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: 'echo STARTED', + writeFileSync(join(proj, 'img', 'note.txt'), 'NOTE') + ensureLink('/', join(proj, 'img', 'build')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: [ ...expandReadDenyGlobLinux(join(proj, '**/build/**'), []), - // Named literally, the link is skipped by the loop as well. + // Named literally, the loop applies the same rule. join(proj, 'img', 'build'), ], }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, + allowAllUnixSockets: true, }) - expect(wrapped).not.toContain(`--tmpfs ${join(proj, 'img', 'build')}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('STARTED\n') - } + } + + it('hides the directory holding a matched link to / instead of the root', async () => { + const img = join(ROOT, 's13', 'proj', 'img') + const wrapped = await wrapLinkToRoot('true') + + expect(wrapped).toContain(`--tmpfs ${img} `) + expect(wrapped).not.toContain(`--tmpfs ${join(img, 'build')} `) + // A --tmpfs / would wipe every mount before it and boot the command on + // an empty tree, so it is never emitted, whatever a link resolves to. + expect(wrapped).not.toContain('--tmpfs / ') + }) + + it('keeps a link to / out of the read-deny prediction', async () => { + // The prediction says where the loop will mount a tmpfs, and every + // covering deny directory lies under '/'. Predicted there, no deny + // stub could be skipped anywhere on the host, and a write-denied + // checkout would refuse to start: bubblewrap cannot create a stub's + // mount point inside a read-only bind. A link to the root is predicted + // at the directory holding it, where the loop mounts it. + const work = join(ROOT, 's17', 'work') + const proj = join(work, 'proj') + mkdirSync(join(proj, 'img'), { recursive: true }) + ensureLink('/', join(proj, 'img', 'build')) + + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(proj, 'img', 'build')] }, + writeConfig: { + allowOnly: [work], + denyWithinAllow: [proj, join(proj, '.claude', 'settings.json')], + }, + mandatoryDenySearchDepth: 1, + }) + + expect(wrapped).toContain(`--tmpfs ${join(proj, 'img')} `) + expect(wrapped).not.toContain('--tmpfs / ') + // The absent deny path under the read-only bind of proj keeps no stub. + expect( + wrapped + .split(' --') + .filter(op => op.includes(` ${join(proj, '.claude')}`)), + ).toEqual([]) + }) + + it.skipIf(!hasBwrap)('starts, with that directory hidden', async () => { + const img = join(ROOT, 's13', 'proj', 'img') + const stdout = runBooted( + await wrapLinkToRoot( + `echo BOOTED; cat ${join(img, 'note.txt')} || echo HIDDEN; ls /`, + ), + ) + expect(stdout).not.toContain('NOTE') + expect(stdout).toContain('HIDDEN') + // The root itself is untouched: the command can still list it. + expect(stdout).toContain('usr') }) - it('starts in a write-denied checkout with a denyRead pattern matching a directory inside it', async () => { - // denyWrite [proj, proj/.claude/settings.json (absent)] + denyRead - // proj/**/build/**: collapsed, the pattern is a tmpfs beneath the - // write-denied directory. The absent deny path is uncreatable under - // proj's read-only bind either way, and a stub for it would have bwrap - // create a mount point inside that bind and abort. + // denyWrite [proj, proj/.claude/settings.json (absent)] + denyRead + // proj/**/build/**: collapsed, the pattern is a tmpfs beneath the + // write-denied directory. The absent deny path is uncreatable under + // proj's read-only bind either way, and a stub for it would have bwrap + // create a mount point inside that bind and abort. + async function wrapWriteDeniedCheckout(command: string): Promise { const work = join(ROOT, 's14', 'work') const proj = join(work, 'proj') mkdirSync(join(proj, 'pkg', 'build'), { recursive: true }) @@ -1087,8 +1579,8 @@ describe.if(isLinux)( const cwd = process.cwd() process.chdir(proj) try { - const wrapped = await wrapCommandWithSandboxLinux({ - command: `mkdir ${join(proj, '.claude')} || echo UNCREATABLE; cat ${join(proj, 'pkg', 'build', '1.out')} || echo HIDDEN`, + return await wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: expandReadDenyGlobLinux(join(proj, '**/build/**'), [ @@ -1101,59 +1593,81 @@ describe.if(isLinux)( }, mandatoryDenySearchDepth: 1, }) - const projBind = wrapped.lastIndexOf(`--ro-bind ${proj} ${proj}`) - expect(projBind).toBeGreaterThan(-1) - // No stub: nothing is mounted at or beneath proj/.claude. - expect( - wrapped - .slice(0, wrapped.indexOf(' --dev ')) - .split(' --') - .filter(op => op.includes(` ${join(proj, '.claude')}`)), - ).toEqual([]) - expect( - wrapped.lastIndexOf(`--tmpfs ${join(proj, 'pkg', 'build')}`), - ).toBeGreaterThan(projBind) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.stdout).toBe('UNCREATABLE\nHIDDEN\n') - } } finally { process.chdir(cwd) } + } + + it('keeps no deny stub in a write-denied checkout with a denyRead pattern matching a directory inside it', async () => { + const proj = join(ROOT, 's14', 'work', 'proj') + const wrapped = await wrapWriteDeniedCheckout('true') + + const projBind = wrapped.lastIndexOf(`--ro-bind ${proj} ${proj}`) + expect(projBind).toBeGreaterThan(-1) + // No stub: nothing is mounted at or beneath proj/.claude. + expect( + wrapped + .slice(0, wrapped.indexOf(' --dev ')) + .split(' --') + .filter(op => op.includes(` ${join(proj, '.claude')}`)), + ).toEqual([]) + expect( + wrapped.lastIndexOf(`--tmpfs ${join(proj, 'pkg', 'build')} `), + ).toBeGreaterThan(projBind) }) - it('still masks the target of a file symlink listed beneath a denied directory', async () => { - // denyRead [cfg, cfg/token], cfg/token -> ../secrets/token: the link - // vanishes with cfg's tmpfs, but the file it named is the target, - // which stays reachable by its own name and must be masked there. + it.skipIf(!hasBwrap)( + 'starts such a checkout, with the directory hidden and the absent deny path uncreatable', + async () => { + const proj = join(ROOT, 's14', 'work', 'proj') + const stdout = runBooted( + await wrapWriteDeniedCheckout( + `echo BOOTED; mkdir ${join(proj, '.claude')} || echo UNCREATABLE; cat ${join(proj, 'pkg', 'build', '1.out')} || echo HIDDEN`, + ), + ) + expect(stdout).toBe('BOOTED\nUNCREATABLE\nHIDDEN\n') + }, + ) + + // denyRead [cfg, cfg/token], cfg/token -> ../secrets/token: the link + // vanishes with cfg's tmpfs, but the file it named is the target, which + // stays reachable by its own name and must be masked there. + function wrapFileLinkBeneathDeniedDir(command: string): Promise { const cfg = join(ROOT, 's9', 'cfg') const secrets = join(ROOT, 's9', 'secrets') mkdirSync(cfg, { recursive: true }) mkdirSync(secrets, { recursive: true }) writeFileSync(join(secrets, 'token'), 'TOKEN') - symlinkSync(join('..', 'secrets', 'token'), join(cfg, 'token')) - const wrapped = await wrapCommandWithSandboxLinux({ - command: `cat ${join(secrets, 'token')}; echo`, + ensureLink(join('..', 'secrets', 'token'), join(cfg, 'token')) + return wrapCommandWithSandboxLinux({ + command, needsNetworkRestriction: false, readConfig: { denyOnly: [cfg, join(cfg, 'token')] }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) + } + + it('still masks the target of a file symlink listed beneath a denied directory', async () => { + const secrets = join(ROOT, 's9', 'secrets') + const wrapped = await wrapFileLinkBeneathDeniedDir('true') + expect(wrapped).toContain(`--ro-bind /dev/null ${join(secrets, 'token')}`) - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 15000, - }) - expect(run.status).toBe(0) - expect(run.stdout).not.toContain('TOKEN') - } }) + it.skipIf(!hasBwrap)( + 'serves that target masked under its own name', + async () => { + const secrets = join(ROOT, 's9', 'secrets') + const stdout = runBooted( + await wrapFileLinkBeneathDeniedDir( + `echo BOOTED; cat ${join(secrets, 'token')}; echo END`, + ), + ) + expect(stdout).not.toContain('TOKEN') + expect(stdout).toContain('END') + }, + ) + describe('a directory deny plus per-file entries beneath it', () => { let big: string let keys: string[] @@ -1171,7 +1685,7 @@ describe.if(isLinux)( readConfig: { denyOnly: [big, ...keys] }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(collapsed.split(`--tmpfs ${big}`)).toHaveLength(2) + expect(collapsed.split(`--tmpfs ${big} `)).toHaveLength(2) expect(collapsed).not.toContain(`/dev/null ${big}/`) }) @@ -1262,7 +1776,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { }, ) - expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', 'a', 'build')}`) + expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', 'a', 'build')} `) expect(wrapped).toContain( `--ro-bind /dev/null ${join(carveOut, 'ok.txt')}`, ) @@ -1274,7 +1788,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { } }) - it('seeds the ancestor pins from a collapsed directory, and pins hold at runtime', async () => { + it('seeds the ancestor pins from a collapsed directory', async () => { // Every tmpfs the collapse adds is an ordinary read-deny unit, so the // directories between it and the allowed write root are pinned: the // package directory above a collapsed build/ cannot be renamed aside to @@ -1297,7 +1811,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { expect(wrapped.indexOf(pin)).toBeLessThan( wrapped.indexOf(`--bind ${ROOT} ${ROOT}`), ) - expect(wrapped).toContain(`--tmpfs ${build}`) + expect(wrapped).toContain(`--tmpfs ${build} `) }) it.skipIf(!bwrapCanNamespace())( @@ -1349,7 +1863,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { }, }) - const tmpfs = wrapped.lastIndexOf(`--tmpfs ${build}`) + const tmpfs = wrapped.lastIndexOf(`--tmpfs ${build} `) expect(tmpfs).toBeGreaterThan(-1) // The deny's own bind is dropped (its ancestor pin, spelled the same, // sits beneath the write root's bind, so only what follows the tmpfs @@ -1427,7 +1941,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { ) for (const pkg of PKGS) { - expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', pkg, 'build')}`) + expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', pkg, 'build')} `) } for (const pkg of PKGS) { expect(wrapped).not.toContain( @@ -1522,7 +2036,7 @@ describe.if(isLinux)( rmSync(ROOT, { recursive: true, force: true }) }) - it.each([ + const LINK_PATTERNS = [ ['the wildcard segment names the link', 'l*/key.txt'], ['a bare star names the link', '*/key.txt'], ['a globstar reaches the link by name', '**/lnk/key.txt'], @@ -1530,9 +2044,14 @@ describe.if(isLinux)( ['the tail names the target directory', '**/cfg/key.txt'], ['the pattern spans depths through the link', 'l*/**/key.txt'], ['the link has an absolute target inside the base', 'a*/key.txt'], - ])('covers the file behind a directory link when %s', (_why, tail) => { - expect(expandReadDenyGlobLinux(join(BASE, tail), [])).toEqual([REALKEY]) - }) + ] as const + + it.each(LINK_PATTERNS)( + 'covers the file behind a directory link when %s', + (_why, tail) => { + expect(expandReadDenyGlobLinux(join(BASE, tail), [])).toEqual([REALKEY]) + }, + ) it('mounts a match behind a link whose target is outside the pattern base, at its target', () => { const mounts = expandReadDenyGlobLinux( @@ -1592,10 +2111,10 @@ describe.if(isLinux)( ]) }) - it.skipIf(!CAN_RUN)( - 'serves the file through neither spelling for a pattern that names the link', - async () => { - for (const tail of ['l*/key.txt', '*/key.txt', '**/lnk/key.txt']) { + for (const [why, tail] of LINK_PATTERNS) { + it.skipIf(!CAN_RUN)( + `serves the file through neither spelling when ${why}`, + async () => { const wrapped = await wrapCommandWithSandboxLinux({ command: `sh -c 'echo BOOTED; cat ${join(LNK, 'key.txt')} 2>&1; cat ${REALKEY} 2>&1; cat ${join(ABS, 'key.txt')} 2>&1'`, needsNetworkRestriction: false, @@ -1615,8 +2134,8 @@ describe.if(isLinux)( expect(result.stderr ?? '').not.toContain('bwrap:') expect(result.stdout).toContain('BOOTED') expect(result.stdout).not.toContain('KEYBYTES') - } - }, - ) + }, + ) + } }, ) diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index a092a5bb7..9245cca10 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -180,8 +180,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { // BEFORE the covering read-only bind, and with no read-deny tmpfs over it // there is no re-application to bind it back on top — so the whole // checkout is read-only in the sandbox and the absent dotfile denies need - // no stub. Vetoing on the nested allow alone kept them and aborted every - // command at startup inside the read-only bind. + // no stub, which bubblewrap could not create inside that bind anyway. process.chdir(PROJ) const out = join(PROJ, 'out') mkdirSync(out) @@ -371,7 +370,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).not.toContain( `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}`, ) - expect(command.lastIndexOf(`--tmpfs ${readDenied}`)).toBeGreaterThan( + expect(command.lastIndexOf(`--tmpfs ${readDenied} `)).toBeGreaterThan( projBind, ) }) @@ -566,35 +565,14 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).toContain(`--ro-bind '${secrets}' '${secrets}'`) }) - it('denies a glob-character read-deny spelled with a trailing slash', async () => { - // Same exemption on the read side, and the one spelling the strip in - // readDenyTargetOf is for: stat() of '/' is ENOTDIR, which reads as - // "the entry is not there" and dropped the deny outright, silently. The - // directory form is a non-regression check — '/' stats fine — and - // pins that the tmpfs is spelled without the slash. Neither spelling - // reaches the read section through the manager, which routes every - // glob-character entry through the glob expansion; this guards a direct - // caller of wrapCommandWithSandboxLinux. - const secretFile = join(PROJ, '[id].env') - writeFileSync(secretFile, 'SECRET=1\n') - const secretDir = join(PROJ, '[id]') - mkdirSync(secretDir) - writeFileSync(join(secretDir, 'token.txt'), 'x\n') - - const command = await wrap([], [`${secretFile}/`, `${secretDir}/`]) - - expect(command).toContain(`--ro-bind /dev/null '${secretFile}'`) - expect(command).toContain(`--tmpfs '${secretDir}'`) - }) - it('re-applies a denyWithinAllow bind under a trailing-slash allow re-bound over a denyRead tmpfs', async () => { // The emission filter drops deny binds hidden by a denyRead tmpfs // UNLESS an allowed write path the tmpfs restored covers them. That // exception tests containment root-aware, which a raw trailing-slash // allow spelling would defeat: the writable re-bind emitted but the deny // bind beneath it dropped, leaving the explicitly denied file writable. - // Non-regression, like the two above: the spelling is stripped before it - // is recorded. + // A non-regression check: the spelling is stripped before it is + // recorded. const nestedAllow = join(PROJ, 'w') mkdirSync(nestedAllow, { recursive: true }) const secret = join(nestedAllow, 'secret.txt') @@ -602,7 +580,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { const command = await wrap([secret], [PROJ], [`${nestedAllow}/`]) - expect(command).toContain(`--tmpfs ${PROJ}`) + expect(command).toContain(`--tmpfs ${PROJ} `) expect(command).toContain(`--bind ${nestedAllow} ${nestedAllow}`) expect(command).toContain(`--ro-bind ${secret} ${secret}`) }) @@ -969,9 +947,9 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { // `**/build/**` collapses to a tmpfs strictly inside the write-denied // checkout. With nothing writable configured under that checkout the // covering bind is the last word and the absent dotfile denies need no - // stub — the old veto on any tmpfs beneath the dir aborted every command - // of such a profile. Only an unusable prediction keeps them, because a - // prediction that failed is no evidence about this directory at all. + // stub, which bubblewrap could not create inside that bind anyway. Only + // an unusable prediction keeps them, because a prediction that failed is + // no evidence about this directory at all. process.chdir(PROJ) const build = join(PROJ, 'pkg', 'build') mkdirSync(build, { recursive: true }) @@ -979,7 +957,7 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { const stub = `--ro-bind /dev/null ${join(PROJ, '.gitconfig')}` const usable = await wrap([PROJ], [build]) - expect(usable).toContain(`--tmpfs ${build}`) + expect(usable).toContain(`--tmpfs ${build} `) expect(usable).not.toContain(stub) const realRealpathSync = fs.realpathSync diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index 4ca6c1b9d..607e2103d 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -16,6 +16,25 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' + +/** + * The root's symlinks into /usr (/bin, /lib, /sbin on a usr-merged system), + * listed here rather than inside a test: a `describe.if` body runs on every + * platform, and a root symlink may dangle (macOS runners have one). An empty + * list means the host is not usr-merged, and the case below has nothing to + * tell apart. + */ +const USR_MERGED_ROOT_LINKS = readdirSync('/', { withFileTypes: true }) + .filter(entry => entry.isSymbolicLink()) + .flatMap(entry => { + try { + return [realpathSync('/' + entry.name)] + } catch { + return [] + } + }) + .filter(target => target.startsWith('/usr/')) /** * Regression tests for symlinked deny paths (resolve-before-mask). @@ -36,7 +55,7 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { let PROJ: string // project dir containing the symlinks let DOTFILES: string // real directory the symlinks point into - const hasBwrap = spawnSync('bwrap', ['--version']).status === 0 + const hasBwrap = bwrapCanNamespace() beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'symlinked-deny-'))) @@ -170,58 +189,46 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { expect(result).not.toContain(`--ro-bind ${resolved} ${resolved}`) }) - it("does not deny the root's symlinks in their own right under a denyRead of /", async () => { - // /bin -> usr/bin and friends on a usr-merged system. Listed here, not - // while the suite is collected: that happens on every platform, and a - // root symlink may dangle (macOS runners have one). - const rootLinks = readdirSync('/', { withFileTypes: true }) - .filter(entry => entry.isSymbolicLink()) - .flatMap(entry => { - try { - return [realpathSync('/' + entry.name)] - } catch { - return [] - } + it.skipIf(USR_MERGED_ROOT_LINKS.length === 0)( + "does not deny the root's symlinks in their own right under a denyRead of /", + async () => { + // A '/' deny stands for the root's children, minus the ones an allowRead + // entry covers: /usr and /etc are named, and /bin, /lib and /sbin are + // links into /usr, so all five are skipped. Denied as entries of their + // own the links would be mounted where they lead and empty the very + // directories that allowRead names. + // allowAllUnixSockets keeps the apply-seccomp helper out of the command: + // where it has been built it lives in the checkout, which the '/' deny + // hides, and the shell would fail to exec it (allow-read.test.ts does the + // same for its '/' denies). + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'echo STARTED', + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/'], allowWithinDeny: ['/usr', '/etc'] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + allowAllUnixSockets: true, }) - .filter(target => target.startsWith('/usr/')) - if (rootLinks.length === 0) return // not usr-merged: nothing to tell apart - - // A '/' deny stands for the root's children, minus the ones an allowRead - // entry covers: /usr and /etc are named, and /bin, /lib and /sbin are - // links into /usr, so all five are skipped. Denied as entries of their - // own the links would be mounted where they lead and empty the very - // directories that allowRead names. - // allowAllUnixSockets keeps the apply-seccomp helper out of the command: - // where it has been built it lives in the checkout, which the '/' deny - // hides, and the shell would fail to exec it (allow-read.test.ts does the - // same for its '/' denies). - const wrapped = await wrapCommandWithSandboxLinux({ - command: 'echo STARTED', - needsNetworkRestriction: false, - readConfig: { denyOnly: ['/'], allowWithinDeny: ['/usr', '/etc'] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, - allowAllUnixSockets: true, - }) - expect(wrapped).not.toContain('--tmpfs /usr ') - expect(wrapped).not.toContain('--tmpfs /etc ') - for (const target of rootLinks) { - expect(wrapped).not.toContain(`--tmpfs ${target} `) - } - if (hasBwrap) { - const run = spawnSync(wrapped, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - // The whole outcome, so a failure says why the sandbox did not start. - expect({ - status: run.status, - stdout: run.stdout, - stderr: run.stderr, - }).toEqual({ status: 0, stdout: 'STARTED\n', stderr: '' }) - } - }) + expect(wrapped).not.toContain('--tmpfs /usr ') + expect(wrapped).not.toContain('--tmpfs /etc ') + for (const target of USR_MERGED_ROOT_LINKS) { + expect(wrapped).not.toContain(`--tmpfs ${target} `) + } + if (hasBwrap) { + const run = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + // The whole outcome, so a failure says why the sandbox did not start. + expect({ + status: run.status, + stdout: run.stdout, + stderr: run.stderr, + }).toEqual({ status: 0, stdout: 'STARTED\n', stderr: '' }) + } + }, + ) it('resolves the mandatory .claude deny paths when cwd/.claude is a symlink', async () => { const claudeLink = join(PROJ, '.claude') From 9945d57db37ff2af6714ac31de6801da602edbce Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 18:18:37 +0000 Subject: [PATCH 22/23] Pin the bubblewrap 0.12 build in CI, and describe the read-side rules as they are The integration leg cloned a mutable tag; it checks out the commit v0.12.0 points at today, and runs apt-get update before installing the build dependencies. README: say what a matched directory becomes, an empty writable tmpfs, and that a build writing through a read-denied directory keeps writing and loses its output at exit; that an allowRead glob is not expanded through symlinks; and that a link to '/' hides what holds it. The bullet that restated the read-side rules of the section below it is cut to what this change adds. --- .github/workflows/integration-tests.yml | 12 ++++++++---- README.md | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0680da75d..8d697a426 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -192,15 +192,19 @@ jobs: if: matrix.os != 'windows' run: npm test - # bubblewrap 0.12 refuses a mount on a symlink destination, which apt's - # 0.9.0 accepts; run the suite against it as well so a profile that - # mounts on a link is caught here, not on a user's machine. + # bubblewrap 0.12 refuses a mount on a symlink destination, which the + # distribution's packaged bubblewrap accepts; run the suite against it + # as well so a profile that mounts on a link is caught here, not on a + # user's machine. Pinned by commit, not by the v0.12.0 tag, which the + # upstream repository can move. - name: Build bubblewrap 0.12.0 (Linux) if: matrix.os == 'linux' run: | + sudo apt-get update sudo apt-get install -y -qq meson ninja-build libcap-dev pkg-config - git clone --depth 1 --branch v0.12.0 https://github.com/containers/bubblewrap.git "$RUNNER_TEMP/bubblewrap" + git clone --filter=blob:none https://github.com/containers/bubblewrap.git "$RUNNER_TEMP/bubblewrap" cd "$RUNNER_TEMP/bubblewrap" + git checkout 2a76602a8c71f36c1527cf9fc3417d9149822e0c meson setup _build --prefix="$RUNNER_TEMP/bwrap-0.12" -Dselinux=disabled -Dman=disabled -Dbash_completion=disabled -Dzsh_completion=disabled ninja -C _build ninja -C _build install diff --git a/README.md b/README.md index 4de1cd2e3..ff09142b0 100644 --- a/README.md +++ b/README.md @@ -387,10 +387,10 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. - `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone). -- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is empty and writable, writes into it never reach the host, and a file added to it later on the host is hidden too. A matched directory that is empty at that point gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable. -- A directory the expansion cannot list is denied as a whole, and nothing beneath it is bound back, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say) hides the nearest directory above it that can, in the same way. -- Symlinked directories are descended. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to `/` or to nothing is skipped. -- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox, and only when its name lives inside that directory (symlinked directories on the way to it resolved, its last component taken as written) and it resolves to somewhere inside it. What a symlink at an allowed path points to is not re-allowed on that account: replacing `docs` with a link to `~/.ssh` does not turn `allowRead: ["docs"]` into an exception to `denyRead: ["~/.ssh"]`. The same holds for a file: an `allowRead` entry lifts its mask only when it names that very file, not a symlink to it. +- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable. +- A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way. +- Symlinked directories are descended, one spelling per directory. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. +- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox. - `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it. Examples: From 0e6ddc5dcc6e747f09fd192c44496a60d5b656e5 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 21:19:19 +0000 Subject: [PATCH 23/23] test(linux): assert the collapse's mounts with the shared argv helper A mount is two or three whole argv words, and the helper now counts them as such. The trailing space these suites appended to a `--tmpfs ` needle, so that a fixture's build/ could not match its build-cache/ sibling, says the same thing less plainly and only while the next word happens to follow; counting mounts also spells out how many were expected, which a substring test cannot. The three places that scan for what follows a mount keep their character offsets, which the helper's argv indices are not comparable with. --- test/sandbox/read-deny-glob.test.ts | 79 +++++++++++++---------- test/sandbox/symlinked-deny-paths.test.ts | 13 ++-- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 82ae5f954..d667c229f 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -25,6 +25,7 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { countMounts, lastIndexOfMount } from '../helpers/bwrap-argv.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => { @@ -640,9 +641,11 @@ describe.if(isLinux)( writeConfig: { allowOnly: [pnpmRoot], denyWithinAllow: [store] }, }) - const storeBind = wrapped.lastIndexOf(`--ro-bind ${store} ${store}`) + const storeBind = lastIndexOfMount(wrapped, '--ro-bind', store, store) expect(storeBind).toBeGreaterThan(-1) - expect(wrapped.lastIndexOf(`--tmpfs ${real} `)).toBeGreaterThan(storeBind) + expect(lastIndexOfMount(wrapped, '--tmpfs', real)).toBeGreaterThan( + storeBind, + ) }) it('does not re-apply a tmpfs over its carve-out when a denyWrite bind covers only the link spelling', async () => { @@ -679,7 +682,7 @@ describe.if(isLinux)( expect(wrapped).toContain(mask) // One tmpfs on the target, and the mask is the last word on the file: // no carve-out re-bind after it. - expect(wrapped.split(`--tmpfs ${realdir} `)).toHaveLength(2) + expect(countMounts(wrapped, '--tmpfs', realdir)).toBe(1) expect(wrapped).toContain(carveOut) expect(wrapped.lastIndexOf(mask)).toBeGreaterThan( wrapped.lastIndexOf(carveOut), @@ -696,7 +699,7 @@ describe.if(isLinux)( }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(viaLink).toContain(`--tmpfs ${real} `) + expect(countMounts(viaLink, '--tmpfs', real)).toBeGreaterThan(0) expect(viaLink).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) @@ -712,7 +715,7 @@ describe.if(isLinux)( }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(viaLink).toContain(`--tmpfs ${real} `) + expect(countMounts(viaLink, '--tmpfs', real)).toBeGreaterThan(0) expect(viaLink).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) @@ -736,7 +739,7 @@ describe.if(isLinux)( it('re-binds a carve-out written through a symlinked directory outside the denied one, at its target', async () => { const wrapped = await wrapCarveOutThroughOutsideLink('true') - expect(wrapped).toContain(`--tmpfs ${real} `) + expect(countMounts(wrapped, '--tmpfs', real)).toBeGreaterThan(0) expect(wrapped).toContain( `--ro-bind ${join(real, 'public')} ${join(real, 'public')}`, ) @@ -781,9 +784,9 @@ describe.if(isLinux)( it('does not bind a denied directory back through an allowRead symlink to it', async () => { const wrapped = await wrapAllowReadLinkToDeniedDir('true') - expect(wrapped).toContain( - `--tmpfs ${join(ROOT, 'g1', 'home', '.ssh')} `, - ) + expect( + countMounts(wrapped, '--tmpfs', join(ROOT, 'g1', 'home', '.ssh')), + ).toBeGreaterThan(0) expect(wrapped).not.toContain( `--ro-bind ${join(ROOT, 'g1', 'proj', 'docs')}`, ) @@ -935,8 +938,10 @@ describe.if(isLinux)( const outside = join(ROOT, 's1', 'e') const wrapped = await wrapOutsideTree('true', index) - expect(wrapped).toContain(`--tmpfs ${denied} `) - expect(wrapped).toContain(`--tmpfs ${join(outside, 'sub')} `) + expect(countMounts(wrapped, '--tmpfs', denied)).toBeGreaterThan(0) + expect( + countMounts(wrapped, '--tmpfs', join(outside, 'sub')), + ).toBeGreaterThan(0) expect(wrapped).not.toContain(`--ro-bind ${join(denied, 'lnk')}`) }) @@ -1038,7 +1043,9 @@ describe.if(isLinux)( expect(wrapped).toContain( `--ro-bind /dev/null ${join(caseRoot, 't', 'f')}`, ) - expect(wrapped).toContain(`--tmpfs ${join(caseRoot, 'a', 'b')} `) + expect( + countMounts(wrapped, '--tmpfs', join(caseRoot, 'a', 'b')), + ).toBeGreaterThan(0) }) it.skipIf(!hasBwrap)( @@ -1088,9 +1095,9 @@ describe.if(isLinux)( const wrapped = await wrapLinkBackUpToWriteRoot('true', reversed) expect( - wrapped.lastIndexOf(`--tmpfs ${join(writeRoot, 'secrets')} `), + lastIndexOfMount(wrapped, '--tmpfs', join(writeRoot, 'secrets')), ).toBeGreaterThan( - wrapped.lastIndexOf(`--bind ${writeRoot} ${writeRoot} `), + lastIndexOfMount(wrapped, '--bind', writeRoot, writeRoot), ) }) @@ -1191,9 +1198,9 @@ describe.if(isLinux)( const hooks = join(caseRoot, 'elsewhere', 'hooks') const wrapped = await wrapDenyWriteThroughDeniedDir('true') - expect(wrapped).toContain( - `--tmpfs ${join(caseRoot, 'real', 'secretdir')} `, - ) + expect( + countMounts(wrapped, '--tmpfs', join(caseRoot, 'real', 'secretdir')), + ).toBeGreaterThan(0) expect(wrapped).toContain(`--ro-bind ${hooks} ${hooks}`) }) @@ -1366,9 +1373,9 @@ describe.if(isLinux)( async () => { const pkg = join(ROOT, 's12', 'proj', 'pkg') try { - expect(await wrapUninspectableEntries('true')).toContain( - `--tmpfs ${pkg} `, - ) + expect( + countMounts(await wrapUninspectableEntries('true'), '--tmpfs', pkg), + ).toBeGreaterThan(0) } finally { chmodSync(pkg, 0o755) } @@ -1467,8 +1474,8 @@ describe.if(isLinux)( // The deny reaches the mount loop under the link's own spelling, where // the stand-in rule hides the nearest directory that can be inspected. - expect(wrapped).toContain(`--tmpfs ${certs} `) - expect(wrapped).not.toContain(`--tmpfs ${link} `) + expect(countMounts(wrapped, '--tmpfs', certs)).toBeGreaterThan(0) + expect(countMounts(wrapped, '--tmpfs', link)).toBe(0) }) it.skipIf(!hasBwrap)( @@ -1513,11 +1520,11 @@ describe.if(isLinux)( const img = join(ROOT, 's13', 'proj', 'img') const wrapped = await wrapLinkToRoot('true') - expect(wrapped).toContain(`--tmpfs ${img} `) - expect(wrapped).not.toContain(`--tmpfs ${join(img, 'build')} `) + expect(countMounts(wrapped, '--tmpfs', img)).toBeGreaterThan(0) + expect(countMounts(wrapped, '--tmpfs', join(img, 'build'))).toBe(0) // A --tmpfs / would wipe every mount before it and boot the command on // an empty tree, so it is never emitted, whatever a link resolves to. - expect(wrapped).not.toContain('--tmpfs / ') + expect(countMounts(wrapped, '--tmpfs', '/')).toBe(0) }) it('keeps a link to / out of the read-deny prediction', async () => { @@ -1543,8 +1550,10 @@ describe.if(isLinux)( mandatoryDenySearchDepth: 1, }) - expect(wrapped).toContain(`--tmpfs ${join(proj, 'img')} `) - expect(wrapped).not.toContain('--tmpfs / ') + expect( + countMounts(wrapped, '--tmpfs', join(proj, 'img')), + ).toBeGreaterThan(0) + expect(countMounts(wrapped, '--tmpfs', '/')).toBe(0) // The absent deny path under the read-only bind of proj keeps no stub. expect( wrapped @@ -1602,7 +1611,7 @@ describe.if(isLinux)( const proj = join(ROOT, 's14', 'work', 'proj') const wrapped = await wrapWriteDeniedCheckout('true') - const projBind = wrapped.lastIndexOf(`--ro-bind ${proj} ${proj}`) + const projBind = lastIndexOfMount(wrapped, '--ro-bind', proj, proj) expect(projBind).toBeGreaterThan(-1) // No stub: nothing is mounted at or beneath proj/.claude. expect( @@ -1612,7 +1621,7 @@ describe.if(isLinux)( .filter(op => op.includes(` ${join(proj, '.claude')}`)), ).toEqual([]) expect( - wrapped.lastIndexOf(`--tmpfs ${join(proj, 'pkg', 'build')} `), + lastIndexOfMount(wrapped, '--tmpfs', join(proj, 'pkg', 'build')), ).toBeGreaterThan(projBind) }) @@ -1685,7 +1694,7 @@ describe.if(isLinux)( readConfig: { denyOnly: [big, ...keys] }, writeConfig: { allowOnly: [], denyWithinAllow: [] }, }) - expect(collapsed.split(`--tmpfs ${big} `)).toHaveLength(2) + expect(countMounts(collapsed, '--tmpfs', big)).toBe(1) expect(collapsed).not.toContain(`/dev/null ${big}/`) }) @@ -1776,7 +1785,9 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { }, ) - expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', 'a', 'build')} `) + expect( + countMounts(wrapped, '--tmpfs', join(ROOT, 'pkg', 'a', 'build')), + ).toBeGreaterThan(0) expect(wrapped).toContain( `--ro-bind /dev/null ${join(carveOut, 'ok.txt')}`, ) @@ -1811,7 +1822,7 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { expect(wrapped.indexOf(pin)).toBeLessThan( wrapped.indexOf(`--bind ${ROOT} ${ROOT}`), ) - expect(wrapped).toContain(`--tmpfs ${build} `) + expect(countMounts(wrapped, '--tmpfs', build)).toBeGreaterThan(0) }) it.skipIf(!bwrapCanNamespace())( @@ -1941,7 +1952,9 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { ) for (const pkg of PKGS) { - expect(wrapped).toContain(`--tmpfs ${join(ROOT, 'pkg', pkg, 'build')} `) + expect( + countMounts(wrapped, '--tmpfs', join(ROOT, 'pkg', pkg, 'build')), + ).toBeGreaterThan(0) } for (const pkg of PKGS) { expect(wrapped).not.toContain( diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index 607e2103d..755e4374b 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -17,6 +17,7 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { countMounts } from '../helpers/bwrap-argv.js' /** * The root's symlinks into /usr (/bin, /lib, /sbin on a usr-merged system), @@ -184,8 +185,10 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { const result = await wrap([join(claudeLink, 'commands')], [claudeLink]) const resolved = join(DOTFILES, 'claude', 'commands') - expect(result).toContain(`--tmpfs ${join(DOTFILES, 'claude')}`) - expect(result).not.toContain(`--tmpfs ${claudeLink}`) + expect( + countMounts(result, '--tmpfs', join(DOTFILES, 'claude')), + ).toBeGreaterThan(0) + expect(countMounts(result, '--tmpfs', claudeLink)).toBe(0) expect(result).not.toContain(`--ro-bind ${resolved} ${resolved}`) }) @@ -209,10 +212,10 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { allowAllUnixSockets: true, }) - expect(wrapped).not.toContain('--tmpfs /usr ') - expect(wrapped).not.toContain('--tmpfs /etc ') + expect(countMounts(wrapped, '--tmpfs', '/usr')).toBe(0) + expect(countMounts(wrapped, '--tmpfs', '/etc')).toBe(0) for (const target of USR_MERGED_ROOT_LINKS) { - expect(wrapped).not.toContain(`--tmpfs ${target} `) + expect(countMounts(wrapped, '--tmpfs', target)).toBe(0) } if (hasBwrap) { const run = spawnSync(wrapped, {