From 8f4c0662c3a709736c8ca2ede360d41b6e15354c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 2 Sep 2026 09:24:58 +0100 Subject: [PATCH 001/141] fix: mandatory denies cover nested hooks at repo depth, submodule git dirs, .git pointer files, and gitignored paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mandatory write-denies keep a sandboxed command from leaving behind something the host's git later executes. Four shapes slipped through: - Linux: the ripgrep scan matched a nested repository's hook FILES (`**/.git/hooks/**`), one segment deeper than its config, so at the default depth a repository directly under cwd had .git/config denied but .git/hooks writable. The scan now also matches `**/.git/HEAD`, and any file inside a `.git/` marks a repository whose hooks/ and config are denied, as for cwd's own .git. - Linux: rg honoured .gitignore/.ignore/.rgignore, which the command can write, so one command could hide a nested repository from the next command's scan. The scan passes --no-ignore. - Both: a submodule's git directory under .git/modules// (its hooks/ and config) matched nothing. Linux walks .git/modules for git directories; macOS adds **/.git/modules/**/{hooks/**,config}. - Both: a `.git` FILE (linked worktree or submodule checkout) could be repointed at a directory the command prepared. An existing one is now read-only — on macOS by vnode type, so .git directories are untouched and creating a new pointer is still allowed — and the hooks/config it leads to are denied: the named git directory's, or for a worktree the commondir's (the main repository's) plus an existing config.worktree. Also: the Linux match-to-directory mapping compared single segments against the two-segment names .claude/commands and .claude/agents, so a nested one got per-file binds and new files stayed creatable; names now match as segment runs on the cwd-relative path. --- README.md | 6 +- src/sandbox/linux-sandbox-utils.ts | 158 ++++++++++----- src/sandbox/macos-sandbox-utils.ts | 48 ++++- src/sandbox/sandbox-utils.ts | 54 +++++ test/sandbox/mandatory-deny-paths.test.ts | 237 +++++++++++++++++++++- 5 files changed, 449 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 14d896861..6deeeffad 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config` +- Git hooks and config: `.git/hooks/`, `.git/config` — in the working directory's repository, in nested repositories, and in the submodule git directories a repository keeps under `.git/modules/`. A `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only itself, and the hooks and config it leads to (the main repository's, for a worktree) are blocked as well; creating a new one is still allowed. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -678,9 +678,9 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach. macOS uses glob patterns which block both existing and new files. +**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance. You can configure this with `mandatoryDenySearchDepth`: +**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance (a path of at most three segments below the working directory, so a nested repository directly beneath it — `pkg/.git/HEAD` — is found and its hooks and config blocked). You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e31685cfe..10ff44ccd 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -18,6 +18,8 @@ import { encodeSandboxedCommand, DANGEROUS_FILES, getDangerousDirectories, + gitDirDenyPaths, + gitFileDenyPaths, } from './sandbox-utils.js' import type { FsReadRestrictionConfig, @@ -267,6 +269,42 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } +/** + * Git directories of the submodules kept under `modulesDir` (a repository's + * `.git/modules`), nested submodules included: a directory there holding a + * HEAD file is one, and its own `modules/` may hold more. A submodule's + * name is its path, so a git directory can sit several levels down + * (`modules/vendor/lib/HEAD`); the walk stops `maxDepth` levels in. + */ +function submoduleGitDirs(modulesDir: string, maxDepth: number): string[] { + const found: string[] = [] + const pending: Array<{ dir: string; depth: number }> = [ + { dir: modulesDir, depth: 0 }, + ] + while (pending.length > 0) { + const { dir, depth } = pending.pop()! + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const child = path.join(dir, entry.name) + if (fs.existsSync(path.join(child, 'HEAD'))) { + found.push(child) + if (depth + 1 < maxDepth) { + pending.push({ dir: path.join(child, 'modules'), depth: depth + 1 }) + } + } else if (depth + 1 < maxDepth) { + pending.push({ dir: child, depth: depth + 1 }) + } + } + } + return found +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. @@ -292,27 +330,31 @@ async function linuxGetMandatoryDenyPaths( ...dangerousDirectories.map(d => path.resolve(cwd, d)), ] - // Git hooks and config are only denied when .git exists as a directory. - // In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so - // .git/hooks can never exist — denying it would cause bwrap to fail. - // When .git doesn't exist at all, mounting at .git would block its + // cwd's own repository. A .git DIRECTORY gets its hooks/ and config + // denied, plus those of every submodule git directory it keeps under + // .git/modules (the hooks a `git commit` inside the submodule runs). A .git + // FILE (linked worktree, submodule checkout) is denied itself along with + // the hooks/config git consults through it (gitFileDenyPaths); .git/hooks + // beneath a file can never exist and denying it would make bwrap fail. When .git + // doesn't exist at all nothing is denied: a mount at .git would block its // creation and break git init. const dotGitPath = path.resolve(cwd, '.git') - let dotGitIsDirectory = false + let dotGitStat: fs.Stats | undefined try { - dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory() + dotGitStat = fs.statSync(dotGitPath) } catch { // .git doesn't exist } - - if (dotGitIsDirectory) { - // Git hooks always blocked for security - denyPaths.push(path.resolve(cwd, '.git/hooks')) - - // Git config conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - denyPaths.push(path.resolve(cwd, '.git/config')) + if (dotGitStat?.isDirectory()) { + denyPaths.push(...gitDirDenyPaths(dotGitPath, allowGitConfig)) + for (const moduleGitDir of submoduleGitDirs( + path.join(dotGitPath, 'modules'), + maxDepth, + )) { + denyPaths.push(...gitDirDenyPaths(moduleGitDir, allowGitConfig)) } + } else if (dotGitStat?.isFile()) { + denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig)) } // Build iglob args for all patterns in one ripgrep call @@ -323,23 +365,37 @@ async function linuxGetMandatoryDenyPaths( for (const dirName of dangerousDirectories) { iglobArgs.push('--iglob', `**/${dirName}/**`) } - // Git hooks always blocked in nested repos - iglobArgs.push('--iglob', '**/.git/hooks/**') - - // Git config conditionally blocked in nested repos + // A nested repository is recognised by any file directly inside its .git + // directory — HEAD is always there — so its hooks/ and config are denied + // at the depth the repository itself is found, not one level further down + // where the hook files sit (with the default depth, a repository directly + // under cwd has .git/config within reach but .git/hooks/* beyond it). + // A FILE named .git is a worktree/submodule pointer (gitFileDenyPaths). + iglobArgs.push( + '--iglob', + '**/.git/HEAD', + '--iglob', + '**/.git/hooks/**', + '--iglob', + '**/.git', + ) if (!allowGitConfig) { iglobArgs.push('--iglob', '**/.git/config') } // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare - // and the security benefit doesn't justify the traversal cost + // and the security benefit doesn't justify the traversal cost. + // --no-ignore: .gitignore, .ignore and .rgignore are writable inside the + // sandbox, so honouring them would let one command hide a nested + // repository from the next command's scan. let matches: string[] = [] try { matches = await ripGrep( [ '--files', '--hidden', + '--no-ignore', '--max-depth', String(maxDepth), ...iglobArgs, @@ -354,39 +410,43 @@ async function linuxGetMandatoryDenyPaths( logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`) } - // Process matches + // Each match is cwd-relative. One inside a dangerous directory (whose name + // may span segments: .claude/commands) denies the directory, so files + // created in it later are covered too. One inside a nested .git directory + // marks a repository: deny that directory's hooks/ and config. One that IS + // a file named .git is a worktree/submodule pointer. Anything else is a + // dangerous file denied by itself. Segments are compared on the relative + // path, so a dangerous name in cwd's own location never counts. + const dirPatterns = dangerousDirectories.map(d => + normalizeCaseForComparison(d).split('/'), + ) + const runAt = (segments: string[], parts: string[]): number => + segments.findIndex((_, i) => + parts.every((part, j) => segments[i + j] === part), + ) for (const match of matches) { - const absolutePath = path.resolve(cwd, match) - - // File inside a dangerous directory -> add the directory path - let foundDir = false - for (const dirName of [...dangerousDirectories, '.git']) { - const normalizedDirName = normalizeCaseForComparison(dirName) - const segments = absolutePath.split(path.sep) - const dirIndex = segments.findIndex( - s => normalizeCaseForComparison(s) === normalizedDirName, - ) - if (dirIndex !== -1) { - // For .git, we want hooks/ or config, not the whole .git dir - if (dirName === '.git') { - const gitDir = segments.slice(0, dirIndex + 1).join(path.sep) - if (match.includes('.git/hooks')) { - denyPaths.push(path.join(gitDir, 'hooks')) - } else if (match.includes('.git/config')) { - denyPaths.push(path.join(gitDir, 'config')) - } - } else { - denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep)) - } - foundDir = true - break - } + const relative = match.split('/') + const lowered = relative.map(normalizeCaseForComparison) + const absoluteOf = (n: number): string => + path.resolve(cwd, relative.slice(0, n).join('/')) + + const dirParts = dirPatterns.find(parts => runAt(lowered, parts) !== -1) + if (dirParts) { + denyPaths.push(absoluteOf(runAt(lowered, dirParts) + dirParts.length)) + continue } - - // Dangerous file match - if (!foundDir) { - denyPaths.push(absolutePath) + const gitAt = lowered.indexOf('.git') + if (gitAt !== -1 && gitAt < relative.length - 1) { + denyPaths.push(...gitDirDenyPaths(absoluteOf(gitAt + 1), allowGitConfig)) + continue + } + if (gitAt === relative.length - 1) { + denyPaths.push( + ...gitFileDenyPaths(absoluteOf(relative.length), allowGitConfig), + ) + continue } + denyPaths.push(absoluteOf(relative.length)) } return [...new Set(denyPaths)] diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 2cc6e5f96..c66b76023 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -1,5 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { spawn } from 'child_process' +import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' import { whichSync } from '../utils/which.js' @@ -13,6 +14,7 @@ import { containsGlobChars, globToRegex, DANGEROUS_FILES, + gitFileDenyPaths, getDangerousDirectories, } from './sandbox-utils.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' @@ -92,19 +94,49 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { denyPaths.push(`**/${dirName}/**`) } - // Git hooks are always blocked for security + // Git hooks are always blocked for security — in cwd's repository, in + // nested repositories, and in the submodule git directories a repository + // keeps under .git/modules (the hooks a commit inside the submodule runs) denyPaths.push(path.resolve(cwd, '.git/hooks')) denyPaths.push('**/.git/hooks/**') + denyPaths.push('**/.git/modules/**/hooks/**') // Git config - conditionally blocked based on allowGitConfig setting if (!allowGitConfig) { denyPaths.push(path.resolve(cwd, '.git/config')) denyPaths.push('**/.git/config') + denyPaths.push('**/.git/modules/**/config') + } + + // cwd checked out as a linked worktree or submodule: .git is a file + // pointing at the real git directory. The file is denied, and so are the + // hooks/config git consults through it (nested .git files are covered by + // gitPointerFileDenyFilter, by vnode type). + const dotGit = path.resolve(cwd, '.git') + try { + if (fs.statSync(dotGit).isFile()) { + denyPaths.push(...gitFileDenyPaths(dotGit, allowGitConfig)) + } + } catch { + // no .git here } return [...new Set(denyPaths)] } +/** + * SBPL filter for a regular file named `.git` anywhere under `cwd`: a linked + * worktree's or submodule checkout's `gitdir:` pointer, which repointed at a + * directory the command prepared is as good as writing that directory's + * config. Matched by vnode type so an ordinary repository's .git DIRECTORY + * stays writable. The write rules re-allow file-write-create for it: only + * an existing pointer is protected, and `git worktree add` / `git submodule + * update --init` can still lay down new ones. + */ +export function gitPointerFileDenyFilter(cwd: string): string { + return `(require-all (vnode-type REGULAR-FILE) (regex ${escapePath(globToRegex(path.join(cwd, '**', '.git')))}))` +} + export interface SandboxViolationEvent { line: string command?: string @@ -794,7 +826,21 @@ function generateWriteRules( for (const normalizedPath of ungrouped) { denyFilters.add(denyPathFilter(normalizedPath)) } + const gitPointerFilter = gitPointerFileDenyFilter( + normalizePathForSandbox('.'), + ) + denyFilters.add(gitPointerFilter) rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag)) + // An existing .git pointer file cannot be rewritten, replaced or removed; + // creating one where none exists stays possible. + rules.push( + ...renderRule( + 'allow', + ['file-write-create'], + new Set([gitPointerFilter]), + logTag, + ), + ) // Block file movement to prevent bypass via mv/rename. A grouped path // contributes its regex, the pin for its parent directory, and the diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 933585175..09e360c88 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -39,6 +39,60 @@ export function getDangerousDirectories(): string[] { ] } +/** + * The paths inside a git directory through which a write becomes code the + * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, + * core.hooksPath, …) unless the caller allows it. + */ +export function gitDirDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + return allowGitConfig + ? [path.join(gitDir, 'hooks')] + : [path.join(gitDir, 'hooks'), path.join(gitDir, 'config')] +} + +/** + * Deny paths for a `.git` FILE — a linked worktree's or submodule checkout's + * `gitdir:` pointer. The file itself is denied (repointing it at a directory + * the command prepared is as good as writing that directory's config), and + * so are the hooks/config git actually consults for it: those of the git + * directory it names (a submodule's, under the superproject's .git/modules), + * or, when that directory has a `commondir` (a linked worktree's), those of + * the common directory — the main repository's .git — plus the worktree's + * own config.worktree when one exists. + */ +export function gitFileDenyPaths( + gitFile: string, + allowGitConfig: boolean, +): string[] { + const denyPaths = [gitFile] + try { + const pointer = fs + .readFileSync(gitFile, 'utf8') + .match(/^gitdir:\s*(.+?)\s*$/m) + if (!pointer) return denyPaths + const gitDir = path.resolve(path.dirname(gitFile), pointer[1]!) + if (!fs.statSync(gitDir).isDirectory()) return denyPaths + let hooksAndConfigDir = gitDir + try { + const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8') + hooksAndConfigDir = path.resolve(gitDir, common.trim()) + const worktreeConfig = path.join(gitDir, 'config.worktree') + if (!allowGitConfig && fs.existsSync(worktreeConfig)) { + denyPaths.push(worktreeConfig) + } + } catch { + // no commondir: a submodule (or standalone) git directory + } + denyPaths.push(...gitDirDenyPaths(hooksAndConfigDir, allowGitConfig)) + } catch { + // Unreadable, or the pointer dangles: the file itself stays denied. + } + return denyPaths +} + /** * Normalizes a path for case-insensitive comparison. * This prevents bypassing security checks using mixed-case paths on case-insensitive diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index e82ca64bd..c98c5b0e1 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -112,6 +112,68 @@ describe.if(isSupportedPlatform)( ) writeFileSync(join(TEST_DIR, '.git', 'index'), ORIGINAL_CONTENT) + // A nested repository directly under cwd, with `nested/` gitignored: + // its config sits at the default scan depth, its hook files one past + // it, and an ignore file must not hide either from the scan. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'hooks'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'nested', 'src'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + writeFileSync(join(TEST_DIR, 'nested', 'src', 'ok.txt'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.gitignore'), 'nested/\nlib/\n') + // A submodule: its git directory lives under cwd's .git/modules and its + // checkout has a .git FILE pointing there. + mkdirSync(join(TEST_DIR, '.git', 'modules', 'lib', 'hooks'), { + recursive: true, + }) + writeFileSync(join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + mkdirSync(join(TEST_DIR, 'lib'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'lib', '.git'), + 'gitdir: ../.git/modules/lib', + ) + // A linked worktree of this repository checked out inside it: its + // .git file points at .git/worktrees/wt, whose commondir is the main + // .git — the hooks a commit in the worktree runs are the main ones. + mkdirSync(join(TEST_DIR, '.git', 'worktrees', 'wt'), { recursive: true }) + writeFileSync(join(TEST_DIR, '.git', 'worktrees', 'wt', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'worktrees', 'wt', 'commondir'), + '../..\n', + ) + mkdirSync(join(TEST_DIR, 'wt-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'wt-checkout', '.git'), + `gitdir: ${join(TEST_DIR, '.git', 'worktrees', 'wt')}`, + ) + // A nested .claude/commands one level down (a name spanning two + // segments), within reach only of a deeper scan. + mkdirSync(join(TEST_DIR, 'pkg', '.claude', 'commands'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'pkg', '.claude', 'commands', 'x.md'), + ORIGINAL_CONTENT, + ) + // Create safe file within .claude that SHOULD be writable (not commands/agents) writeFileSync( join(TEST_DIR, '.claude', 'some-other-file.txt'), @@ -139,9 +201,10 @@ describe.if(isSupportedPlatform)( async function runSandboxedWrite( filePath: string, content: string, + opts: { mandatoryDenySearchDepth?: number; append?: boolean } = {}, ): Promise<{ success: boolean; stderr: string }> { const platform = getPlatform() - const command = `echo '${content}' > '${filePath}'` + const command = `echo '${content}' ${opts.append ? '>>' : '>'} '${filePath}'` // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { @@ -163,6 +226,7 @@ describe.if(isSupportedPlatform)( needsNetworkRestriction: false, readConfig: undefined, writeConfig, + mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, }) } @@ -267,6 +331,158 @@ describe.if(isSupportedPlatform)( }) }) + describe('Nested repositories, submodules and worktree pointers', () => { + it("blocks writes to a nested repository's .git/config (gitignored or not)", async () => { + const result = await runSandboxedWrite( + 'nested/.git/config', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it("blocks writes to a nested repository's existing hook at the default depth", async () => { + // The hook file itself lies one level past the default scan depth; + // the repository is recognised by nested/.git/HEAD. + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it('blocks creating a new hook in a nested repository', async () => { + const result = await runSandboxedWrite( + 'nested/.git/hooks/post-checkout', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(existsSync('nested/.git/hooks/post-checkout')).toBe(false) + }) + + it('keeps the rest of a nested repository writable', async () => { + const result = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('nested/src/ok.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it("blocks writes to a submodule's config and hooks under .git/modules", async () => { + const config = await runSandboxedWrite( + '.git/modules/lib/config', + MODIFIED_CONTENT, + ) + expect(config.success).toBe(false) + expect(readFileSync('.git/modules/lib/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + + const hook = await runSandboxedWrite( + '.git/modules/lib/hooks/post-checkout', + MODIFIED_CONTENT, + ) + expect(hook.success).toBe(false) + expect(existsSync('.git/modules/lib/hooks/post-checkout')).toBe(false) + }) + + it("blocks repointing a submodule checkout's .git file", async () => { + const result = await runSandboxedWrite( + 'lib/.git', + 'gitdir: /tmp/elsewhere', + ) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + }) + + it('still lets a command create a .git file where none exists', async () => { + mkdirSync('fresh-checkout', { recursive: true }) + try { + const result = await runSandboxedWrite( + 'fresh-checkout/.git', + 'gitdir: ../.git/modules/fresh', + ) + + expect(result.success).toBe(true) + expect(readFileSync('fresh-checkout/.git', 'utf8').trim()).toBe( + 'gitdir: ../.git/modules/fresh', + ) + } finally { + rmSync('fresh-checkout', { recursive: true, force: true }) + } + }) + + it("from a linked worktree, blocks the main repository's hooks its commits would run", async () => { + // cwd is the worktree checkout; the main repository (TEST_DIR) is + // writable, so without following .git -> gitdir -> commondir its + // hooks/ would be too. + process.chdir(join(TEST_DIR, 'wt-checkout')) + const hook = join(TEST_DIR, '.git', 'hooks', 'pre-commit') + const command = `echo '${MODIFIED_CONTENT}' > '${hook}'` + const writeConfig = { allowOnly: [TEST_DIR], denyWithinAllow: [] } + const wrappedCommand = + getPlatform() === 'macos' + ? wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + }) + : await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + }) + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + expect(result.status).not.toBe(0) + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + }) + + it('denies a nested .claude/commands as a directory once the scan reaches it', async () => { + // pkg/.claude/commands/x.md is four segments deep: found with a + // depth of 4 (macOS matches by pattern at any depth), and then the + // whole directory is read-only, new files included. + const existing = await runSandboxedWrite( + 'pkg/.claude/commands/x.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(existing.success).toBe(false) + expect(readFileSync('pkg/.claude/commands/x.md', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + + const created = await runSandboxedWrite( + 'pkg/.claude/commands/new.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(created.success).toBe(false) + expect(existsSync('pkg/.claude/commands/new.md')).toBe(false) + }) + }) + describe('Dangerous directories should be blocked', () => { it('blocks writes to .vscode/', async () => { const result = await runSandboxedWrite( @@ -948,6 +1164,25 @@ describe.if(isSupportedPlatform)( // should not cause the sandbox to fail. expect(result.status).toBe(0) expect(result.stdout.trim()).toBe('hello') + cleanupBwrapMountPoints() + + // …and the pointer file itself is read-only: repointing it at a + // directory the command prepared would hand the host's git that + // directory's config and hooks. + const repoint = spawnSync( + await wrapCommandWithSandboxLinux({ + command: 'echo "gitdir: /tmp/evil" > .git', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }), + { shell: true, encoding: 'utf8', timeout: 10000 }, + ) + expect(repoint.status).not.toBe(0) + expect(readFileSync(join(worktreeDir, '.git'), 'utf8')).toBe( + 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', + ) cleanupBwrapMountPoints() } finally { From 1dbfe26804df1271fc3fdfb8e8cf5e359eaf3776 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 00:47:30 +0000 Subject: [PATCH 002/141] fix: bound the .git pointer create allow to the write roots; scan relative to cwd macOS: the create re-allow for `.git` pointer files followed the write denies unconditionally, so a command could create `sub/.git` under a cwd that allowOnly never covered. It is now intersected with the allowed write paths and dropped when there are none. The filter helper is no longer exported. Linux: rg prefixes each match with its target, so the segment scan also looked at cwd's own ancestors. With cwd under a directory named like a dangerous one (~/.vscode/ext/foo), a dangerous file below cwd mapped to a deny of that ancestor and was itself left writable. Matches are now taken relative to cwd. Also: - An unreadable directory under cwd made rg exit 2 and the scan discard every match it had printed. ripGrep now throws RipgrepError carrying the partial matches, and the scan keeps them and warns. - A nested repository's .git/modules is walked like cwd's own, matching what the macOS pattern already covers. - config.worktree is denied whether or not it exists yet, like hooks/ and config. - gitFileDenyPaths and submoduleGitDirs treat only ENOENT/ENOTDIR as "absent" and log anything else. - The git deny-path helpers move out of sandbox-utils.ts into mandatory-deny-paths.ts. - Tests: a regression case for each of the above and for the HEAD-only nested repository (allowGitConfig), multi-behaviour cases split, positive controls for the worktree cases, the unused `append` option dropped. --- README.md | 4 +- src/sandbox/linux-sandbox-utils.ts | 148 +++++++-------- src/sandbox/macos-sandbox-utils.ts | 60 +++--- src/sandbox/mandatory-deny-paths.ts | 108 +++++++++++ src/sandbox/sandbox-utils.ts | 54 ------ src/utils/ripgrep.ts | 24 ++- test/sandbox/mandatory-deny-paths.test.ts | 212 ++++++++++++++++------ test/utils/ripgrep.test.ts | 29 ++- 8 files changed, 404 insertions(+), 235 deletions(-) create mode 100644 src/sandbox/mandatory-deny-paths.ts diff --git a/README.md b/README.md index 6deeeffad..6f0d5e3b6 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config` — in the working directory's repository, in nested repositories, and in the submodule git directories a repository keeps under `.git/modules/`. A `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only itself, and the hooks and config it leads to (the main repository's, for a worktree) are blocked as well; creating a new one is still allowed. +- Git hooks and config: `.git/hooks/`, `.git/config`, in the working directory's repository, in nested repositories, and in the submodule git directories they keep under `.git/modules/`. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well; on macOS that holds for the working directory's own `.git` file only, since nested pointers are matched by pattern and not followed. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -680,7 +680,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance (a path of at most three segments below the working directory, so a nested repository directly beneath it — `pkg/.git/HEAD` — is found and its hooks and config blocked). You can configure this with `mandatoryDenySearchDepth`: +**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e3a95b6d2..64ca59ca5 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -7,7 +7,7 @@ import { spawn } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { tmpdir } from 'node:os' import path, { join } from 'node:path' -import { ripGrep } from '../utils/ripgrep.js' +import { ripGrep, RipgrepError } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { generateProxyEnvVars, @@ -20,9 +20,12 @@ import { isAtOrUnder, isStrictlyUnder, getDangerousDirectories, +} from './sandbox-utils.js' +import { gitDirDenyPaths, gitFileDenyPaths, -} from './sandbox-utils.js' + submoduleGitDirs, +} from './mandatory-deny-paths.js' import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, @@ -271,42 +274,6 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } -/** - * Git directories of the submodules kept under `modulesDir` (a repository's - * `.git/modules`), nested submodules included: a directory there holding a - * HEAD file is one, and its own `modules/` may hold more. A submodule's - * name is its path, so a git directory can sit several levels down - * (`modules/vendor/lib/HEAD`); the walk stops `maxDepth` levels in. - */ -function submoduleGitDirs(modulesDir: string, maxDepth: number): string[] { - const found: string[] = [] - const pending: Array<{ dir: string; depth: number }> = [ - { dir: modulesDir, depth: 0 }, - ] - while (pending.length > 0) { - const { dir, depth } = pending.pop()! - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(dir, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const child = path.join(dir, entry.name) - if (fs.existsSync(path.join(child, 'HEAD'))) { - found.push(child) - if (depth + 1 < maxDepth) { - pending.push({ dir: path.join(child, 'modules'), depth: depth + 1 }) - } - } else if (depth + 1 < maxDepth) { - pending.push({ dir: child, depth: depth + 1 }) - } - } - } - return found -} - /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. @@ -332,30 +299,34 @@ async function linuxGetMandatoryDenyPaths( ...dangerousDirectories.map(d => path.resolve(cwd, d)), ] - // cwd's own repository. A .git DIRECTORY gets its hooks/ and config - // denied, plus those of every submodule git directory it keeps under - // .git/modules (the hooks a `git commit` inside the submodule runs). A .git - // FILE (linked worktree, submodule checkout) is denied itself along with - // the hooks/config git consults through it (gitFileDenyPaths); .git/hooks - // beneath a file can never exist and denying it would make bwrap fail. When .git - // doesn't exist at all nothing is denied: a mount at .git would block its - // creation and break git init. + // A repository's hooks/ and config, and those of the submodule git + // directories under its .git/modules (what a commit inside the submodule + // runs). Called for cwd's .git and for each nested one the scan finds. + const seenGitDirs = new Set() + const denyGitDir = (gitDir: string): void => { + if (seenGitDirs.has(gitDir)) return + seenGitDirs.add(gitDir) + const moduleGitDirs = submoduleGitDirs( + path.join(gitDir, 'modules'), + maxDepth, + ) + for (const dir of [gitDir, ...moduleGitDirs]) { + denyPaths.push(...gitDirDenyPaths(dir, allowGitConfig)) + } + } + const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined try { dotGitStat = fs.statSync(dotGitPath) } catch { - // .git doesn't exist + // No .git: nothing is denied, since a mount at .git would block `git init`. } if (dotGitStat?.isDirectory()) { - denyPaths.push(...gitDirDenyPaths(dotGitPath, allowGitConfig)) - for (const moduleGitDir of submoduleGitDirs( - path.join(dotGitPath, 'modules'), - maxDepth, - )) { - denyPaths.push(...gitDirDenyPaths(moduleGitDir, allowGitConfig)) - } + denyGitDir(dotGitPath) } else if (dotGitStat?.isFile()) { + // A pointer file (linked worktree, submodule checkout) has no hooks/ + // beneath it, and binding a path under a file makes bwrap fail. denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig)) } @@ -368,7 +339,7 @@ async function linuxGetMandatoryDenyPaths( iglobArgs.push('--iglob', `**/${dirName}/**`) } // A nested repository is recognised by any file directly inside its .git - // directory — HEAD is always there — so its hooks/ and config are denied + // directory (HEAD is always there), so its hooks/ and config are denied // at the depth the repository itself is found, not one level further down // where the hook files sit (with the default depth, a repository directly // under cwd has .git/config within reach but .git/hooks/* beyond it). @@ -387,16 +358,16 @@ async function linuxGetMandatoryDenyPaths( // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare - // and the security benefit doesn't justify the traversal cost. - // --no-ignore: .gitignore, .ignore and .rgignore are writable inside the - // sandbox, so honouring them would let one command hide a nested - // repository from the next command's scan. + // and the security benefit doesn't justify the traversal cost let matches: string[] = [] try { matches = await ripGrep( [ '--files', '--hidden', + // .gitignore, .ignore and .rgignore are writable inside the sandbox: + // honouring them would let one command hide a nested repository + // from the next command's scan. '--no-ignore', '--max-depth', String(maxDepth), @@ -409,46 +380,53 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig, ) } catch (error) { - logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`) + // An unreadable directory makes rg exit non-zero after listing the rest + // of the tree; those matches still count. + if (error instanceof RipgrepError) { + matches = error.partialMatches + } + logForDebugging( + `[Sandbox] ripgrep scan failed, kept ${matches.length} partial matches; mandatory denies below cwd may be incomplete: ${error}`, + { level: 'warn' }, + ) } - // Each match is cwd-relative. One inside a dangerous directory (whose name - // may span segments: .claude/commands) denies the directory, so files - // created in it later are covered too. One inside a nested .git directory - // marks a repository: deny that directory's hooks/ and config. One that IS - // a file named .git is a worktree/submodule pointer. Anything else is a - // dangerous file denied by itself. Segments are compared on the relative - // path, so a dangerous name in cwd's own location never counts. const dirPatterns = dangerousDirectories.map(d => normalizeCaseForComparison(d).split('/'), ) - const runAt = (segments: string[], parts: string[]): number => - segments.findIndex((_, i) => - parts.every((part, j) => segments[i + j] === part), - ) for (const match of matches) { - const relative = match.split('/') + // rg prefixes each match with its target, cwd. Segments are compared + // relative to it, so a dangerous name in cwd's own location never counts. + const absolute = path.resolve(cwd, match) + const relative = path.relative(cwd, absolute).split(path.sep) + if (relative[0] === '..') { + denyPaths.push(absolute) + continue + } const lowered = relative.map(normalizeCaseForComparison) const absoluteOf = (n: number): string => - path.resolve(cwd, relative.slice(0, n).join('/')) + path.join(cwd, ...relative.slice(0, n)) + // Where `parts` first occurs as consecutive segments: a dangerous + // directory's name can span two (.claude/commands). + const runAt = (parts: string[]): number => + lowered.findIndex((_, i) => + parts.every((part, j) => lowered[i + j] === part), + ) - const dirParts = dirPatterns.find(parts => runAt(lowered, parts) !== -1) + const dirParts = dirPatterns.find(parts => runAt(parts) !== -1) if (dirParts) { - denyPaths.push(absoluteOf(runAt(lowered, dirParts) + dirParts.length)) + // The directory, not the file, so files created in it later are covered. + denyPaths.push(absoluteOf(runAt(dirParts) + dirParts.length)) continue } const gitAt = lowered.indexOf('.git') - if (gitAt !== -1 && gitAt < relative.length - 1) { - denyPaths.push(...gitDirDenyPaths(absoluteOf(gitAt + 1), allowGitConfig)) - continue - } - if (gitAt === relative.length - 1) { - denyPaths.push( - ...gitFileDenyPaths(absoluteOf(relative.length), allowGitConfig), - ) - continue + if (gitAt === -1) { + denyPaths.push(absolute) + } else if (gitAt < relative.length - 1) { + denyGitDir(absoluteOf(gitAt + 1)) + } else { + denyPaths.push(...gitFileDenyPaths(absolute, allowGitConfig)) } - denyPaths.push(absoluteOf(relative.length)) } return [...new Set(denyPaths)] diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index c66b76023..4c9c80b36 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -14,9 +14,9 @@ import { containsGlobChars, globToRegex, DANGEROUS_FILES, - gitFileDenyPaths, getDangerousDirectories, } from './sandbox-utils.js' +import { gitFileDenyPaths } from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' import type { @@ -94,11 +94,11 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { denyPaths.push(`**/${dirName}/**`) } - // Git hooks are always blocked for security — in cwd's repository, in - // nested repositories, and in the submodule git directories a repository - // keeps under .git/modules (the hooks a commit inside the submodule runs) + // Git hooks are always blocked for security denyPaths.push(path.resolve(cwd, '.git/hooks')) denyPaths.push('**/.git/hooks/**') + // A submodule's git directory (.git/modules//) is not matched by the + // pattern above; its hooks are what a commit inside the submodule runs. denyPaths.push('**/.git/modules/**/hooks/**') // Git config - conditionally blocked based on allowGitConfig setting @@ -108,10 +108,9 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { denyPaths.push('**/.git/modules/**/config') } - // cwd checked out as a linked worktree or submodule: .git is a file - // pointing at the real git directory. The file is denied, and so are the - // hooks/config git consults through it (nested .git files are covered by - // gitPointerFileDenyFilter, by vnode type). + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFileFilters), which cannot follow them. const dotGit = path.resolve(cwd, '.git') try { if (fs.statSync(dotGit).isFile()) { @@ -125,16 +124,12 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { } /** - * SBPL filter for a regular file named `.git` anywhere under `cwd`: a linked - * worktree's or submodule checkout's `gitdir:` pointer, which repointed at a - * directory the command prepared is as good as writing that directory's - * config. Matched by vnode type so an ordinary repository's .git DIRECTORY - * stays writable. The write rules re-allow file-write-create for it: only - * an existing pointer is protected, and `git worktree add` / `git submodule - * update --init` can still lay down new ones. + * SBPL filters that together match a regular file named `.git` anywhere + * under `cwd`, a `gitdir:` pointer. Matched by vnode type so a repository's + * .git DIRECTORY stays writable. */ -export function gitPointerFileDenyFilter(cwd: string): string { - return `(require-all (vnode-type REGULAR-FILE) (regex ${escapePath(globToRegex(path.join(cwd, '**', '.git')))}))` +function gitPointerFileFilters(cwd: string): string { + return `(vnode-type REGULAR-FILE) (regex ${escapePath(globToRegex(path.join(cwd, '**', '.git')))})` } export interface SandboxViolationEvent { @@ -826,21 +821,24 @@ function generateWriteRules( for (const normalizedPath of ungrouped) { denyFilters.add(denyPathFilter(normalizedPath)) } - const gitPointerFilter = gitPointerFileDenyFilter( - normalizePathForSandbox('.'), - ) - denyFilters.add(gitPointerFilter) + const gitPointer = gitPointerFileFilters(normalizePathForSandbox('.')) + denyFilters.add(`(require-all ${gitPointer})`) rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag)) - // An existing .git pointer file cannot be rewritten, replaced or removed; - // creating one where none exists stays possible. - rules.push( - ...renderRule( - 'allow', - ['file-write-create'], - new Set([gitPointerFilter]), - logTag, - ), - ) + // An existing pointer cannot be rewritten, replaced or removed; `git + // worktree add` and `git submodule update --init` still create new ones, + // but only inside the write roots, since this allow follows the denies. + // User and mandatory denies are re-applied to creation by the rule below. + if (allowFilters.size > 0) { + const createPointer = `(require-all ${gitPointer} (require-any ${[...allowFilters].join(' ')}))` + rules.push( + ...renderRule( + 'allow', + ['file-write-create'], + new Set([createPointer]), + logTag, + ), + ) + } // Block file movement to prevent bypass via mv/rename. A grouped path // contributes its regex, the pin for its parent directory, and the diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts new file mode 100644 index 000000000..5731a58d6 --- /dev/null +++ b/src/sandbox/mandatory-deny-paths.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs' +import * as path from 'path' +import { logForDebugging } from '../utils/debug.js' + +/** The path is absent, as opposed to unreadable or otherwise unverifiable. */ +function isAbsenceError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +/** + * The paths inside a git directory through which a write becomes code the + * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, + * core.hooksPath and the like) unless the caller allows it. + */ +export function gitDirDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + return allowGitConfig + ? [path.join(gitDir, 'hooks')] + : [path.join(gitDir, 'hooks'), path.join(gitDir, 'config')] +} + +/** + * Deny paths for a `.git` file, the `gitdir:` pointer of a linked worktree or + * submodule checkout: the file itself plus the hooks/ and config git reads + * through it (the named git directory's, or for a linked worktree its + * commondir's and the worktree's own config.worktree). + */ +export function gitFileDenyPaths( + gitFile: string, + allowGitConfig: boolean, +): string[] { + const denyPaths = [gitFile] + try { + const target = fs + .readFileSync(gitFile, 'utf8') + .match(/^gitdir:\s*(.+?)\s*$/m)?.[1] + if (target === undefined) return denyPaths + const gitDir = path.resolve(path.dirname(gitFile), target) + if (!fs.statSync(gitDir).isDirectory()) return denyPaths + let hooksAndConfigDir = gitDir + try { + const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8') + hooksAndConfigDir = path.resolve(gitDir, common.trim()) + if (!allowGitConfig) { + denyPaths.push(path.join(gitDir, 'config.worktree')) + } + } catch (err) { + // No commondir: a submodule's (or standalone) git directory. + if (!isAbsenceError(err)) throw err + } + denyPaths.push(...gitDirDenyPaths(hooksAndConfigDir, allowGitConfig)) + } catch (err) { + // A dangling pointer names nothing git would read. Any other failure + // leaves the hooks/config behind the pointer undenied. + if (!isAbsenceError(err)) { + logForDebugging( + `[Sandbox] Could not follow ${gitFile}, denying only the file itself: ${err}`, + { level: 'warn' }, + ) + } + } + return denyPaths +} + +/** + * Git directories of the submodules under `modulesDir` (a repository's + * .git/modules), nested submodules included. A submodule's name is its path, + * so one can sit several levels down (modules/vendor/lib), hence the walk. + */ +export function submoduleGitDirs( + modulesDir: string, + maxDepth: number, +): string[] { + const found: string[] = [] + const pending = [{ dir: modulesDir, depth: 0 }] + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const { dir, depth } = next + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent is the common case: no submodules, or none nested in this one. + if (!isAbsenceError(err)) { + logForDebugging( + `[Sandbox] Could not list ${dir}, submodule git directories beneath it are not denied: ${err}`, + { level: 'warn' }, + ) + } + continue + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const child = path.join(dir, entry.name) + if (fs.existsSync(path.join(child, 'HEAD'))) { + found.push(child) + if (depth + 1 < maxDepth) { + pending.push({ dir: path.join(child, 'modules'), depth: depth + 1 }) + } + } else if (depth + 1 < maxDepth) { + pending.push({ dir: child, depth: depth + 1 }) + } + } + } + return found +} diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index f98f6081e..f5d9bd8f1 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -39,60 +39,6 @@ export function getDangerousDirectories(): string[] { ] } -/** - * The paths inside a git directory through which a write becomes code the - * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, - * core.hooksPath, …) unless the caller allows it. - */ -export function gitDirDenyPaths( - gitDir: string, - allowGitConfig: boolean, -): string[] { - return allowGitConfig - ? [path.join(gitDir, 'hooks')] - : [path.join(gitDir, 'hooks'), path.join(gitDir, 'config')] -} - -/** - * Deny paths for a `.git` FILE — a linked worktree's or submodule checkout's - * `gitdir:` pointer. The file itself is denied (repointing it at a directory - * the command prepared is as good as writing that directory's config), and - * so are the hooks/config git actually consults for it: those of the git - * directory it names (a submodule's, under the superproject's .git/modules), - * or, when that directory has a `commondir` (a linked worktree's), those of - * the common directory — the main repository's .git — plus the worktree's - * own config.worktree when one exists. - */ -export function gitFileDenyPaths( - gitFile: string, - allowGitConfig: boolean, -): string[] { - const denyPaths = [gitFile] - try { - const pointer = fs - .readFileSync(gitFile, 'utf8') - .match(/^gitdir:\s*(.+?)\s*$/m) - if (!pointer) return denyPaths - const gitDir = path.resolve(path.dirname(gitFile), pointer[1]!) - if (!fs.statSync(gitDir).isDirectory()) return denyPaths - let hooksAndConfigDir = gitDir - try { - const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8') - hooksAndConfigDir = path.resolve(gitDir, common.trim()) - const worktreeConfig = path.join(gitDir, 'config.worktree') - if (!allowGitConfig && fs.existsSync(worktreeConfig)) { - denyPaths.push(worktreeConfig) - } - } catch { - // no commondir: a submodule (or standalone) git directory - } - denyPaths.push(...gitDirDenyPaths(hooksAndConfigDir, allowGitConfig)) - } catch { - // Unreadable, or the pointer dangles: the file itself stays denied. - } - return denyPaths -} - /** * Normalizes a path for case-insensitive comparison. * This prevents bypassing security checks using mixed-case paths on case-insensitive diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index c246d9171..3b84cb3c4 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -17,6 +17,20 @@ export function hasRipgrepSync(): boolean { return whichSync('rg') !== null } +/** + * ripgrep exited with an error status. `partialMatches` is what it listed + * before that: rg reports an unreadable directory with exit code 2 after + * printing every match it could reach. + */ +export class RipgrepError extends Error { + readonly partialMatches: string[] + + constructor(message: string, partialMatches: string[]) { + super(message) + this.partialMatches = partialMatches + } +} + /** * Execute ripgrep with the given arguments * @param args Command-line arguments to pass to rg @@ -24,7 +38,7 @@ export function hasRipgrepSync(): boolean { * @param abortSignal AbortSignal to cancel the operation * @param config Ripgrep configuration (command and optional args) * @returns Array of matching lines (one per line of output) - * @throws Error if ripgrep exits with non-zero status (except exit code 1 which means no matches) + * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ export async function ripGrep( args: string[], @@ -50,12 +64,16 @@ export async function ripGrep( }), ]) + const matches = stdout.trim().split('\n').filter(Boolean) if (code === 0) { - return stdout.trim().split('\n').filter(Boolean) + return matches } if (code === 1) { // Exit code 1 means "no matches found" - this is normal return [] } - throw new Error(`ripgrep failed with exit code ${code}: ${stderr}`) + throw new RipgrepError( + `ripgrep failed with exit code ${code}: ${stderr}`, + matches, + ) } diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index c98c5b0e1..06c043dc8 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -9,6 +9,7 @@ import { } from 'bun:test' import { spawn, spawnSync } from 'node:child_process' import { + chmodSync, mkdirSync, rmSync, writeFileSync, @@ -131,6 +132,14 @@ describe.if(isSupportedPlatform)( ) writeFileSync(join(TEST_DIR, 'nested', 'src', 'ok.txt'), ORIGINAL_CONTENT) writeFileSync(join(TEST_DIR, '.gitignore'), 'nested/\nlib/\n') + // The nested repository has a submodule of its own. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'hooks'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'HEAD'), + 'ref: x', + ) // A submodule: its git directory lives under cwd's .git/modules and its // checkout has a .git FILE pointing there. mkdirSync(join(TEST_DIR, '.git', 'modules', 'lib', 'hooks'), { @@ -152,7 +161,7 @@ describe.if(isSupportedPlatform)( ) // A linked worktree of this repository checked out inside it: its // .git file points at .git/worktrees/wt, whose commondir is the main - // .git — the hooks a commit in the worktree runs are the main ones. + // .git, so the hooks a commit in the worktree runs are the main ones. mkdirSync(join(TEST_DIR, '.git', 'worktrees', 'wt'), { recursive: true }) writeFileSync(join(TEST_DIR, '.git', 'worktrees', 'wt', 'HEAD'), 'ref: x') writeFileSync( @@ -173,6 +182,14 @@ describe.if(isSupportedPlatform)( join(TEST_DIR, 'pkg', '.claude', 'commands', 'x.md'), ORIGINAL_CONTENT, ) + // A working directory whose own location has a dangerous name in it. + mkdirSync(join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub', '.gitconfig'), + ORIGINAL_CONTENT, + ) // Create safe file within .claude that SHOULD be writable (not commands/agents) writeFileSync( @@ -201,14 +218,18 @@ describe.if(isSupportedPlatform)( async function runSandboxedWrite( filePath: string, content: string, - opts: { mandatoryDenySearchDepth?: number; append?: boolean } = {}, + opts: { + mandatoryDenySearchDepth?: number + allowGitConfig?: boolean + allowOnly?: string[] + } = {}, ): Promise<{ success: boolean; stderr: string }> { const platform = getPlatform() - const command = `echo '${content}' ${opts.append ? '>>' : '>'} '${filePath}'` + const command = `echo '${content}' > '${filePath}'` // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { - allowOnly: ['.'], + allowOnly: opts.allowOnly ?? ['.'], denyWithinAllow: [], // Empty - relying on mandatory denies } @@ -219,6 +240,7 @@ describe.if(isSupportedPlatform)( needsNetworkRestriction: false, readConfig: undefined, writeConfig, + allowGitConfig: opts.allowGitConfig, }) } else { wrappedCommand = await wrapCommandWithSandboxLinux({ @@ -227,6 +249,7 @@ describe.if(isSupportedPlatform)( readConfig: undefined, writeConfig, mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, + allowGitConfig: opts.allowGitConfig, }) } @@ -332,7 +355,7 @@ describe.if(isSupportedPlatform)( }) describe('Nested repositories, submodules and worktree pointers', () => { - it("blocks writes to a nested repository's .git/config (gitignored or not)", async () => { + it("blocks writes to a nested repository's .git/config even when gitignored", async () => { const result = await runSandboxedWrite( 'nested/.git/config', MODIFIED_CONTENT, @@ -345,8 +368,6 @@ describe.if(isSupportedPlatform)( }) it("blocks writes to a nested repository's existing hook at the default depth", async () => { - // The hook file itself lies one level past the default scan depth; - // the repository is recognised by nested/.git/HEAD. const result = await runSandboxedWrite( 'nested/.git/hooks/pre-commit', MODIFIED_CONTENT, @@ -358,6 +379,21 @@ describe.if(isSupportedPlatform)( ) }) + it("blocks a nested repository's hooks when only its HEAD is within the scan depth", async () => { + // With allowGitConfig the scan does not look for .git/config, and the + // hook files lie one level past the default depth. + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + it('blocks creating a new hook in a nested repository', async () => { const result = await runSandboxedWrite( 'nested/.git/hooks/post-checkout', @@ -380,24 +416,61 @@ describe.if(isSupportedPlatform)( ) }) - it("blocks writes to a submodule's config and hooks under .git/modules", async () => { - const config = await runSandboxedWrite( + it.if(isLinux && process.getuid?.() !== 0)( + 'keeps what the scan found when a directory under cwd is unreadable', + async () => { + mkdirSync('unreadable', { mode: 0o000 }) + try { + const result = await runSandboxedWrite( + 'nested/.git/config', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + chmodSync('unreadable', 0o755) + rmSync('unreadable', { recursive: true, force: true }) + } + }, + ) + + it("blocks writes to a submodule's config under .git/modules", async () => { + const result = await runSandboxedWrite( '.git/modules/lib/config', MODIFIED_CONTENT, ) - expect(config.success).toBe(false) + + expect(result.success).toBe(false) expect(readFileSync('.git/modules/lib/config', 'utf8')).toBe( ORIGINAL_CONTENT, ) + }) - const hook = await runSandboxedWrite( + it("blocks creating a hook in a submodule's git directory", async () => { + const result = await runSandboxedWrite( '.git/modules/lib/hooks/post-checkout', MODIFIED_CONTENT, ) - expect(hook.success).toBe(false) + + expect(result.success).toBe(false) expect(existsSync('.git/modules/lib/hooks/post-checkout')).toBe(false) }) + it("blocks creating a hook in a nested repository's submodule", async () => { + const result = await runSandboxedWrite( + 'nested/.git/modules/dep/hooks/post-checkout', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(existsSync('nested/.git/modules/dep/hooks/post-checkout')).toBe( + false, + ) + }) + it("blocks repointing a submodule checkout's .git file", async () => { const result = await runSandboxedWrite( 'lib/.git', @@ -427,36 +500,77 @@ describe.if(isSupportedPlatform)( } }) - it("from a linked worktree, blocks the main repository's hooks its commits would run", async () => { - // cwd is the worktree checkout; the main repository (TEST_DIR) is - // writable, so without following .git -> gitdir -> commondir its - // hooks/ would be too. - process.chdir(join(TEST_DIR, 'wt-checkout')) - const hook = join(TEST_DIR, '.git', 'hooks', 'pre-commit') - const command = `echo '${MODIFIED_CONTENT}' > '${hook}'` - const writeConfig = { allowOnly: [TEST_DIR], denyWithinAllow: [] } - const wrappedCommand = - getPlatform() === 'macos' - ? wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - }) - : await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - }) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, + it('does not let a .git file be created outside the allowed write paths', async () => { + const opts = { allowOnly: [join(TEST_DIR, 'nested', 'src')] } + mkdirSync('unlisted', { recursive: true }) + try { + const pointer = await runSandboxedWrite( + 'unlisted/.git', + 'gitdir: /tmp/elsewhere', + opts, + ) + expect(pointer.success).toBe(false) + expect(existsSync('unlisted/.git')).toBe(false) + + const control = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + } finally { + rmSync('unlisted', { recursive: true, force: true }) + } + }) + + describe('from a linked worktree checkout', () => { + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'wt-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'wt-checkout', 'notes.txt'), { force: true }) + }) + + it("blocks the main repository's hooks, which the worktree's commits run", async () => { + const hook = join(TEST_DIR, '.git', 'hooks', 'pre-commit') + const denied = await runSandboxedWrite(hook, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) }) - expect(result.status).not.toBe(0) - expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + it("blocks repointing the checkout's own .git file", async () => { + const original = readFileSync('.git', 'utf8') + const result = await runSandboxedWrite( + '.git', + 'gitdir: /tmp/elsewhere', + opts, + ) + + expect(result.success).toBe(false) + expect(readFileSync('.git', 'utf8')).toBe(original) + }) + }) + + it("matches dangerous names below cwd only, not in cwd's own location", async () => { + process.chdir(join(TEST_DIR, '.vscode', 'ext', 'foo')) + + const denied = await runSandboxedWrite( + 'sub/.gitconfig', + MODIFIED_CONTENT, + ) + expect(denied.success).toBe(false) + expect(readFileSync('sub/.gitconfig', 'utf8')).toBe(ORIGINAL_CONTENT) + + const control = await runSandboxedWrite('sub/ok.txt', MODIFIED_CONTENT) + expect(control.success).toBe(true) }) it('denies a nested .claude/commands as a directory once the scan reaches it', async () => { @@ -1166,24 +1280,6 @@ describe.if(isSupportedPlatform)( expect(result.stdout.trim()).toBe('hello') cleanupBwrapMountPoints() - // …and the pointer file itself is read-only: repointing it at a - // directory the command prepared would hand the host's git that - // directory's config and hooks. - const repoint = spawnSync( - await wrapCommandWithSandboxLinux({ - command: 'echo "gitdir: /tmp/evil" > .git', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - }), - { shell: true, encoding: 'utf8', timeout: 10000 }, - ) - expect(repoint.status).not.toBe(0) - expect(readFileSync(join(worktreeDir, '.git'), 'utf8')).toBe( - 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', - ) - cleanupBwrapMountPoints() } finally { process.chdir(originalDir) diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 26136d325..95a73c2e9 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'bun:test' -import { writeFileSync, mkdtempSync, rmSync } from 'fs' +import { chmodSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { ripGrep } from '../../src/utils/ripgrep.js' +import { ripGrep, RipgrepError } from '../../src/utils/ripgrep.js' describe('ripGrep', () => { it('finds matches with default config', async () => { @@ -79,4 +79,29 @@ describe('ripGrep', () => { ripGrep(['--invalid-flag-xyz'], '.', new AbortController().signal), ).rejects.toThrow(/ripgrep failed/) }) + + it.if(process.getuid?.() !== 0)( + 'hands back what rg listed before an unreadable directory failed the run', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-test-')) + mkdirSync(join(dir, 'locked')) + writeFileSync(join(dir, 'a.txt'), 'hello') + chmodSync(join(dir, 'locked'), 0o000) + try { + const error = await ripGrep( + ['--files'], + dir, + new AbortController().signal, + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).partialMatches).toEqual([ + join(dir, 'a.txt'), + ]) + } finally { + chmodSync(join(dir, 'locked'), 0o755) + rmSync(dir, { recursive: true }) + } + }, + ) }) From e6ea51c45fc2f9a464510c1ad4875b797c99431f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 03:25:42 +0000 Subject: [PATCH 003/141] fix(sandbox): close the git-metadata gaps in the mandatory denies commondir names the git directory whose hooks and config git reads, in any git directory and not only a linked worktree's, so one write to it redirects every deny below it. config.worktree is read instead of config wherever extensions.worktreeConfig is on. Both now come from gitDirDenyPaths, so every git directory gets them on both platforms. A repository is recognised by any regular file directly inside its .git (one `**/.git/*` glob replaces three) and a submodule git directory by any of HEAD/config/hooks/objects, so moving HEAD aside no longer hides either from the next command's scan. Detection no longer depends on allowGitConfig, which governs what is denied and not what is found. The three discovery paths fail closed. A directory ripgrep could not read is denied whole, from the paths it names in its own diagnostics; a directory the .git/modules walk could not list is denied whole; an unstattable pointer target is denied at the deepest ancestor still reachable; and a scan that does not finish inside its timeout aborts the wrap rather than sandboxing with a deny list of unknown completeness. A pointer target is followed only when it looks like a git directory, so `gitdir: ..` cannot point the deny list at an ordinary config/ tree, and an absent target is denied so the command cannot create it and fill it with hooks before the host's git first uses it. Pointer files are read as git reads them: a bounded prefix of a regular file, opened non-blocking, a prefix check and no backtracking regex, so a FIFO or a very large file left at that path cannot block or slow the host. On macOS the two mid-`**` module globs are gone, replaced by literal enumeration of the working directory's own submodule git directories plus a single-segment nested pattern - exact paths with ancestor pins, and no more denying refs/heads/config or a submodule named config. An existing .git pointer also gets a trailing file-write-unlink deny, which the read section's unlink re-allow for write roots was winning over whenever a read config was present. On Linux, placeholder destinations are deduped through seenDenyWrite, so one destination cannot take two different placeholder mounts and abort bwrap for every later command in that directory. ripgrep runs --null, so a path containing a newline is not split in two and a killed run's truncated tail is dropped rather than denied. The .git/modules walk has its own depth bound instead of borrowing the scan-depth knob, logs when the bound cuts it, and follows a symlinked modules entry. --- src/sandbox/linux-sandbox-utils.ts | 137 +++++++----- src/sandbox/macos-sandbox-utils.ts | 90 +++++--- src/sandbox/mandatory-deny-paths.ts | 332 +++++++++++++++++++++++----- src/utils/ripgrep.ts | 76 +++++-- 4 files changed, 482 insertions(+), 153 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 64ca59ca5..7a8b08760 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -8,6 +8,7 @@ import type { ChildProcess } from 'node:child_process' import { tmpdir } from 'node:os' import path, { join } from 'node:path' import { ripGrep, RipgrepError } from '../utils/ripgrep.js' +import type { RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { generateProxyEnvVars, @@ -77,7 +78,7 @@ export interface LinuxSandboxParams { enableWeakerNestedSandbox?: boolean allowAllUnixSockets?: boolean binShell?: string - ripgrepConfig?: { command: string; args?: string[] } + ripgrepConfig?: RipgrepConfig /** Maximum directory depth to search for dangerous files (default: 3) */ mandatoryDenySearchDepth?: number /** Allow writes to .git/config files (default: false) */ @@ -274,13 +275,50 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } +/** Where `parts` first occurs as consecutive segments of `segments`, or -1. */ +function indexOfSegmentRun(segments: string[], parts: string[]): number { + return segments.findIndex((_, i) => + parts.every((part, j) => segments[i + j] === part), + ) +} + +/** + * The paths under `cwd` a failed ripgrep run named in its diagnostics — the + * directories it could not read. Denying them is how the scan fails closed: + * their contents are unknown, so a nested repository inside one must not stay + * writable. rg reports `: `; a path holding `: ` is cut short + * at it, which denies an ancestor and so only ever denies more. + */ +function unreadablePathsFromRipgrepStderr( + stderr: string, + cwd: string, +): string[] { + const prefix = cwd + path.sep + const paths = new Set() + for (const line of stderr.split('\n')) { + const start = line.indexOf(prefix) + if (start === -1) continue + const rest = line.slice(start) + const end = rest.indexOf(': ') + const candidate = (end === -1 ? rest : rest.slice(0, end)).trimEnd() + if (candidate.length > prefix.length) paths.add(candidate) + } + return [...paths] +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. - * With --max-depth limiting, this is fast enough to run on each command without memoization. + * + * Runs on each command without memoization. `--max-depth` keeps that to + * milliseconds on ordinary trees, but `--no-ignore` means gitignored data + * within the depth is walked too: measured at about +100 ms per command on a + * tree with 150k ignored files three levels down. A scan that cannot finish + * inside {@link ripGrep}'s timeout aborts the wrap rather than sandboxing + * with a deny list of unknown completeness. */ async function linuxGetMandatoryDenyPaths( - ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' }, + ripgrepConfig: RipgrepConfig = { command: 'rg' }, maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, @@ -306,11 +344,9 @@ async function linuxGetMandatoryDenyPaths( const denyGitDir = (gitDir: string): void => { if (seenGitDirs.has(gitDir)) return seenGitDirs.add(gitDir) - const moduleGitDirs = submoduleGitDirs( - path.join(gitDir, 'modules'), - maxDepth, - ) - for (const dir of [gitDir, ...moduleGitDirs]) { + const modules = submoduleGitDirs(path.join(gitDir, 'modules')) + denyPaths.push(...modules.unreadableDirs) + for (const dir of [gitDir, ...modules.gitDirs]) { denyPaths.push(...gitDirDenyPaths(dir, allowGitConfig)) } } @@ -338,23 +374,15 @@ async function linuxGetMandatoryDenyPaths( for (const dirName of dangerousDirectories) { iglobArgs.push('--iglob', `**/${dirName}/**`) } - // A nested repository is recognised by any file directly inside its .git - // directory (HEAD is always there), so its hooks/ and config are denied - // at the depth the repository itself is found, not one level further down - // where the hook files sit (with the default depth, a repository directly - // under cwd has .git/config within reach but .git/hooks/* beyond it). + // A nested repository is recognised by ANY regular file directly inside its + // .git directory, so its hooks/ and config are denied at the depth the + // repository itself is found, not one level further down where the hook + // files sit (with the default depth, a repository directly under cwd has + // .git/config within reach but .git/hooks/* beyond it). Detection must not + // depend on any one file the sandboxed command could move aside, nor on + // allowGitConfig, which governs what is denied and not what is found. // A FILE named .git is a worktree/submodule pointer (gitFileDenyPaths). - iglobArgs.push( - '--iglob', - '**/.git/HEAD', - '--iglob', - '**/.git/hooks/**', - '--iglob', - '**/.git', - ) - if (!allowGitConfig) { - iglobArgs.push('--iglob', '**/.git/config') - } + iglobArgs.push('--iglob', '**/.git/*', '--iglob', '**/.git') // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare @@ -380,10 +408,20 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig, ) } catch (error) { - // An unreadable directory makes rg exit non-zero after listing the rest - // of the tree; those matches still count. + if (error instanceof RipgrepError && error.timedOut) { + // The command that runs next is the one that could have made the tree + // slow to walk, so a truncated listing is not something to sandbox on: + // an unreached nested repository would be one with writable hooks. + throw new Error( + `[Sandbox] ripgrep scan of ${cwd} did not finish; refusing to sandbox with mandatory denies of unknown completeness: ${error.message}`, + ) + } if (error instanceof RipgrepError) { + // An unreadable directory makes rg exit non-zero after listing the rest + // of the tree; those matches still count, and each directory it could + // not read is denied whole, since what it holds is unknown. matches = error.partialMatches + denyPaths.push(...unreadablePathsFromRipgrepStderr(error.stderr, cwd)) } logForDebugging( `[Sandbox] ripgrep scan failed, kept ${matches.length} partial matches; mandatory denies below cwd may be incomplete: ${error}`, @@ -395,37 +433,29 @@ async function linuxGetMandatoryDenyPaths( normalizeCaseForComparison(d).split('/'), ) for (const match of matches) { - // rg prefixes each match with its target, cwd. Segments are compared - // relative to it, so a dangerous name in cwd's own location never counts. - const absolute = path.resolve(cwd, match) - const relative = path.relative(cwd, absolute).split(path.sep) - if (relative[0] === '..') { - denyPaths.push(absolute) - continue - } + // rg prefixes each match with its target, cwd, and does not follow + // symlinks, so every line is under it. Segments are compared relative to + // cwd, so a dangerous name in cwd's own location never counts. + const relative = path.relative(cwd, match).split(path.sep) const lowered = relative.map(normalizeCaseForComparison) - const absoluteOf = (n: number): string => - path.join(cwd, ...relative.slice(0, n)) - // Where `parts` first occurs as consecutive segments: a dangerous - // directory's name can span two (.claude/commands). - const runAt = (parts: string[]): number => - lowered.findIndex((_, i) => - parts.every((part, j) => lowered[i + j] === part), - ) - const dirParts = dirPatterns.find(parts => runAt(parts) !== -1) - if (dirParts) { + const dirRun = dirPatterns + .map(parts => ({ parts, at: indexOfSegmentRun(lowered, parts) })) + .find(({ at }) => at !== -1) + if (dirRun) { // The directory, not the file, so files created in it later are covered. - denyPaths.push(absoluteOf(runAt(dirParts) + dirParts.length)) + const end = dirRun.at + dirRun.parts.length + denyPaths.push(path.join(cwd, ...relative.slice(0, end))) continue } const gitAt = lowered.indexOf('.git') if (gitAt === -1) { - denyPaths.push(absolute) + denyPaths.push(match) } else if (gitAt < relative.length - 1) { - denyGitDir(absoluteOf(gitAt + 1)) - } else { - denyPaths.push(...gitFileDenyPaths(absolute, allowGitConfig)) + denyGitDir(path.join(cwd, ...relative.slice(0, gitAt + 1))) + } else if (relative.length > 1) { + // cwd's own pointer file is handled above, before the scan. + denyPaths.push(...gitFileDenyPaths(match, allowGitConfig)) } } @@ -988,7 +1018,7 @@ async function generateFilesystemArgs( writeConfig: FsWriteRestrictionConfig | undefined, maskedFileBinds: Array<{ realPath: string; fakePath: string }> | undefined, maskedFileStoreDir: string | undefined, - ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' }, + ripgrepConfig: RipgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, @@ -1496,6 +1526,13 @@ async function generateFilesystemArgs( // of /dev/null. This prevents the component from appearing as a file // which breaks tools that expect to traverse it as a directory. if (firstNonExistent !== normalizedPath) { + // Absent deny paths under one absent directory share this + // destination (a git directory's hooks/ and config, say). A + // second bind would hit the first's mount point and bwrap + // aborts, taking every command in this cwd with it. The leaf + // case needs no check: normalizedPath is deduped above. + if (seenDenyWrite.has(firstNonExistent)) continue + seenDenyWrite.add(firstNonExistent) const emptyDir = fs.mkdtempSync( path.join(tmpdir(), 'claude-empty-'), ) diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4c9c80b36..f5a21ae35 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -16,7 +16,11 @@ import { DANGEROUS_FILES, getDangerousDirectories, } from './sandbox-utils.js' -import { gitFileDenyPaths } from './mandatory-deny-paths.js' +import { + gitDirDenyPaths, + gitFileDenyPaths, + submoduleGitDirs, +} from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' import type { @@ -75,8 +79,10 @@ export interface MacOSSandboxParams { } /** - * Get mandatory deny patterns as glob patterns (no filesystem scanning). - * macOS sandbox profile supports regex/glob matching directly via globToRegex(). + * Get mandatory deny patterns: glob patterns for what sits below cwd, which + * the macOS sandbox profile matches directly via globToRegex(), plus literal + * paths for the working directory's own repository. Reads cwd's `.git` and + * walks its `.git/modules`, so the result depends on the tree at cwd. */ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { const cwd = process.cwd() @@ -94,42 +100,52 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] { denyPaths.push(`**/${dirName}/**`) } - // Git hooks are always blocked for security - denyPaths.push(path.resolve(cwd, '.git/hooks')) - denyPaths.push('**/.git/hooks/**') - // A submodule's git directory (.git/modules//) is not matched by the - // pattern above; its hooks are what a commit inside the submodule runs. - denyPaths.push('**/.git/modules/**/hooks/**') - - // Git config - conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - denyPaths.push(path.resolve(cwd, '.git/config')) - denyPaths.push('**/.git/config') - denyPaths.push('**/.git/modules/**/config') + // Nested repositories and the submodule git directories they keep under + // .git/modules/ are matched by pattern: there is no scan on macOS, and a + // glob covers a git directory that does not exist yet as well. The + // submodule name is a single segment here — a nested repository's + // `vendor/lib` submodule is not covered — because a `**` in the middle + // would also match any component named config or hooks (a branch named + // feature/config, a submodule named config), which fails ordinary git + // operations that worked before. + for (const gitDirPattern of ['**/.git', '**/.git/modules/*']) { + denyPaths.push(...gitDirDenyPaths(gitDirPattern, allowGitConfig)) } - // cwd checked out as a linked worktree or submodule: .git is a pointer - // file. Nested pointer files are matched by vnode type instead - // (gitPointerFileFilters), which cannot follow them. + // The working directory's own repository is enumerated instead: literals + // are exact whatever a submodule is named, and each one pins its + // directories against being renamed out from under the deny. const dotGit = path.resolve(cwd, '.git') + denyPaths.push(...gitDirDenyPaths(dotGit, allowGitConfig)) + let dotGitStat: fs.Stats | undefined try { - if (fs.statSync(dotGit).isFile()) { - denyPaths.push(...gitFileDenyPaths(dotGit, allowGitConfig)) - } + dotGitStat = fs.statSync(dotGit) } catch { // no .git here } + if (dotGitStat?.isFile()) { + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFilter), which cannot follow them. + denyPaths.push(...gitFileDenyPaths(dotGit, allowGitConfig)) + } else if (dotGitStat?.isDirectory()) { + const modules = submoduleGitDirs(path.join(dotGit, 'modules')) + denyPaths.push(...modules.unreadableDirs) + for (const gitDir of modules.gitDirs) { + denyPaths.push(...gitDirDenyPaths(gitDir, allowGitConfig)) + } + } return [...new Set(denyPaths)] } /** - * SBPL filters that together match a regular file named `.git` anywhere - * under `cwd`, a `gitdir:` pointer. Matched by vnode type so a repository's - * .git DIRECTORY stays writable. + * SBPL filter matching a regular file named `.git` anywhere under cwd, a + * `gitdir:` pointer. Matched by vnode type so a repository's .git DIRECTORY + * stays writable. */ -function gitPointerFileFilters(cwd: string): string { - return `(vnode-type REGULAR-FILE) (regex ${escapePath(globToRegex(path.join(cwd, '**', '.git')))})` +function gitPointerFilter(): string { + return `(require-all (vnode-type REGULAR-FILE) ${pathFilter(normalizePathForSandbox('**/.git'))})` } export interface SandboxViolationEvent { @@ -821,13 +837,14 @@ function generateWriteRules( for (const normalizedPath of ungrouped) { denyFilters.add(denyPathFilter(normalizedPath)) } - const gitPointer = gitPointerFileFilters(normalizePathForSandbox('.')) - denyFilters.add(`(require-all ${gitPointer})`) + const gitPointer = gitPointerFilter() + denyFilters.add(gitPointer) rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag)) - // An existing pointer cannot be rewritten, replaced or removed; `git - // worktree add` and `git submodule update --init` still create new ones, - // but only inside the write roots, since this allow follows the denies. - // User and mandatory denies are re-applied to creation by the rule below. + // An existing pointer cannot be rewritten; `git worktree add` still creates + // new ones, but only inside the write roots, since this allow follows the + // denies. User and mandatory denies are re-applied to creation by the rule + // below, and removing or renaming over an existing pointer by the + // file-write-unlink deny after it. if (allowFilters.size > 0) { const createPointer = `(require-all ${gitPointer} (require-any ${[...allowFilters].join(' ')}))` rules.push( @@ -861,6 +878,15 @@ function generateWriteRules( ), ) + // Unlink only, so creating a pointer stays allowed: the rule above and the + // read section's re-allow of file-write-unlink for write roots would + // otherwise leave `rm lib/.git` and `mv evil lib/.git` open, which is the + // same rewrite the file-write* deny blocks. Emitted last; nothing after it + // in the profile re-allows unlink. + rules.push( + ...renderRule('deny', ['file-write-unlink'], new Set([gitPointer]), logTag), + ) + return rules } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 5731a58d6..cb2774ebe 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -8,25 +8,70 @@ function isAbsenceError(err: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR' } +/** + * How much of a `.git` pointer or a `commondir` is read. Both hold a single + * path, and git refuses a gitfile larger than 1 MiB, so a file this size is + * not one git would follow either. + */ +const MAX_GIT_METADATA_BYTES = 8192 + +/** + * Depth bound for the `.git/modules` walk. A submodule's name is its path + * (`vendor/lib`) and submodules nest, so the walk descends both name segments + * and nested `modules` directories; this bounds a hostile or looping tree, not + * a real one, and is deliberately unrelated to the ripgrep scan's depth. + */ +const MAX_SUBMODULE_WALK_DEPTH = 10 + +/** + * Entries whose presence makes a directory a git directory. git needs HEAD + * and objects; config and hooks are what this file protects, so a directory + * holding either is treated as one even if HEAD has been moved aside. + */ +const GIT_DIR_MARKERS = new Set(['HEAD', 'config', 'hooks', 'objects']) + +/** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ +type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' + +/** Directories found under a `.git/modules`, and what could not be read. */ +export interface SubmoduleScan { + /** The submodule git directories. */ + gitDirs: string[] + /** + * Directories the walk could not list. Their contents are unknown, so they + * are denied whole rather than left writable with a git directory possibly + * inside them. + */ + unreadableDirs: string[] +} + /** * The paths inside a git directory through which a write becomes code the - * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, - * core.hooksPath and the like) unless the caller allows it. + * host's git runs later: hooks/ always, `commondir` always (it redirects the + * hooks and config git reads to another directory entirely), and config plus + * `config.worktree` (core.fsmonitor, core.editor, core.hooksPath and the + * like, the latter read when extensions.worktreeConfig is on) unless the + * caller allows config writes. */ export function gitDirDenyPaths( gitDir: string, allowGitConfig: boolean, ): string[] { - return allowGitConfig - ? [path.join(gitDir, 'hooks')] - : [path.join(gitDir, 'hooks'), path.join(gitDir, 'config')] + const denyPaths = [path.join(gitDir, 'hooks'), path.join(gitDir, 'commondir')] + if (!allowGitConfig) { + denyPaths.push( + path.join(gitDir, 'config'), + path.join(gitDir, 'config.worktree'), + ) + } + return denyPaths } /** * Deny paths for a `.git` file, the `gitdir:` pointer of a linked worktree or * submodule checkout: the file itself plus the hooks/ and config git reads - * through it (the named git directory's, or for a linked worktree its - * commondir's and the worktree's own config.worktree). + * through it (the named git directory's, and for a linked worktree its + * commondir's as well). */ export function gitFileDenyPaths( gitFile: string, @@ -34,27 +79,31 @@ export function gitFileDenyPaths( ): string[] { const denyPaths = [gitFile] try { - const target = fs - .readFileSync(gitFile, 'utf8') - .match(/^gitdir:\s*(.+?)\s*$/m)?.[1] + const pointer = readGitMetadataFile(gitFile) + const target = + pointer === undefined ? undefined : parseGitdirPointer(pointer) if (target === undefined) return denyPaths const gitDir = path.resolve(path.dirname(gitFile), target) - if (!fs.statSync(gitDir).isDirectory()) return denyPaths - let hooksAndConfigDir = gitDir - try { - const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8') - hooksAndConfigDir = path.resolve(gitDir, common.trim()) - if (!allowGitConfig) { - denyPaths.push(path.join(gitDir, 'config.worktree')) - } - } catch (err) { - // No commondir: a submodule's (or standalone) git directory. - if (!isAbsenceError(err)) throw err + denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) + + // A linked worktree's git directory holds the path of the main one, whose + // hooks and config its commits run. + const commonFile = path.join(gitDir, 'commondir') + const common = readGitMetadataFile(commonFile) + const commonDir = + common === undefined + ? undefined + : path.resolve(gitDir, firstLine(common).trim()) + if (commonDir !== undefined && commonDir !== gitDir) { + denyPaths.push( + ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), + ) } - denyPaths.push(...gitDirDenyPaths(hooksAndConfigDir, allowGitConfig)) } catch (err) { - // A dangling pointer names nothing git would read. Any other failure - // leaves the hooks/config behind the pointer undenied. + // A dangling pointer names nothing git would read. A pointer this process + // cannot read is one the host's git cannot read either, so the file + // itself is the whole deny; an unreadable TARGET is denied whole by + // gitDirTargetDenyPaths instead. if (!isAbsenceError(err)) { logForDebugging( `[Sandbox] Could not follow ${gitFile}, denying only the file itself: ${err}`, @@ -70,39 +119,212 @@ export function gitFileDenyPaths( * .git/modules), nested submodules included. A submodule's name is its path, * so one can sit several levels down (modules/vendor/lib), hence the walk. */ -export function submoduleGitDirs( - modulesDir: string, - maxDepth: number, -): string[] { - const found: string[] = [] - const pending = [{ dir: modulesDir, depth: 0 }] - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const { dir, depth } = next - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(dir, { withFileTypes: true }) - } catch (err) { - // Absent is the common case: no submodules, or none nested in this one. - if (!isAbsenceError(err)) { - logForDebugging( - `[Sandbox] Could not list ${dir}, submodule git directories beneath it are not denied: ${err}`, - { level: 'warn' }, - ) - } +export function submoduleGitDirs(modulesDir: string): SubmoduleScan { + const scan: SubmoduleScan = { gitDirs: [], unreadableDirs: [] } + collectSubmoduleGitDirs(modulesDir, 0, scan, new Set()) + return scan +} + +function collectSubmoduleGitDirs( + dir: string, + depth: number, + scan: SubmoduleScan, + visited: Set, +): void { + const entries = listDirectory(dir, scan) + if (entries === undefined) return + for (const entry of entries) { + const child = path.join(dir, entry.name) + // git accepts a symlinked entry under .git/modules, and Dirent.isDirectory + // is false for one, so the link is followed — and the realpath recorded, + // since a link back up would otherwise loop until the depth bound. + if (!isDirectory(entry, child, scan)) continue + const visitKey = realPathOrSelf(child) + if (visited.has(visitKey)) continue + visited.add(visitKey) + + const childEntries = listDirectory(child, scan) + if (childEntries === undefined) continue + const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) + if (isGitDir) scan.gitDirs.push(child) + + if (depth + 1 >= MAX_SUBMODULE_WALK_DEPTH) { + logForDebugging( + `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}; submodule git directories beneath it are not denied`, + { level: 'warn' }, + ) continue } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const child = path.join(dir, entry.name) - if (fs.existsSync(path.join(child, 'HEAD'))) { - found.push(child) - if (depth + 1 < maxDepth) { - pending.push({ dir: path.join(child, 'modules'), depth: depth + 1 }) - } - } else if (depth + 1 < maxDepth) { - pending.push({ dir: child, depth: depth + 1 }) - } + collectSubmoduleGitDirs( + isGitDir ? path.join(child, 'modules') : child, + depth + 1, + scan, + visited, + ) + } +} + +/** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ +function listDirectory( + dir: string, + scan: SubmoduleScan, +): fs.Dirent[] | undefined { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent is the common case: no submodules, or none nested in this one. + if (!isAbsenceError(err)) { + const denied = deepestReachableAncestor(dir) ?? dir + scan.unreadableDirs.push(denied) + logForDebugging( + `[Sandbox] Could not list ${dir}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) } + return undefined + } +} + +/** Whether `entry` is a directory, following a symlink to one. */ +function isDirectory( + entry: fs.Dirent, + entryPath: string, + scan: SubmoduleScan, +): boolean { + if (entry.isDirectory()) return true + if (!entry.isSymbolicLink()) return false + try { + return fs.statSync(entryPath).isDirectory() + } catch (err) { + if (!isAbsenceError(err)) { + scan.unreadableDirs.push(deepestReachableAncestor(entryPath) ?? entryPath) + } + return false + } +} + +/** + * Deny paths for a directory a `gitdir:` or `commondir` names. An existing + * directory that is not a git directory is left alone: file content must not + * be able to point the deny list at, say, a Rails `config/`. An absent one is + * still denied, so the sandboxed command cannot create the target and fill it + * with hooks before the host's git first uses it. + */ +function gitDirTargetDenyPaths( + target: string, + allowGitConfig: boolean, + source: string, +): string[] { + const kind = gitDirKind(target) + switch (kind) { + case 'git-dir': + case 'absent': + return gitDirDenyPaths(target, allowGitConfig) + case 'unreadable': { + const denied = deepestReachableAncestor(target) ?? target + logForDebugging( + `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, + { level: 'warn' }, + ) + return [denied] + } + case 'other': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is not a git directory; denying only ${source}`, + { level: 'warn' }, + ) + return [] + } +} + +function gitDirKind(dir: string): GitDirKind { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + return isAbsenceError(err) ? 'absent' : 'unreadable' + } + return entries.some(e => e.name === 'HEAD' || e.name === 'objects') + ? 'git-dir' + : 'other' +} + +/** + * At most {@link MAX_GIT_METADATA_BYTES} of `file`, or undefined when it is + * absent, is not a regular file, or is longer than that. The path is one a + * sandboxed command may create: a FIFO there would block the host on every + * later wrap (hence O_NONBLOCK and the type check), and an arbitrarily large + * file would be buffered whole on every command. + */ +function readGitMetadataFile(file: string): string | undefined { + // O_NONBLOCK is POSIX-only; this file's callers are the Linux and macOS + // backends, and 0 leaves the flags as they were. + const nonBlocking = fs.constants.O_NONBLOCK ?? 0 + let fd: number + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | nonBlocking) + } catch (err) { + if (isAbsenceError(err)) return undefined + throw err + } + try { + // From the open file description, so it describes what was actually + // opened rather than what the path named a moment ago. + if (!fs.fstatSync(fd).isFile()) return undefined + const buffer = Buffer.alloc(MAX_GIT_METADATA_BYTES) + const read = fs.readSync(fd, buffer, 0, buffer.length, 0) + if (read === buffer.length) { + logForDebugging( + `[Sandbox] ${file} is larger than ${MAX_GIT_METADATA_BYTES} bytes, which is not a path git would follow; ignoring it`, + { level: 'warn' }, + ) + return undefined + } + return buffer.toString('utf8', 0, read) + } finally { + fs.closeSync(fd) + } +} + +/** + * The `gitdir:` target of a pointer file. git requires the prefix at byte 0 + * and trims only newline bytes from the end, and a path never spans lines. + */ +function parseGitdirPointer(contents: string): string | undefined { + const prefix = 'gitdir: ' + const line = firstLine(contents) + if (!line.startsWith(prefix)) return undefined + const target = line.slice(prefix.length) + return target.length > 0 ? target : undefined +} + +/** The first line, without the newline bytes git strips (`\n`, `\r`). */ +function firstLine(contents: string): string { + const end = contents.indexOf('\n') + return (end === -1 ? contents : contents.slice(0, end)).replace(/\r+$/, '') +} + +/** + * The deepest ancestor of `target` (itself included) this process can still + * stat. Denying that directory fails closed when the path below it cannot be + * inspected: nothing under it is writable in the sandbox. + */ +function deepestReachableAncestor(target: string): string | undefined { + for (let dir = target; ; dir = path.dirname(dir)) { + try { + if (fs.lstatSync(dir).isDirectory()) return dir + } catch { + // Unreachable at this level; try the parent. + } + if (path.dirname(dir) === dir) return undefined + } +} + +/** `target` with symlinks resolved, or itself when that fails. */ +function realPathOrSelf(target: string): string { + try { + return fs.realpathSync(target) + } catch { + return target } - return found } diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 3b84cb3c4..dd9ed71a1 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -7,8 +7,12 @@ export interface RipgrepConfig { args?: string[] /** Override argv[0] when spawning (for multicall binaries that dispatch on argv[0]) */ argv0?: string + /** How long the run may take before it is killed (default: 10 s). */ + timeoutMs?: number } +const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 + /** * Check if ripgrep (rg) is available synchronously * Returns true if rg is installed, false otherwise @@ -20,24 +24,41 @@ export function hasRipgrepSync(): boolean { /** * ripgrep exited with an error status. `partialMatches` is what it listed * before that: rg reports an unreadable directory with exit code 2 after - * printing every match it could reach. + * printing every match it could reach, and names each one in `stderr`. + * `timedOut` says the run was killed instead of finishing, so what it listed + * is a prefix of an unknown whole rather than everything it could reach. */ export class RipgrepError extends Error { readonly partialMatches: string[] + readonly stderr: string + readonly timedOut: boolean - constructor(message: string, partialMatches: string[]) { + constructor( + message: string, + partialMatches: string[], + stderr: string, + timedOut: boolean, + ) { super(message) this.partialMatches = partialMatches + this.stderr = stderr + this.timedOut = timedOut } } /** - * Execute ripgrep with the given arguments + * Execute ripgrep with the given arguments. + * + * The run is `--null`-delimited: a path may contain a newline, and a run cut + * short by the timeout can end mid-path, so line splitting would turn one + * path into two and hand back a truncated one. Output is split on NUL and an + * unterminated tail is dropped. + * * @param args Command-line arguments to pass to rg * @param target Target directory or file to search * @param abortSignal AbortSignal to cancel the operation * @param config Ripgrep configuration (command and optional args) - * @returns Array of matching lines (one per line of output) + * @returns Array of matching paths * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ export async function ripGrep( @@ -46,34 +67,57 @@ export async function ripGrep( abortSignal: AbortSignal, config: RipgrepConfig = { command: 'rg' }, ): Promise { - const { command, args: commandArgs = [], argv0 } = config + const { + command, + args: commandArgs = [], + argv0, + timeoutMs = DEFAULT_RIPGREP_TIMEOUT_MS, + } = config - const child = spawn(command, [...commandArgs, ...args, target], { + const child = spawn(command, [...commandArgs, '--null', ...args, target], { argv0, signal: abortSignal, - timeout: 10_000, + timeout: timeoutMs, windowsHide: true, }) - const [stdout, stderr, code] = await Promise.all([ + const [stdout, stderr, exit] = await Promise.all([ text(child.stdout), text(child.stderr), - new Promise((resolve, reject) => { - child.on('close', resolve) - child.on('error', reject) - }), + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.on('close', (code, signal) => resolve({ code, signal })) + child.on('error', reject) + }, + ), ]) - const matches = stdout.trim().split('\n').filter(Boolean) - if (code === 0) { + const matches = splitNullDelimited(stdout) + if (exit.code === 0) { return matches } - if (code === 1) { + if (exit.code === 1) { // Exit code 1 means "no matches found" - this is normal return [] } + // A null exit code means the child was killed rather than exiting. An + // abort rejects through the error handler above, so here it is the timeout. + const timedOut = exit.code === null throw new RipgrepError( - `ripgrep failed with exit code ${code}: ${stderr}`, + timedOut + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${timeoutMs} ms: ${stderr}` + : `ripgrep failed with exit code ${exit.code}: ${stderr}`, matches, + stderr, + timedOut, ) } + +/** NUL-terminated records, dropping an unterminated (truncated) last one. */ +function splitNullDelimited(output: string): string[] { + const records = output.split('\0') + // A complete run ends with a terminator, so the tail is empty; anything + // else is a record the run was cut off in the middle of. + records.pop() + return records.filter(Boolean) +} From af1e24101f890651bfbadc3d13f96cd55453bf00 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 03:25:42 +0000 Subject: [PATCH 004/141] test: cover the git-metadata deny paths and the NUL-delimited scan Integration cases for each gap: removing and renaming over a pointer file with a read config present, commondir and config.worktree in the working directory's repository, in a submodule git directory and in a nested repository, a repository and a submodule git directory whose HEAD was moved aside, a pointer naming an ordinary directory, a dangling pointer's target, a directory the scan could not read, and a scan that does not finish. Unit cases for the helpers: the pointer parse, the size cap, a FIFO at the pointer and at the commondir, multi-segment and nested submodule names, a symlinked modules entry, an unlistable directory and the walk's depth bound. ripGrep gains cases for the NUL split, the dropped truncated tail and the stderr its caller denies from. Also drops a duplicated cleanupBwrapMountPoints() call and a comment describing behaviour this branch had already replaced, and gates the unreadable-directory case on the platform rather than on getuid, which is undefined (and so not 0) on Windows. --- test/sandbox/macos-glob-deny-reemit.test.ts | 8 +- test/sandbox/mandatory-deny-paths.test.ts | 499 +++++++++++++++++++- test/utils/ripgrep.test.ts | 49 +- 3 files changed, 531 insertions(+), 25 deletions(-) diff --git a/test/sandbox/macos-glob-deny-reemit.test.ts b/test/sandbox/macos-glob-deny-reemit.test.ts index 1dc319733..2910f9c03 100644 --- a/test/sandbox/macos-glob-deny-reemit.test.ts +++ b/test/sandbox/macos-glob-deny-reemit.test.ts @@ -440,10 +440,10 @@ describe.if(isMacOS)('macOS write enforcement for glob denies', () => { expect(readFileSync(join(PROJECT, 'plain.txt'), 'utf8')).toBe('X\n') }) - it("mandatory **/.git/hooks/** still blocks a nested repo's hooks (regression guard)", () => { - // The mandatory patterns are anchored at process.cwd(); this pattern - // already carried its own /** tail before the subtree change, so this - // guards that the change keeps it working rather than fixing it. + it("mandatory **/.git/hooks still blocks a nested repo's hooks (regression guard)", () => { + // The mandatory patterns are anchored at process.cwd(). The pattern + // names the hooks directory itself, and the deny covers everything + // beneath it the way a literal subpath deny does. process.chdir(PROJECT) const hook = join(PROJECT, 'vendor', 'dep', '.git', 'hooks', 'pre-commit') const newHook = join( diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 06c043dc8..02d0d9439 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -11,6 +11,8 @@ import { spawn, spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, + mkdtempSync, + renameSync, rmSync, writeFileSync, readFileSync, @@ -29,7 +31,12 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' -import { isLinux, isSupportedPlatform } from '../helpers/platform.js' +import { + gitDirDenyPaths, + gitFileDenyPaths, + submoduleGitDirs, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' /** * Integration tests for mandatory deny paths. @@ -46,6 +53,12 @@ describe.if(isSupportedPlatform)( 'Mandatory Deny Paths - Integration Tests', () => { const TEST_DIR = join(tmpdir(), `mandatory-deny-integration-${Date.now()}`) + // A read-denied region outside cwd, so the read section emits its + // operation-specific unlink/create rules (which a deny has to survive). + const READ_DENY_DIR = join( + tmpdir(), + `mandatory-deny-readdeny-${Date.now()}`, + ) const ORIGINAL_CONTENT = 'ORIGINAL' const MODIFIED_CONTENT = 'MODIFIED' let originalCwd: string @@ -53,6 +66,8 @@ describe.if(isSupportedPlatform)( beforeAll(() => { originalCwd = process.cwd() mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(READ_DENY_DIR, { recursive: true }) + writeFileSync(join(READ_DENY_DIR, 'secret.txt'), ORIGINAL_CONTENT) // Create ALL dangerous files from DANGEROUS_FILES writeFileSync(join(TEST_DIR, '.bashrc'), ORIGINAL_CONTENT) @@ -201,6 +216,7 @@ describe.if(isSupportedPlatform)( afterAll(() => { process.chdir(originalCwd) rmSync(TEST_DIR, { recursive: true, force: true }) + rmSync(READ_DENY_DIR, { recursive: true, force: true }) }) beforeEach(() => { @@ -215,17 +231,23 @@ describe.if(isSupportedPlatform)( cleanupBwrapMountPoints({ force: true }) }) - async function runSandboxedWrite( - filePath: string, - content: string, - opts: { - mandatoryDenySearchDepth?: number - allowGitConfig?: boolean - allowOnly?: string[] - } = {}, + interface SandboxRunOptions { + mandatoryDenySearchDepth?: number + allowGitConfig?: boolean + allowOnly?: string[] + /** + * A read config makes the read section emit its own + * operation-specific unlink/create rules, which the write section's + * denies have to survive. + */ + readConfig?: { denyOnly: string[]; allowWithinDeny?: string[] } + } + + async function runSandboxed( + command: string, + opts: SandboxRunOptions = {}, ): Promise<{ success: boolean; stderr: string }> { const platform = getPlatform() - const command = `echo '${content}' > '${filePath}'` // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { @@ -238,7 +260,7 @@ describe.if(isSupportedPlatform)( wrappedCommand = wrapCommandWithSandboxMacOS({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, allowGitConfig: opts.allowGitConfig, }) @@ -246,7 +268,7 @@ describe.if(isSupportedPlatform)( wrappedCommand = await wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, allowGitConfig: opts.allowGitConfig, @@ -265,6 +287,25 @@ describe.if(isSupportedPlatform)( } } + /** + * The write did not land. On Linux bwrap leaves the empty file it + * mounted over the absent deny path; on macOS nothing is created. + */ + function expectNotWritten(absolutePath: string): void { + const content = existsSync(absolutePath) + ? readFileSync(absolutePath, 'utf8') + : '' + expect(content).toBe('') + } + + async function runSandboxedWrite( + filePath: string, + content: string, + opts: SandboxRunOptions = {}, + ): Promise<{ success: boolean; stderr: string }> { + return runSandboxed(`echo '${content}' > '${filePath}'`, opts) + } + describe('Dangerous files should be blocked', () => { it('blocks writes to .bashrc', async () => { const result = await runSandboxedWrite('.bashrc', MODIFIED_CONTENT) @@ -483,6 +524,246 @@ describe.if(isSupportedPlatform)( ) }) + it('blocks removing an existing .git pointer file', async () => { + const result = await runSandboxed('rm -f lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + }) + + it('blocks renaming a file over an existing .git pointer', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'decoy'), 'gitdir: /tmp/elsewhere') + try { + const result = await runSandboxed('mv -f lib/decoy lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + } finally { + rmSync(join(TEST_DIR, 'lib', 'decoy'), { force: true }) + } + }) + + it('still removes an ordinary file with the same read config', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'plain.txt'), ORIGINAL_CONTENT) + try { + const result = await runSandboxed('rm -f lib/plain.txt', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(true) + expect(existsSync(join(TEST_DIR, 'lib', 'plain.txt'))).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'lib', 'plain.txt'), { force: true }) + } + }) + + it("blocks creating a commondir in the repository's git directory", async () => { + // git reads hooks and config through commondir, so a write here + // moves every deny below to a directory of the command's choosing. + const result = await runSandboxedWrite('.git/commondir', 'decoy') + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, '.git', 'commondir')) + }) + + it("blocks creating a commondir in a submodule's git directory", async () => { + const result = await runSandboxedWrite( + '.git/modules/lib/commondir', + 'decoy', + ) + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, '.git', 'modules', 'lib', 'commondir')) + }) + + it("blocks creating a nested repository's commondir", async () => { + const result = await runSandboxedWrite('nested/.git/commondir', 'decoy') + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, 'nested', '.git', 'commondir')) + }) + + it('blocks creating .git/config.worktree', async () => { + // Read instead of .git/config wherever extensions.worktreeConfig is + // on, which `git sparse-checkout init` turns on. + const result = await runSandboxedWrite( + '.git/config.worktree', + 'fsmonitor = touch pwned', + ) + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, '.git', 'config.worktree')) + }) + + it('allows .git/config.worktree when allowGitConfig is true', async () => { + try { + const result = await runSandboxedWrite( + '.git/config.worktree', + 'bare = false', + { allowGitConfig: true }, + ) + + expect(result.success).toBe(true) + } finally { + rmSync(join(TEST_DIR, '.git', 'config.worktree'), { force: true }) + } + }) + + it('finds a submodule git directory whose HEAD was moved aside', async () => { + const head = join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD') + renameSync(head, `${head}.bak`) + try { + const result = await runSandboxedWrite( + '.git/modules/lib/hooks/pre-commit', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect( + readFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + 'utf8', + ), + ).toBe(ORIGINAL_CONTENT) + } finally { + renameSync(`${head}.bak`, head) + } + }) + + it.if(isLinux)( + 'finds a nested repository whose HEAD was moved aside', + async () => { + // With allowGitConfig the scan does not look for config either, and + // the hook files are one level past the default depth: the + // repository has to be recognised by whatever else its .git holds. + const head = join(TEST_DIR, 'nested', '.git', 'HEAD') + renameSync(head, `${head}.bak`) + try { + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + renameSync(`${head}.bak`, head) + } + }, + ) + + it.if(isLinux)( + 'does not follow a .git file that names an ordinary directory', + async () => { + // `gitdir: ..` from app/tools would otherwise make app/config and + // app/hooks — an ordinary Rails-shaped tree — read-only. + mkdirSync(join(TEST_DIR, 'app', 'config'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'app', 'tools'), { recursive: true }) + writeFileSync(join(TEST_DIR, 'app', 'tools', '.git'), 'gitdir: ..') + try { + const result = await runSandboxedWrite( + 'app/config/settings.yml', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + } finally { + rmSync(join(TEST_DIR, 'app'), { recursive: true, force: true }) + } + }, + ) + + it.if(isLinux)( + 'blocks filling in the git directory a dangling .git file names', + async () => { + mkdirSync(join(TEST_DIR, 'dangling'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'dangling', '.git'), + 'gitdir: ../dangling-gitdir', + ) + try { + const result = await runSandboxed( + 'mkdir -p dangling-gitdir/hooks && echo X > dangling-gitdir/hooks/pre-commit', + ) + + expect(result.success).toBe(false) + expect( + existsSync( + join(TEST_DIR, 'dangling-gitdir', 'hooks', 'pre-commit'), + ), + ).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'dangling'), { recursive: true, force: true }) + rmSync(join(TEST_DIR, 'dangling-gitdir'), { + recursive: true, + force: true, + }) + } + }, + ) + + it.if(isLinux)( + 'refuses to sandbox at all when the scan does not finish', + async () => { + // One complete path, one the run was cut off in the middle of, and + // then a run that outlives its timeout: what it did not reach is + // unknown, so there is nothing safe to wrap the next command with. + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig: { + command: '/bin/sh', + args: [ + '-c', + 'printf "%s\\0%s" "$PWD/a/.git/HEAD" "$PWD/b/.gi"; exec sleep 30', + ], + timeoutMs: 200, + }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(/did not finish/) + }, + ) + + it.if(isLinux && process.getuid?.() !== 0)( + 'denies a directory the scan could not read', + async () => { + mkdirSync(join(TEST_DIR, 'locked', '.git', 'hooks'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'locked', '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + chmodSync(join(TEST_DIR, 'locked'), 0o000) + try { + const result = await runSandboxed( + 'chmod 755 locked && echo X > locked/.git/hooks/pre-commit', + ) + + expect(result.success).toBe(false) + expect(result.stderr).not.toBe('') + } finally { + chmodSync(join(TEST_DIR, 'locked'), 0o755) + rmSync(join(TEST_DIR, 'locked'), { recursive: true, force: true }) + } + }, + ) + it('still lets a command create a .git file where none exists', async () => { mkdirSync('fresh-checkout', { recursive: true }) try { @@ -546,6 +827,22 @@ describe.if(isSupportedPlatform)( expect(control.success).toBe(true) }) + it("blocks rewriting the worktree's commondir", async () => { + // It names the git directory whose hooks and config a commit here + // runs, so it chooses what the denies below apply to. + const commondir = join( + TEST_DIR, + '.git', + 'worktrees', + 'wt', + 'commondir', + ) + const denied = await runSandboxedWrite(commondir, '../../decoy', opts) + + expect(denied.success).toBe(false) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + }) + it("blocks repointing the checkout's own .git file", async () => { const original = readFileSync('.git', 'utf8') const result = await runSandboxedWrite( @@ -1256,10 +1553,10 @@ describe.if(isSupportedPlatform)( denyWithinAllow: [] as string[], } - // linuxGetMandatoryDenyPaths adds .git/hooks to deny list. - // .git exists as a file, so .git/hooks doesn't exist. - // The code will try to mount /dev/null at .git/hooks, but bwrap - // can't create a mount point there because .git is a file. + // .git is a pointer file here, so it goes through + // gitFileDenyPaths: the file itself and the hooks and config it + // leads to are denied, and nothing is mounted under the file + // (bwrap could not create a mount point there). const wrappedCommand = await wrapCommandWithSandboxLinux({ command: 'echo hello', needsNetworkRestriction: false, @@ -1279,8 +1576,6 @@ describe.if(isSupportedPlatform)( expect(result.status).toBe(0) expect(result.stdout.trim()).toBe('hello') cleanupBwrapMountPoints() - - cleanupBwrapMountPoints() } finally { process.chdir(originalDir) rmSync(worktreeDir, { recursive: true, force: true }) @@ -1512,3 +1807,171 @@ describe('macGetMandatoryDenyPatterns - Unit Tests', () => { expect(hasGitConfigPattern).toBe(true) }) }) +describe('Git metadata deny paths - Unit Tests', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'git-deny-paths-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + /** A directory git would accept as a git directory. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + return gitDir + } + + function makePointer(checkout: string, target: string): string { + mkdirSync(join(dir, checkout), { recursive: true }) + const pointer = join(dir, checkout, '.git') + writeFileSync(pointer, `gitdir: ${target}\n`) + return pointer + } + + it('denies commondir in every git directory, and config.worktree with config', () => { + expect(gitDirDenyPaths('/repo/.git', false)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + '/repo/.git/config', + '/repo/.git/config.worktree', + ]) + expect(gitDirDenyPaths('/repo/.git', true)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + ]) + }) + + it('follows a pointer to the git directory it names', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it("follows a linked worktree's commondir as well", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('leaves a pointer that names an ordinary directory alone', () => { + // `gitdir: ..` from app/tools would otherwise deny app/config and + // app/hooks, which are an ordinary tree and not a git directory. + mkdirSync(join(dir, 'app', 'config'), { recursive: true }) + const pointer = makePointer(join('app', 'tools'), '..') + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }) + + it('blocks the git directory a dangling pointer names from being filled in', () => { + const pointer = makePointer('checkout', '../not-created-yet') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'not-created-yet'), false), + ]) + }) + + it('ignores a .git file longer than a path git would follow', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, `gitdir: ${gitDir}${' '.repeat(9000)}\n`) + + // git trims only newline bytes, so the padded path is not one it follows + // either — and the file is never read whole on the way to finding out. + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }) + + it.if(!isWindows)( + 'does not block on a FIFO left where a git directory keeps its commondir', + () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }, + ) + + it.if(!isWindows)( + 'does not block on a FIFO left where a pointer file goes', + () => { + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + expect(spawnSync('mkfifo', [pointer]).status).toBe(0) + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }, + ) + + it('recognises a submodule git directory without a HEAD', () => { + const gitDir = join(dir, 'modules', 'lib') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + }) + }) + + it('walks a submodule name that spans several segments, and nested ones', () => { + const outer = makeGitDir(join(dir, 'modules', 'vendor', 'lib')) + const inner = makeGitDir(join(outer, 'modules', 'dep')) + + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs.sort()).toEqual([outer, inner].sort()) + }) + + it.if(!isWindows)('follows a symlinked entry under modules', () => { + const gitDir = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(gitDir, join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([ + join(dir, 'modules', 'lib'), + ]) + }) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'denies a directory under modules it could not list', + () => { + const locked = join(dir, 'modules', 'locked') + makeGitDir(join(locked, 'deep')) + chmodSync(locked, 0o000) + try { + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs).toEqual([]) + expect(scan.unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('stops walking modules at its own depth bound', () => { + // Deeper than the bound: a name of 12 segments, which no real submodule + // has, and which a symlink loop could otherwise spin on. + const deep = join(dir, 'modules', ...Array.from({ length: 12 }, () => 'x')) + makeGitDir(deep) + + expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([]) + }) +}) diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 95a73c2e9..ace45b949 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { ripGrep, RipgrepError } from '../../src/utils/ripgrep.js' +import { isWindows } from '../helpers/platform.js' describe('ripGrep', () => { it('finds matches with default config', async () => { @@ -43,7 +44,7 @@ describe('ripGrep', () => { try { const script = join(dir, 'echo-argv0.cjs') // ripGrep appends target as the last arg; ignore it and print argv0 - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -60,7 +61,7 @@ describe('ripGrep', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-noargv0-')) try { const script = join(dir, 'echo-argv0.cjs') - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -80,7 +81,46 @@ describe('ripGrep', () => { ).rejects.toThrow(/ripgrep failed/) }) - it.if(process.getuid?.() !== 0)( + it.if(!isWindows)( + 'drops a path a killed run was cut off in the middle of', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-timeout-')) + try { + const error = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + // A complete record, then a truncated one, then a run that outlives + // the timeout. exec so the kill reaches whatever holds stdout. + args: ['-c', 'printf "/found/a\\0/trunc"; exec sleep 30'], + timeoutMs: 200, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).timedOut).toBe(true) + expect((error as RipgrepError).partialMatches).toEqual(['/found/a']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + ) + + it.if(!isWindows)( + 'keeps a path containing a newline in one piece', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-newline-')) + try { + const results = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'printf "/a/nl\\ndir/.git/HEAD\\0"'], + }) + + expect(results).toEqual(['/a/nl\ndir/.git/HEAD']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( 'hands back what rg listed before an unreadable directory failed the run', async () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')) @@ -98,6 +138,9 @@ describe('ripGrep', () => { expect((error as RipgrepError).partialMatches).toEqual([ join(dir, 'a.txt'), ]) + // The caller denies what rg could not read, so the paths must survive. + expect((error as RipgrepError).stderr).toContain(join(dir, 'locked')) + expect((error as RipgrepError).timedOut).toBe(false) } finally { chmodSync(join(dir, 'locked'), 0o755) rmSync(dir, { recursive: true }) From af8a0679726944fdba8e487e586b04ac5675e466 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 10 Sep 2026 03:25:42 +0000 Subject: [PATCH 005/141] docs(readme): what the git denies cover and which git operations break Spells out commondir and config.worktree and why they are denied, the difference between the enumerated working-directory repository and the pattern-matched nested ones, and the fail-closed scan. Lists the git operations that no longer work inside the sandbox, and states the residual: a command can still rename aside the directory holding a pointer and create a fresh one in its place, which the ancestor pins block on Linux within the scan depth and for the macOS literal denies. --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f0d5e3b6..a645082d5 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config`, in the working directory's repository, in nested repositories, and in the submodule git directories they keep under `.git/modules/`. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well; on macOS that holds for the working directory's own `.git` file only, since nested pointers are matched by pattern and not followed. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -678,7 +678,17 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. +**Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: + +- removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason; +- `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; +- from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; +- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. + +**Known limit (both platforms).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On Linux a path found by the scan has its ancestor directories pinned within the scan depth, so this is blocked there for what the scan reached; on macOS it is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. + +**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and a scan that does not finish in time aborts the command rather than sandboxing it with a partial deny list (a scan that cannot run at all — no `ripgrep` — is still logged and not fatal). **Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`: From c36ee8b17878010758c81f9541e0653e857b75e2 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 11 Sep 2026 17:19:21 +0000 Subject: [PATCH 006/141] fix(sandbox): read a .git pointer and commondir the way git reads them The metadata read stopped at 8 KiB and gave up at that size, so a .git file padded past it protected only the pointer file itself while git still followed it: a valid gitdir line with 9,000 newline bytes after it is a pointer git rev-parse accepts before and after the padding, and the target's hooks and config went back to being writable inside the sandbox. git's read_gitfile_gently refuses a gitfile only past 1 MiB, and below that reads it whole: the prefix sits at offset 0, \n and \r are stripped from the end of the whole file rather than a line being taken from it, the path ends at the first NUL, and nothing is trimmed. commondir is read the same way minus the prefix, and with no size limit at all. This now follows those rules on bytes rather than on a decoded first line, sizing the read from the fstat -- one byte past it, so a file that grew since is read on rather than taken for whole -- and re-applying the bound to what actually arrived, since readSync can also come back short. Where the rules cannot be followed the wrap is refused rather than applied with a deny list that may name the wrong directory: a commondir larger than is read here, and a path whose bytes are not valid UTF-8, where a JavaScript string of them opens a different file than git does. That is the call already made for a scan that does not finish in time. A target too long for the filesystem goes the other way and is followed nowhere: git's own stat of it fails, and no command can create anything there either, so there is nothing below it to protect. --- src/sandbox/mandatory-deny-paths.ts | 184 ++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 37 deletions(-) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index cb2774ebe..5b67108db 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -9,11 +9,21 @@ function isAbsenceError(err: unknown): boolean { } /** - * How much of a `.git` pointer or a `commondir` is read. Both hold a single - * path, and git refuses a gitfile larger than 1 MiB, so a file this size is - * not one git would follow either. + * The path is longer than the filesystem allows, so no file can occupy it: + * git's own stat of it fails, and a sandboxed command cannot create a git + * directory there either. */ -const MAX_GIT_METADATA_BYTES = 8192 +function isUnusablePathError(err: unknown): boolean { + return (err as NodeJS.ErrnoException | undefined)?.code === 'ENAMETOOLONG' +} + +/** + * How much of a `.git` pointer or a `commondir` is read: what git's + * `read_gitfile_gently` accepts for a pointer file, so one larger than this + * is not a pointer git follows either. `commondir` has no bound in git, so + * one this large is read by git and not by this, and fails closed. + */ +const MAX_GIT_METADATA_BYTES = 1024 * 1024 /** * Depth bound for the `.git/modules` walk. A submodule's name is its path @@ -31,7 +41,25 @@ const MAX_SUBMODULE_WALK_DEPTH = 10 const GIT_DIR_MARKERS = new Set(['HEAD', 'config', 'hooks', 'objects']) /** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ -type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' +type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' + +/** What {@link readGitMetadataFile} found at a `.git` file or a `commondir`. */ +type GitMetadata = + /** The whole file, within the size git accepts. */ + | { kind: 'contents'; bytes: Buffer } + /** Absent, or not the regular file git requires: nothing git reads here. */ + | { kind: 'none' } + /** Longer than {@link MAX_GIT_METADATA_BYTES}. */ + | { kind: 'too-large' } + +/** + * A git metadata file whose target cannot be worked out the way git works it + * out. Thrown rather than logged, and not swallowed by + * {@link gitFileDenyPaths}: sandboxing with a deny list that misses the hooks + * and config git reads is worse than not sandboxing, which is the same call + * the Linux backend makes for a scan that does not finish in time. + */ +export class GitMetadataError extends Error {} /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { @@ -72,6 +100,11 @@ export function gitDirDenyPaths( * submodule checkout: the file itself plus the hooks/ and config git reads * through it (the named git directory's, and for a linked worktree its * commondir's as well). + * + * Throws {@link GitMetadataError} when the pointer or the `commondir` names + * something this cannot resolve the way git does; the wrap is then refused + * rather than applied with a deny list that may not cover the directory git + * uses. */ export function gitFileDenyPaths( gitFile: string, @@ -80,8 +113,18 @@ export function gitFileDenyPaths( const denyPaths = [gitFile] try { const pointer = readGitMetadataFile(gitFile) + if (pointer.kind === 'too-large') { + // git refuses a .git file this large outright, so it leads nowhere. + logForDebugging( + `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, + { level: 'warn' }, + ) + return denyPaths + } const target = - pointer === undefined ? undefined : parseGitdirPointer(pointer) + pointer.kind === 'contents' + ? parseGitdirPointer(pointer.bytes, gitFile) + : undefined if (target === undefined) return denyPaths const gitDir = path.resolve(path.dirname(gitFile), target) denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) @@ -90,16 +133,28 @@ export function gitFileDenyPaths( // hooks and config its commits run. const commonFile = path.join(gitDir, 'commondir') const common = readGitMetadataFile(commonFile) + if (common.kind === 'too-large') { + // git reads commondir whole, with no size limit of its own, so a file + // past this bound still names the directory whose hooks git runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + } + const commonTarget = + common.kind === 'contents' + ? gitMetadataPath(common.bytes, commonFile) + : undefined const commonDir = - common === undefined + commonTarget === undefined ? undefined - : path.resolve(gitDir, firstLine(common).trim()) + : path.resolve(gitDir, commonTarget) if (commonDir !== undefined && commonDir !== gitDir) { denyPaths.push( ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), ) } } catch (err) { + if (err instanceof GitMetadataError) throw err // A dangling pointer names nothing git would read. A pointer this process // cannot read is one the host's git cannot read either, so the file // itself is the whole deny; an unreadable TARGET is denied whole by @@ -228,6 +283,12 @@ function gitDirTargetDenyPaths( ) return [denied] } + case 'unusable': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is longer than the filesystem allows: no file can be there for git to read or for a command to create; denying only ${source}`, + { level: 'warn' }, + ) + return [] case 'other': logForDebugging( `[Sandbox] ${source} names ${target}, which is not a git directory; denying only ${source}`, @@ -237,11 +298,18 @@ function gitDirTargetDenyPaths( } } +/** + * Whether `dir` is a git directory. Deliberately looser than git's + * `is_git_directory` (a valid HEAD plus objects/ and refs/): every directory + * git accepts has a HEAD entry, so this accepts those and some besides, + * which only ever denies more. + */ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { + if (isUnusablePathError(err)) return 'unusable' return isAbsenceError(err) ? 'absent' : 'unreadable' } return entries.some(e => e.name === 'HEAD' || e.name === 'objects') @@ -250,13 +318,11 @@ function gitDirKind(dir: string): GitDirKind { } /** - * At most {@link MAX_GIT_METADATA_BYTES} of `file`, or undefined when it is - * absent, is not a regular file, or is longer than that. The path is one a - * sandboxed command may create: a FIFO there would block the host on every - * later wrap (hence O_NONBLOCK and the type check), and an arbitrarily large - * file would be buffered whole on every command. + * The contents of `file`, or what stopped this from reading it the way git + * does. The path is one a sandboxed command may create: a FIFO there would + * block the host on every later wrap (hence O_NONBLOCK and the type check). */ -function readGitMetadataFile(file: string): string | undefined { +function readGitMetadataFile(file: string): GitMetadata { // O_NONBLOCK is POSIX-only; this file's callers are the Linux and macOS // backends, and 0 leaves the flags as they were. const nonBlocking = fs.constants.O_NONBLOCK ?? 0 @@ -264,44 +330,88 @@ function readGitMetadataFile(file: string): string | undefined { try { fd = fs.openSync(file, fs.constants.O_RDONLY | nonBlocking) } catch (err) { - if (isAbsenceError(err)) return undefined + if (isAbsenceError(err) || isUnusablePathError(err)) return { kind: 'none' } throw err } try { // From the open file description, so it describes what was actually // opened rather than what the path named a moment ago. - if (!fs.fstatSync(fd).isFile()) return undefined - const buffer = Buffer.alloc(MAX_GIT_METADATA_BYTES) - const read = fs.readSync(fd, buffer, 0, buffer.length, 0) - if (read === buffer.length) { - logForDebugging( - `[Sandbox] ${file} is larger than ${MAX_GIT_METADATA_BYTES} bytes, which is not a path git would follow; ignoring it`, - { level: 'warn' }, - ) - return undefined + const stat = fs.fstatSync(fd) + if (!stat.isFile()) return { kind: 'none' } + if (stat.size > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + // Sized from that stat rather than from the bound, and one byte past it + // so a file that grew since is read on rather than taken for whole; + // readSync can also stop short of the length it was given. + let buffer = Buffer.alloc(stat.size + 1) + let read = 0 + for (;;) { + if (read === buffer.length) { + if (buffer.length > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + const grown = Buffer.alloc(MAX_GIT_METADATA_BYTES + 1) + buffer.copy(grown) + buffer = grown + } + const chunk = fs.readSync(fd, buffer, read, buffer.length - read, read) + if (chunk === 0) break + read += chunk } - return buffer.toString('utf8', 0, read) + return { kind: 'contents', bytes: buffer.subarray(0, read) } } finally { fs.closeSync(fd) } } /** - * The `gitdir:` target of a pointer file. git requires the prefix at byte 0 - * and trims only newline bytes from the end, and a path never spans lines. + * The `gitdir:` target of a pointer file, or undefined when git would not + * follow the file at all. git requires the 8-byte prefix at offset 0 — no + * leading whitespace, no other spelling — and everything after it is path. */ -function parseGitdirPointer(contents: string): string | undefined { - const prefix = 'gitdir: ' - const line = firstLine(contents) - if (!line.startsWith(prefix)) return undefined - const target = line.slice(prefix.length) - return target.length > 0 ? target : undefined +function parseGitdirPointer( + contents: Buffer, + file: string, +): string | undefined { + const prefix = Buffer.from('gitdir: ') + if (!contents.subarray(0, prefix.length).equals(prefix)) return undefined + return gitMetadataPath(contents.subarray(prefix.length), file) } -/** The first line, without the newline bytes git strips (`\n`, `\r`). */ -function firstLine(contents: string): string { - const end = contents.indexOf('\n') - return (end === -1 ? contents : contents.slice(0, end)).replace(/\r+$/, '') +/** + * The path a git metadata file names, by git's rules rather than a line + * reader's: `\n` and `\r` are stripped from the END OF THE FILE (so a newline + * inside the path is part of it), nothing is trimmed, and what is left is a C + * string and ends at the first NUL. `read_gitfile_gently` and + * `get_common_dir_noenv` in git's setup.c both read this way. + * + * This re-implements that parsing instead of asking git, because the library + * has to work where git is not installed, a wrap would otherwise spawn a + * process per pointer per command, and running git inside a checkout the + * sandboxed command can write is the hazard these denies exist to contain; + * test/sandbox/git-pointer-parity.test.ts is what keeps the two in step, + * resolving a corpus of pointer shapes both ways and comparing. + * + * Throws {@link GitMetadataError} for a path whose bytes are not valid UTF-8: + * a JavaScript string of them names a different file than git opens, so + * there is no path here to deny. + */ +function gitMetadataPath(contents: Buffer, file: string): string | undefined { + let end = contents.length + while ( + end > 0 && + (contents[end - 1] === 0x0a || contents[end - 1] === 0x0d) + ) { + end-- + } + const nul = contents.subarray(0, end).indexOf(0) + const pathBytes = contents.subarray(0, nul === -1 ? end : nul) + if (pathBytes.length === 0) return undefined + + const decoded = pathBytes.toString('utf8') + if (!Buffer.from(decoded, 'utf8').equals(pathBytes)) { + throw new GitMetadataError( + `[Sandbox] ${file} names a path that is not valid UTF-8; refusing to sandbox with a deny list that would name a different directory than git opens`, + ) + } + return decoded } /** From ea00b2eeb44a5e63443be00b29a03a807b304bdd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 11 Sep 2026 17:19:21 +0000 Subject: [PATCH 007/141] test: check the pointer and commondir parse against real git The padded pointer gets a case through the real wrapper on both platforms, and unit cases cover both sides of the 1 MiB bound, a path spanning lines, NUL truncation, CRLF, a padded commondir, an untrimmed one, and the two paths that refuse to sandbox. Hand-written cases only cover the shapes their author thought of, and the input that matters is the one this resolves confidently to one directory while git opens another. So each shape is also built on disk with a real target git directory and resolved twice, once here and once by git rev-parse in the pointer's own directory with the git environment scrubbed: wherever git follows a pointer, that directory's hooks, config, config.worktree and commondir have to be in the deny list, or the wrap has to have been refused. Denying more than git follows is fine; denying less fails. The corpus crosses prefix spellings, trailers, path spellings and target shapes, and a seeded generator mixes the same ingredients across another hundred and fifty cases. It skips where git is not installed. --- test/sandbox/git-pointer-parity.test.ts | 696 ++++++++++++++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 228 ++++++- 2 files changed, 916 insertions(+), 8 deletions(-) create mode 100644 test/sandbox/git-pointer-parity.test.ts diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts new file mode 100644 index 000000000..97b007815 --- /dev/null +++ b/test/sandbox/git-pointer-parity.test.ts @@ -0,0 +1,696 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' +import { + GitMetadataError, + gitFileDenyPaths, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { isWindows } from '../helpers/platform.js' + +/** + * Differential tests for the `.git` pointer and `commondir` parsing in + * mandatory-deny-paths.ts, which re-implements git's own (see the comment on + * gitMetadataPath there for why it is not a call to git). Hand-written cases + * only check the shapes their author thought of; what matters here is that + * there is no shape this resolves confidently to one directory while git + * opens another. So every case is resolved twice — once by gitFileDenyPaths, + * once by `git rev-parse` run in the pointer's own directory — and the rule + * is: wherever git follows the pointer, that directory's hooks and config + * are in the deny list, or the wrap was refused outright. Denying more than + * git follows is fine; denying less is the bug. + * + * Skipped where git is not installed. Windows is out of scope for this file + * (mandatory-deny-paths serves the Linux and macOS backends) and several + * shapes here — a newline in a directory name, bytes that are not valid + * UTF-8 — cannot exist there. + */ +const HAS_GIT = + spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0 + +describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { + let root: string + let caseCount = 0 + + beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'git-pointer-parity-'))) + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + /** What precedes the path. Only `gitdir: ` at byte 0 is one git follows. */ + const PREFIXES = { + plain: Buffer.from('gitdir: '), + noSpace: Buffer.from('gitdir:'), + twoSpaces: Buffer.from('gitdir: '), + tab: Buffer.from('gitdir:\t'), + leadingSpace: Buffer.from(' gitdir: '), + byteOrderMark: Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('gitdir: '), + ]), + upperCase: Buffer.from('GITDIR: '), + } + + /** + * What follows it. git strips `\n` and `\r` from the end of the file and + * nothing else, and the path ends at the first NUL. + */ + const TRAILERS = { + none: Buffer.alloc(0), + newline: Buffer.from('\n'), + crlf: Buffer.from('\r\n'), + mixedEndings: Buffer.from('\n\r\n\r'), + nineThousandNewlines: Buffer.from('\n'.repeat(9000)), + sixtyFourKiBOfNewlines: Buffer.from('\n'.repeat(64 * 1024)), + trailingSpaces: Buffer.from(' \n'), + secondLine: Buffer.from('\nnot-a-git-dir\n'), + embeddedNul: Buffer.from('\0/elsewhere\n'), + } + + /** How the target is spelled. Every component of each exists on disk. */ + const SPELLINGS = { + absolute: (_checkout: string, target: string): string => target, + relative: (checkout: string, target: string): string => + relative(checkout, target), + dotSlash: (checkout: string, target: string): string => + `./${relative(checkout, target)}`, + doubledSlash: (checkout: string, target: string): string => + relative(checkout, target).replace('/', '//'), + trailingSlash: (checkout: string, target: string): string => + `${relative(checkout, target)}/`, + backThroughDotDot: (checkout: string, target: string): string => + join('..', basename(checkout), '..', relative(dirname(checkout), target)), + } + + /** + * The target's shape. git follows a pointer only to a directory its + * `is_git_directory` accepts: a valid HEAD, plus objects/ and refs/ found + * through the target's own `commondir`. + */ + const TARGETS = { + gitDir: (caseDir: string): string => makeGitDir(join(caseDir, 'target')), + realGitInit: (caseDir: string): string => { + const repo = join(caseDir, 'initialized') + expect(runGit(caseDir, ['init', '-q', repo])).not.toBeUndefined() + return join(repo, '.git') + }, + headIsASymlink: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'target')) + rmSync(join(gitDir, 'HEAD')) + symlinkSync('refs/heads/main', join(gitDir, 'HEAD')) + return gitDir + }, + symlinkToAGitDir: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real-target')) + const link = join(caseDir, 'target') + symlinkSync(gitDir, link) + return link + }, + throughASymlinkedParent: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real', 'target')) + symlinkSync(dirname(gitDir), join(caseDir, 'link')) + return join(caseDir, 'link', basename(gitDir)) + }, + /** HEAD and a commondir, no objects/ or refs/: a linked worktree's. */ + worktreeGitDir: (caseDir: string): string => { + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(worktree, 'commondir'), '../..\n') + return worktree + }, + ordinaryDirectory: (caseDir: string): string => { + const target = join(caseDir, 'target') + mkdirSync(join(target, 'config'), { recursive: true }) + return target + }, + absent: (caseDir: string): string => join(caseDir, 'target'), + nameHoldsANewline: (caseDir: string): string => + makeGitDir(join(caseDir, 'two\nlines')), + nameIsNotAscii: (caseDir: string): string => + makeGitDir(join(caseDir, 'ziel-日本-🌱')), + } + + interface PointerCase { + prefix: keyof typeof PREFIXES + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + target: keyof typeof TARGETS + /** Pad the file with newline bytes to exactly this size. */ + padTo?: number + /** The `.git` file is a symlink to the regular file holding the bytes. */ + pointerIsASymlink?: boolean + } + + const MAX_GITFILE_SIZE = 1024 * 1024 + + /** A directory git's `is_git_directory` accepts. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + return gitDir + } + + /** + * git, with the host's configuration and environment kept out of it, so + * what it resolves is the pointer file and nothing else. Returns its + * output with the single newline it adds removed — a directory name may + * end in a space or hold a newline of its own — or undefined when it + * failed, which for `rev-parse` here means it did not follow the pointer. + */ + function runGit(cwd: string, args: string[]): string | undefined { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: root, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + } + for (const name of [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_INDEX_FILE', + 'GIT_CEILING_DIRECTORIES', + 'GIT_DISCOVERY_ACROSS_FILESYSTEM', + 'GIT_NAMESPACE', + 'GIT_CONFIG', + 'GIT_CONFIG_COUNT', + ]) { + delete env[name] + } + const result = spawnSync( + 'git', + ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=/dev/null', ...args], + { cwd, env, encoding: 'utf8' }, + ) + if (result.status !== 0) return undefined + return result.stdout.endsWith('\n') + ? result.stdout.slice(0, -1) + : result.stdout + } + + /** The directory `git rev-parse ` names in `checkout`, if any. */ + function gitResolves(checkout: string, arg: string): string | undefined { + const printed = runGit(checkout, ['rev-parse', arg]) + if (printed === undefined || printed === '') return undefined + const absolute = isAbsolute(printed) ? printed : resolve(checkout, printed) + const real = realPathOrSelf(absolute) + // Discovery walks up from cwd when a pointer is merely absent, so a + // repository above the scratch tree is not this pointer's target. + return real.startsWith(root + sep) ? real : undefined + } + + function realPathOrSelf(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } + } + + /** Each deny path, keyed by the real directory it sits in. */ + function coverage(denyPaths: string[]): Set { + return new Set( + denyPaths.map(p => join(realPathOrSelf(dirname(p)), basename(p))), + ) + } + + const GUARDED_NAMES = ['hooks', 'config', 'config.worktree', 'commondir'] + + type Verdict = 'checked' | 'refused' | 'not-followed' + + function assertParity( + label: string, + pointer: string, + checkout: string, + ): Verdict { + let denyPaths: string[] + try { + denyPaths = gitFileDenyPaths(pointer, false) + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + // Refusing to sandbox covers everything, including what git follows. + return 'refused' + } + const gitDir = gitResolves(checkout, '--absolute-git-dir') + if (gitDir === undefined) return 'not-followed' + const commonDir = gitResolves(checkout, '--git-common-dir') + const covered = coverage(denyPaths) + const missing = [gitDir, commonDir] + .filter((d): d is string => d !== undefined) + .flatMap(d => GUARDED_NAMES.map(name => join(d, name))) + .filter(p => !covered.has(p)) + expect({ label, missing }).toEqual({ label, missing: [] }) + return 'checked' + } + + /** Build one case on disk and resolve it both ways. */ + function runPointerCase(spec: PointerCase, labelPrefix = ''): Verdict { + const label = labelPrefix + JSON.stringify(spec) + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + + const target = TARGETS[spec.target](caseDir) + const spelled = SPELLINGS[spec.spelling](checkout, target) + let contents = Buffer.concat([ + PREFIXES[spec.prefix], + Buffer.from(spelled), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + + const pointer = join(checkout, '.git') + if (spec.pointerIsASymlink === true) { + const regular = join(checkout, 'pointer-file') + writeFileSync(regular, contents) + symlinkSync(regular, pointer) + } else { + writeFileSync(pointer, contents) + } + return assertParity(label, pointer, checkout) + } + + const FIXED_CASES: PointerCase[] = [ + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'dotSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'doubledSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'trailingSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'backThroughDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'crlf', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'mixedEndings', + target: 'gitDir', + }, + // The reported bypass: padding past the bound this used to read to. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'sixtyFourKiBOfNewlines', + target: 'gitDir', + }, + // The size git accepts for a gitfile, exactly and one byte past it. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE + 1, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'trailingSpaces', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'secondLine', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'embeddedNul', + target: 'gitDir', + }, + { + prefix: 'noSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'twoSpaces', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'tab', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'leadingSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'byteOrderMark', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'upperCase', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'headIsASymlink', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'throughASymlinkedParent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'nineThousandNewlines', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'ordinaryDirectory', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'nameHoldsANewline', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'nameIsNotAscii', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + pointerIsASymlink: true, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + pointerIsASymlink: true, + }, + ] + + it('resolves a corpus of pointer shapes the way git does', () => { + const verdicts = FIXED_CASES.map(runPointerCase) + // A corpus git followed nothing in would assert nothing at all. + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 120_000) + + it('resolves randomly mixed pointer shapes the way git does', () => { + // Deterministic, so a failing mix is reproducible from the seed below. + const seed = 0x515 + const random = mulberry32(seed) + const specs: PointerCase[] = Array.from({ length: 150 }, () => ({ + // Weighted to the one prefix git follows: the others are worth some + // cases, but they all stop at the same place. + prefix: + random() < 0.5 + ? 'plain' + : pick(random, Object.keys(PREFIXES) as (keyof typeof PREFIXES)[]), + spelling: pick( + random, + Object.keys(SPELLINGS) as (keyof typeof SPELLINGS)[], + ), + trailer: pick(random, Object.keys(TRAILERS) as (keyof typeof TRAILERS)[]), + target: pick(random, Object.keys(TARGETS) as (keyof typeof TARGETS)[]), + ...(random() < 0.1 + ? { padTo: MAX_GITFILE_SIZE + (random() < 0.5 ? 0 : 1) } + : {}), + pointerIsASymlink: random() < 0.1, + })) + const verdicts = specs.map((spec, index) => + runPointerCase(spec, `seed ${seed}, case ${index}: `), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(20) + }, 300_000) + + it('refuses to sandbox on a pointer whose path is not valid UTF-8', () => { + // The one shape with no honest answer: the bytes name a directory git + // opens and a JavaScript string of them names a different one, so there + // is nothing to put in the deny list. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const target = Buffer.concat([ + Buffer.from(join(caseDir, 'target-')), + Buffer.from([0xff]), + ]) + mkdirSync(Buffer.concat([target, Buffer.from('/objects')]), { + recursive: true, + }) + mkdirSync(Buffer.concat([target, Buffer.from('/refs')]), { + recursive: true, + }) + writeFileSync( + Buffer.concat([target, Buffer.from('/HEAD')]), + 'ref: refs/heads/main\n', + ) + const pointer = join(checkout, '.git') + writeFileSync( + pointer, + Buffer.concat([Buffer.from('gitdir: '), target, Buffer.from('\n')]), + ) + + // git follows it, so anything short of refusing would be a deny list + // for a directory other than the one whose hooks run. + expect( + runGit(checkout, ['rev-parse', '--absolute-git-dir']), + ).not.toBeUndefined() + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) + + /** The same shapes in a linked worktree's `commondir`, which has no prefix. */ + interface CommonDirCase { + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + /** Bytes before the path. git trims none of them. */ + leading: string + padTo?: number + } + + function runCommonDirCase(spec: CommonDirCase): Verdict { + const label = JSON.stringify(spec) + const caseDir = join(root, `commondir-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + + let contents = Buffer.concat([ + Buffer.from(spec.leading), + Buffer.from(SPELLINGS[spec.spelling](worktree, main)), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + writeFileSync(join(worktree, 'commondir'), contents) + + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktree}\n`) + return assertParity(label, pointer, checkout) + } + + it('resolves a corpus of commondir shapes the way git does', () => { + const verdicts: Verdict[] = [] + // Every trailer against each way of leading the path — git trims none of + // it — and then every spelling of the path itself. + for (const leading of ['', ' ', '\t']) { + for (const trailer of Object.keys( + TRAILERS, + ) as (keyof typeof TRAILERS)[]) { + verdicts.push( + runCommonDirCase({ leading, spelling: 'relative', trailer }), + ) + } + } + for (const spelling of Object.keys( + SPELLINGS, + ) as (keyof typeof SPELLINGS)[]) { + verdicts.push( + runCommonDirCase({ leading: '', spelling, trailer: 'newline' }), + ) + } + verdicts.push( + runCommonDirCase({ + leading: '', + spelling: 'relative', + trailer: 'none', + padTo: MAX_GITFILE_SIZE, + }), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 300_000) + + it('refuses to sandbox on a commondir past the size it reads', () => { + // git reads commondir whole, with no bound of its own, so a file past + // this one still names the git directory whose hooks a commit runs. + const caseDir = join(root, `commondir-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync( + join(worktree, 'commondir'), + '../..'.padEnd(MAX_GITFILE_SIZE + 1, '\n'), + ) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktree}\n`) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) +}) + +function mulberry32(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function pick(random: () => number, values: readonly T[]): T { + const value = values[Math.floor(random() * values.length)] + if (value === undefined) throw new Error('nothing to pick from') + return value +} diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 02d0d9439..ef3ad21fc 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -32,6 +32,7 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { + GitMetadataError, gitDirDenyPaths, gitFileDenyPaths, submoduleGitDirs, @@ -188,6 +189,20 @@ describe.if(isSupportedPlatform)( join(TEST_DIR, 'wt-checkout', '.git'), `gitdir: ${join(TEST_DIR, '.git', 'worktrees', 'wt')}`, ) + // A checkout whose .git pointer carries 9,000 newline bytes after the + // path. git strips them and follows the pointer, so its target needs + // the same denies an unpadded pointer's does. + mkdirSync(join(TEST_DIR, 'padded-target', 'hooks'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-target', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync(join(TEST_DIR, 'padded-target', 'config'), ORIGINAL_CONTENT) + mkdirSync(join(TEST_DIR, 'padded-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-checkout', '.git'), + `gitdir: ${join(TEST_DIR, 'padded-target')}${'\n'.repeat(9000)}`, + ) // A nested .claude/commands one level down (a name spanning two // segments), within reach only of a deeper scan. mkdirSync(join(TEST_DIR, 'pkg', '.claude', 'commands'), { @@ -856,6 +871,44 @@ describe.if(isSupportedPlatform)( }) }) + describe('from a checkout whose pointer is padded with newlines', () => { + // git strips them from the end of the file and follows the pointer, + // so reading less of the file than git does would leave the target's + // hooks and config writable. + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'padded-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'padded-checkout', 'notes.txt'), { + force: true, + }) + }) + + it("blocks the padded pointer's target, as an unpadded one's", async () => { + const config = join(TEST_DIR, 'padded-target', 'config') + const denied = await runSandboxedWrite(config, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(config, 'utf8')).toBe(ORIGINAL_CONTENT) + + const hook = join(TEST_DIR, 'padded-target', 'hooks', 'pre-commit') + const hookWrite = await runSandboxedWrite( + hook, + MODIFIED_CONTENT, + opts, + ) + expect(hookWrite.success).toBe(false) + expectNotWritten(hook) + + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + }) + }) + it("matches dangerous names below cwd only, not in cwd's own location", async () => { process.chdir(join(TEST_DIR, '.vscode', 'ext', 'foo')) @@ -1825,13 +1878,18 @@ describe('Git metadata deny paths - Unit Tests', () => { return gitDir } - function makePointer(checkout: string, target: string): string { + /** A checkout whose `.git` file holds exactly `contents`. */ + function writePointer(checkout: string, contents: string | Buffer): string { mkdirSync(join(dir, checkout), { recursive: true }) const pointer = join(dir, checkout, '.git') - writeFileSync(pointer, `gitdir: ${target}\n`) + writeFileSync(pointer, contents) return pointer } + function makePointer(checkout: string, target: string): string { + return writePointer(checkout, `gitdir: ${target}\n`) + } + it('denies commondir in every git directory, and config.worktree with config', () => { expect(gitDirDenyPaths('/repo/.git', false)).toEqual([ '/repo/.git/hooks', @@ -1886,17 +1944,171 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it('ignores a .git file longer than a path git would follow', () => { + it('follows a pointer padded with the newline bytes git strips', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${'\n'.repeat(9000)}`, + ) + + // git strips them from the end of the whole file, so this is a pointer + // it follows; reading less of the file than git does would leave the + // target's hooks and config out of the deny list. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('counts trailing spaces as part of the path, as git does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const padded = `${gitDir}${' '.repeat(200)}` + const pointer = writePointer('checkout', `gitdir: ${padded}\n`) + + // Nothing is trimmed but the newline, so the pointer names a directory + // that does not exist — denied against being created, not confused for + // the real git directory next to it. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(padded, false), + ]) + }) + + it('follows a pointer to a path no file can occupy nowhere', () => { + // Past the filesystem's name limit: git's own stat of it fails, and a + // sandboxed command cannot create a git directory there either, so + // there is nothing below the pointer to deny. const gitDir = makeGitDir(join(dir, 'gitdir')) - mkdirSync(join(dir, 'checkout'), { recursive: true }) - const pointer = join(dir, 'checkout', '.git') - writeFileSync(pointer, `gitdir: ${gitDir}${' '.repeat(9000)}\n`) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${' '.repeat(9000)}\n`, + ) - // git trims only newline bytes, so the padded path is not one it follows - // either — and the file is never read whole on the way to finding out. expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) }) + it('follows a pointer of the largest size git accepts, and no larger', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const maxSize = 1024 * 1024 + const head = `gitdir: ${gitDir}` + + const atBound = writePointer( + 'checkout', + head + '\n'.repeat(maxSize - head.length), + ) + expect(statSync(atBound).size).toBe(maxSize) + expect(gitFileDenyPaths(atBound, false)).toEqual([ + atBound, + ...gitDirDenyPaths(gitDir, false), + ]) + + // One byte more is a .git file git refuses outright, so it leads nowhere. + const pastBound = writePointer( + 'past-bound', + head + '\n'.repeat(maxSize + 1 - head.length), + ) + expect(statSync(pastBound).size).toBe(maxSize + 1) + expect(gitFileDenyPaths(pastBound, false)).toEqual([pastBound]) + }) + + it.if(!isWindows)('takes a path that spans lines whole, as git does', () => { + // Only the newline bytes at the END of the file are stripped, so one in + // the middle is part of the directory name. + const gitDir = makeGitDir(join(dir, 'two\nlines')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\n`) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('stops the path at the first NUL byte, as a C string does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}\0/../elsewhere\n`, + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('strips a CRLF line ending', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\r\n`) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('refuses to sandbox at all on a path that is not valid UTF-8', () => { + // Decoded, those bytes name a different directory than git opens, so + // there is no deny list to be had — and sandboxing without one would + // leave the hooks git runs writable. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + Buffer.concat([ + Buffer.from(`gitdir: ${gitDir}`), + Buffer.from([0xff]), + Buffer.from('\n'), + ]), + ) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) + + it("follows a commondir padded past the pointer's own bound", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + `../..${'\n'.repeat(9000)}`, + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('does not trim a commondir, which git reads byte for byte', () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + // A leading space makes this a relative path beginning with a space, + // which is where git looks too — not the main git directory. + writeFileSync(join(worktreeGitDir, 'commondir'), ` ${main}\n`) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + const denyPaths = gitFileDenyPaths(pointer, false) + expect(denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(join(worktreeGitDir, ` ${main}`), false), + ]) + expect(denyPaths).not.toContain(join(main, 'hooks')) + }) + + it('refuses to sandbox at all on a commondir past the size it reads', () => { + // git reads commondir whole and with no bound of its own, so one this + // large still names the git directory whose hooks a commit here runs. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) + it.if(!isWindows)( 'does not block on a FIFO left where a git directory keeps its commondir', () => { From 0824c946e1c9e19901ed1840f1cfdebc79a7e03a Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 11 Sep 2026 17:19:21 +0000 Subject: [PATCH 008/141] docs(readme): how a .git pointer is read, and when a wrap is refused The paragraph on the git denies said which paths are protected but not how the pointer leading to them is read, which is where the size bound and the refusal to sandbox now live. --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a645082d5..940e4d719 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 @@ -666,7 +669,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: From 7f33786811cdf42371f007d6b1bb4b6cb73a1a44 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 11 Sep 2026 17:29:56 +0000 Subject: [PATCH 009/141] test: build the invalid-UTF-8 parity case only where a name can hold it macOS filesystems reject a directory name that is not valid UTF-8 (mkdir fails with EILSEQ), and the target has to exist for git to follow the pointer to it. The refusal itself is platform-independent and stays covered everywhere by the unit case, which needs no such directory. --- test/sandbox/git-pointer-parity.test.ts | 75 ++++++++++++++----------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 97b007815..6b125f7b4 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -22,7 +22,7 @@ import { GitMetadataError, gitFileDenyPaths, } from '../../src/sandbox/mandatory-deny-paths.js' -import { isWindows } from '../helpers/platform.js' +import { isLinux, isWindows } from '../helpers/platform.js' /** * Differential tests for the `.git` pointer and `commondir` parsing in @@ -555,40 +555,47 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(20) }, 300_000) - it('refuses to sandbox on a pointer whose path is not valid UTF-8', () => { - // The one shape with no honest answer: the bytes name a directory git - // opens and a JavaScript string of them names a different one, so there - // is nothing to put in the deny list. - const caseDir = join(root, `pointer-${caseCount++}`) - const checkout = join(caseDir, 'checkout') - mkdirSync(checkout, { recursive: true }) - const target = Buffer.concat([ - Buffer.from(join(caseDir, 'target-')), - Buffer.from([0xff]), - ]) - mkdirSync(Buffer.concat([target, Buffer.from('/objects')]), { - recursive: true, - }) - mkdirSync(Buffer.concat([target, Buffer.from('/refs')]), { - recursive: true, - }) - writeFileSync( - Buffer.concat([target, Buffer.from('/HEAD')]), - 'ref: refs/heads/main\n', - ) - const pointer = join(checkout, '.git') - writeFileSync( - pointer, - Buffer.concat([Buffer.from('gitdir: '), target, Buffer.from('\n')]), - ) + // Linux only: the target has to exist for git to follow it, and macOS + // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The + // refusal itself is platform-independent and covered everywhere by the + // unit case in mandatory-deny-paths.test.ts, which needs no such target. + it.if(isLinux)( + 'refuses to sandbox on a pointer whose path is not valid UTF-8', + () => { + // The one shape with no honest answer: the bytes name a directory git + // opens and a JavaScript string of them names a different one, so there + // is nothing to put in the deny list. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const target = Buffer.concat([ + Buffer.from(join(caseDir, 'target-')), + Buffer.from([0xff]), + ]) + mkdirSync(Buffer.concat([target, Buffer.from('/objects')]), { + recursive: true, + }) + mkdirSync(Buffer.concat([target, Buffer.from('/refs')]), { + recursive: true, + }) + writeFileSync( + Buffer.concat([target, Buffer.from('/HEAD')]), + 'ref: refs/heads/main\n', + ) + const pointer = join(checkout, '.git') + writeFileSync( + pointer, + Buffer.concat([Buffer.from('gitdir: '), target, Buffer.from('\n')]), + ) - // git follows it, so anything short of refusing would be a deny list - // for a directory other than the one whose hooks run. - expect( - runGit(checkout, ['rev-parse', '--absolute-git-dir']), - ).not.toBeUndefined() - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) - }) + // git follows it, so anything short of refusing would be a deny list + // for a directory other than the one whose hooks run. + expect( + runGit(checkout, ['rev-parse', '--absolute-git-dir']), + ).not.toBeUndefined() + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }, + ) /** The same shapes in a linked worktree's `commondir`, which has no prefix. */ interface CommonDirCase { From 7d43e9c69c5491b0e3b415cac49cfd4a252bc941 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 11 Sep 2026 17:53:38 +0000 Subject: [PATCH 010/141] fix(sandbox): resolve a pointer's .. the way the kernel does, not on paper path.resolve folds .. lexically, so `gitdir: link/../evil` resolved to /evil while the kernel follows link first and hands git the directory under link's target instead: the deny list named a directory git never opens, and left the hooks of the one it does open writable. The same held for the directory the pointer file itself sits in, when the checkout is reached through a symlink and .. pops its physical parent, and for a commondir read out of either. A .. in any of those values now also gets a physical walk: component by component from the real path of the directory the value was read in, following each symlink where it is met, taking the rest of the path as written past a component that is not there (the kernel cannot traverse one either, so nothing beyond it can redirect the path), and giving up on a loop at the same 40 hops the kernel allows, which hands the loop itself to the existing unreadable handling and denies the deepest directory that still reads. Where the walk lands somewhere other than the lexical fold, both directories are denied; where it lands on the same object by another name -- a temporary directory under a /var that is a symlink to /private/var -- nothing is added, so no deny that exists today changes its spelling. fs.realpathSync is not that walk: it runs its argument through the same lexical folding before resolving it, and it cannot reach a target that does not exist yet, which is the one that has to be denied so the sandboxed command cannot create it and fill it with hooks. --- README.md | 2 +- src/sandbox/mandatory-deny-paths.ts | 128 ++++++++++++++++++---- test/sandbox/git-pointer-parity.test.ts | 89 +++++++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 123 ++++++++++++++++++++- 4 files changed, 316 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 940e4d719..d17355c22 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 5b67108db..ed6ebb8d0 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -25,6 +25,12 @@ function isUnusablePathError(err: unknown): boolean { */ const MAX_GIT_METADATA_BYTES = 1024 * 1024 +/** + * Symlink hops allowed while resolving one path, the limit Linux itself + * applies before it gives up with ELOOP. + */ +const MAX_SYMLINK_HOPS = 40 + /** * Depth bound for the `.git/modules` walk. A submodule's name is its path * (`vendor/lib`) and submodules nest, so the walk descends both name segments @@ -101,6 +107,10 @@ export function gitDirDenyPaths( * through it (the named git directory's, and for a linked worktree its * commondir's as well). * + * A `..` in either path can land the kernel somewhere other than where the + * path folds to on paper, so both directories are denied — see + * {@link gitMetadataTargets}. + * * Throws {@link GitMetadataError} when the pointer or the `commondir` names * something this cannot resolve the way git does; the wrap is then refused * rather than applied with a deny list that may not cover the directory git @@ -126,32 +136,36 @@ export function gitFileDenyPaths( ? parseGitdirPointer(pointer.bytes, gitFile) : undefined if (target === undefined) return denyPaths - const gitDir = path.resolve(path.dirname(gitFile), target) - denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) + const gitDirs = gitMetadataTargets(path.dirname(gitFile), target) + for (const gitDir of gitDirs) { + denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) + } // A linked worktree's git directory holds the path of the main one, whose - // hooks and config its commits run. - const commonFile = path.join(gitDir, 'commondir') - const common = readGitMetadataFile(commonFile) - if (common.kind === 'too-large') { - // git reads commondir whole, with no size limit of its own, so a file - // past this bound still names the directory whose hooks git runs. - throw new GitMetadataError( - `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, - ) - } - const commonTarget = - common.kind === 'contents' - ? gitMetadataPath(common.bytes, commonFile) - : undefined - const commonDir = - commonTarget === undefined - ? undefined - : path.resolve(gitDir, commonTarget) - if (commonDir !== undefined && commonDir !== gitDir) { - denyPaths.push( - ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), - ) + // hooks and config its commits run. git reads it out of the directory it + // opened, so each candidate above has its own. + for (const gitDir of gitDirs) { + const commonFile = path.join(gitDir, 'commondir') + const common = readGitMetadataFile(commonFile) + if (common.kind === 'too-large') { + // git reads commondir whole, with no size limit of its own, so a + // file past this bound still names the directory whose hooks git + // runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + } + const commonTarget = + common.kind === 'contents' + ? gitMetadataPath(common.bytes, commonFile) + : undefined + if (commonTarget === undefined) continue + for (const commonDir of gitMetadataTargets(gitDir, commonTarget)) { + if (commonDir === gitDir) continue + denyPaths.push( + ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), + ) + } } } catch (err) { if (err instanceof GitMetadataError) throw err @@ -414,6 +428,72 @@ function gitMetadataPath(contents: Buffer, file: string): string | undefined { return decoded } +/** + * The git directories a `gitdir:` or `commondir` value read in `base` leads + * to: the path as it folds on paper, which is the spelling these denies have + * always used, and — when a `..` in it could send the kernel elsewhere — the + * directory the kernel actually reaches. git hands the two strings to stat + * joined and unnormalised, so a symlink is followed before a later `..` + * applies and `a/link/../b` need not be `a/b`. Both are denied when they + * differ: git opens one of them, and denying the other costs one bind. + */ +function gitMetadataTargets(base: string, target: string): string[] { + const lexical = path.resolve(base, target) + if (!target.split('/').includes('..')) return [lexical] + const root = path.parse(lexical).root + const physical = physicalPath(physicalPath(root, base), target) + // Both sides resolved the same way, so a base that merely spells itself + // differently (a /var that is a symlink to /private/var) is no difference. + return physical === physicalPath(root, lexical) + ? [lexical] + : [lexical, physical] +} + +/** + * Where the kernel lands walking `target` from `base`, symlinks followed as + * it meets them. `path.resolve` folds `..` lexically, and `fs.realpathSync` + * folds its argument the same way before resolving it, so neither answers + * this; a walk also reaches a tail that does not exist yet, which realpath + * cannot. A component that cannot be walked — missing, unreadable, or a loop + * past the hop limit — ends it, since the kernel cannot traverse one either + * and nothing beyond it can redirect the path; the rest is taken as written + * and classified by {@link gitDirTargetDenyPaths} like any other target. + */ +function physicalPath(base: string, target: string): string { + let current = path.isAbsolute(target) ? path.parse(target).root : base + let pending = target.split('/') + let hops = 0 + while (pending.length > 0) { + const name = pending.shift() + if (name === undefined || name === '' || name === '.') continue + if (name === '..') { + current = path.dirname(current) + continue + } + const next = path.join(current, name) + let link: string | undefined + try { + if (fs.lstatSync(next).isSymbolicLink()) link = fs.readlinkSync(next) + } catch { + return path.join(next, ...pending) + } + if (link === undefined) { + current = next + continue + } + // A loop is where the walk stops, and what it hands back: the rest of + // the path folded past it would name a directory this cannot vouch for, + // while the link itself reads as unreadable and is denied whole. + hops += 1 + if (hops > MAX_SYMLINK_HOPS) return next + // A link's own target is walked in its place, from the directory holding + // it unless it is absolute. + if (path.isAbsolute(link)) current = path.parse(link).root + pending = [...link.split('/'), ...pending] + } + return current +} + /** * The deepest ancestor of `target` (itself included) this process can still * stat. Denying that directory fails closed when the path below it cannot be diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 6b125f7b4..62fc53576 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -99,6 +99,22 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { `${relative(checkout, target)}/`, backThroughDotDot: (checkout: string, target: string): string => join('..', basename(checkout), '..', relative(dirname(checkout), target)), + // These two build the symlink they then walk through, and return a + // string rather than a join(), since a `..` after a symlink is exactly + // what a lexical fold would take out. + viaSymlinkThenDotDot: (checkout: string, target: string): string => { + const side = join(dirname(target), 'side') + mkdirSync(side, { recursive: true }) + symlinkSync(side, join(checkout, 'hop')) + return `hop/../${basename(target)}` + }, + viaSymlinkThenTwoDotDots: (checkout: string, target: string): string => { + const deeper = join(dirname(target), 'side', 'deeper') + mkdirSync(deeper, { recursive: true }) + mkdirSync(join(checkout, 'sub'), { recursive: true }) + symlinkSync(deeper, join(checkout, 'sub', 'hop')) + return `sub/hop/../../${basename(target)}` + }, } /** @@ -519,6 +535,62 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { target: 'gitDir', pointerIsASymlink: true, }, + // A `..` after a symlink: the kernel follows the link first, so these + // land somewhere a lexical fold of the path never visits. + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'embeddedNul', + target: 'gitDir', + }, ] it('resolves a corpus of pointer shapes the way git does', () => { @@ -555,6 +627,23 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(20) }, 300_000) + it('resolves a .. against the directory the pointer file is really in', () => { + // The base counts as much as the target: with the checkout reached + // through a symlink, `..` pops the directory the kernel is in, not the + // one the path was spelled from, and git follows it there. + const caseDir = join(root, `pointer-${caseCount++}`) + mkdirSync(join(caseDir, 'nested', 'checkout'), { recursive: true }) + const checkout = join(caseDir, 'checkout-link') + symlinkSync(join(caseDir, 'nested', 'checkout'), checkout) + makeGitDir(join(caseDir, 'nested', 'target')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + expect(assertParity('symlinked pointer directory', pointer, checkout)).toBe( + 'checked', + ) + }) + // Linux only: the target has to exist for git to follow it, and macOS // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The // refusal itself is platform-independent and covered everywhere by the diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index ef3ad21fc..6bcd3959c 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -19,6 +19,7 @@ import { symlinkSync, existsSync, statSync, + realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -1864,7 +1865,9 @@ describe('Git metadata deny paths - Unit Tests', () => { let dir: string beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'git-deny-paths-')) + // Real, so a temporary directory that is itself reached through a + // symlink (macOS /var) does not make every path here two paths. + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-deny-paths-'))) }) afterEach(() => { @@ -2096,6 +2099,124 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denyPaths).not.toContain(join(main, 'hooks')) }) + it.if(!isWindows)( + 'denies where a .. after a symlink lands, and the lexical path too', + () => { + // checkout/hop is real/side, so the kernel reads hop/../evil as + // real/evil while folding it on paper gives checkout/evil. git opens + // the first; denying only the second leaves its hooks writable. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + }, + ) + + it.if(!isWindows)('denies both when both are git directories', () => { + const lexical = makeGitDir(join(dir, 'checkout', 'evil')) + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(lexical, false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)('walks a chain of symlinks as the kernel does', () => { + // first is second, second is an absolute path to real/side/deeper. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side', 'deeper'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: first/../../evil\n') + symlinkSync('second', join(dir, 'checkout', 'first')) + symlinkSync( + join(dir, 'real', 'side', 'deeper'), + join(dir, 'checkout', 'second'), + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)( + 'takes the rest of a path as written past what is not there', + () => { + // The kernel cannot traverse a missing directory, so nothing after it + // redirects the path: gone/x is denied against being created, and so + // is the lexical checkout/x. + const pointer = writePointer('checkout', 'gitdir: hop/../x\n') + symlinkSync('gone/deeper', join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), + ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), + ]) + }, + ) + + it.if(!isWindows)('denies what it can reach when symlinks loop', () => { + const pointer = writePointer('checkout', 'gitdir: loopA/../evil\n') + symlinkSync(join(dir, 'checkout', 'loopB'), join(dir, 'checkout', 'loopA')) + symlinkSync(join(dir, 'checkout', 'loopA'), join(dir, 'checkout', 'loopB')) + + // Past the hop limit the walk stops on the loop itself, which is where + // the deny goes: the whole directory that still reads. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + join(dir, 'checkout'), + ]) + }) + + it.if(!isWindows)('resolves a commondir the same way', () => { + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + const physical = makeGitDir(join(dir, 'real', 'common')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + symlinkSync(join(dir, 'real', 'side'), join(worktreeGitDir, 'hop')) + writeFileSync(join(worktreeGitDir, 'commondir'), 'hop/../common\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(join(worktreeGitDir, 'common'), false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)( + 'resolves a .. against the directory the pointer file is really in', + () => { + // The checkout is reached through a symlink, so `..` pops the + // directory the kernel is in and not the one the path was spelled + // from — nested/target, not dir/target. + const physical = makeGitDir(join(dir, 'nested', 'target')) + mkdirSync(join(dir, 'nested', 'checkout'), { recursive: true }) + symlinkSync(join(dir, 'nested', 'checkout'), join(dir, 'link')) + const pointer = join(dir, 'link', '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'target'), false), + ...gitDirDenyPaths(physical, false), + ]) + }, + ) + it('refuses to sandbox at all on a commondir past the size it reads', () => { // git reads commondir whole and with no bound of its own, so one this // large still names the git directory whose hooks a commit here runs. From d258cc57a07243d69732163fdcb0359d59d68846 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 14:11:30 +0000 Subject: [PATCH 011/141] fix(sandbox): deny the directory the .git/modules walk stops at The walk that finds submodule git directories is bounded at ten levels, and on reaching the bound it only logged and moved on. Everything else this cannot inspect fails closed -- a directory ripgrep could not read is denied whole, so is one the walk could not list, and a pointer target that cannot be inspected is denied at its deepest reachable ancestor -- which left the bound as the one unknown still writable: a submodule git directory nested eleven levels down under .git/modules kept its hooks/ and config, the pair these denies exist to protect. The directory the walk stops at now joins the ones it could not list and is denied whole, covering whatever is beneath it without inspecting it. The bound is counted per .git/modules tree, so that directory is always at least ten segments inside one and can never be the repository or anything above it. Reaching it takes a submodule name of ten path segments or five levels of nesting, which no real tree has; a tree that does gets that one directory read-only inside the sandbox. Also records, at the two "is this a git directory" predicates, why they differ. The .git/modules walk takes config and hooks as markers because its input is a tree git itself created, so a looser test only ever denies more. A gitdir:/commondir target is named by file content a sandboxed command can write, so the same test there would let that content aim a deny at any directory that merely holds a config or hooks. --- README.md | 2 +- src/sandbox/mandatory-deny-paths.ts | 17 +++++++++++++---- test/sandbox/mandatory-deny-paths.test.ts | 11 +++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d17355c22..247a5e753 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through — one it cannot list, or the one it stops at ten levels down — is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index ed6ebb8d0..ecbc1de91 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -72,9 +72,10 @@ export interface SubmoduleScan { /** The submodule git directories. */ gitDirs: string[] /** - * Directories the walk could not list. Their contents are unknown, so they - * are denied whole rather than left writable with a git directory possibly - * inside them. + * Directories the walk could not see through: one it could not list, and + * the one it stops at on reaching {@link MAX_SUBMODULE_WALK_DEPTH}. What + * lies under them is unknown, so they are denied whole rather than left + * writable with a git directory possibly inside them. */ unreadableDirs: string[] } @@ -218,8 +219,12 @@ function collectSubmoduleGitDirs( if (isGitDir) scan.gitDirs.push(child) if (depth + 1 >= MAX_SUBMODULE_WALK_DEPTH) { + // Nothing below here is inspected, so the directory is denied whole, + // like one the walk could not list: a submodule git directory nested + // deeper would otherwise keep its hooks and config writable. + scan.unreadableDirs.push(child) logForDebugging( - `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}; submodule git directories beneath it are not denied`, + `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}, denying ${child} whole`, { level: 'warn' }, ) continue @@ -317,6 +322,10 @@ function gitDirTargetDenyPaths( * `is_git_directory` (a valid HEAD plus objects/ and refs/): every directory * git accepts has a HEAD entry, so this accepts those and some besides, * which only ever denies more. + * + * Narrower than {@link GIT_DIR_MARKERS}, which the `.git/modules` walk uses: + * a directory here is named by file content a sandboxed command can write, so + * accepting `config` or `hooks` would let that content aim a deny anywhere. */ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 6bcd3959c..0c7e61132 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -2307,4 +2307,15 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([]) }) + + it('denies the directory the modules walk stopped at', () => { + // A git directory one level past the bound: the walk never sees it, so + // the directory it stopped at is denied whole instead. + const bound = join(dir, 'modules', ...Array.from({ length: 10 }, () => 'x')) + makeGitDir(join(bound, 'deep')) + + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs).toEqual([]) + expect(scan.unreadableDirs).toEqual([bound]) + }) }) From 2db36ad355f9b7c2d7c6b7980b9366ad26559ef4 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 18:33:39 +0000 Subject: [PATCH 012/141] fix(sandbox): deny the modules beneath a bound git directory, not the directory A git directory exactly at the walk's depth bound went into both lists: into gitDirs, so its hooks and config were denied by path, and into unreadableDirs, so the backends denied it whole and took its objects, refs and index with it. Ten levels of ordinary nested submodules were enough to reach it, and git inside that submodule then failed on add, commit and fetch, where before only hooks and config were denied. What the walk would have descended into is denied instead: the modules directory beneath a git directory, absent or not, since a sandboxed command must not be able to create one and hide a git directory inside; otherwise the directory itself, as before. The depth-bound tests fold into one parameterised case asserting the whole scan shape, with its segment count derived from the constant rather than repeated. The Linux arm asserts the argv and, where bubblewrap can build its namespaces, runs the wrapped command: a write to objects/ succeeds inside the bound git directory while hooks/, config and creating modules/ fail. --- src/sandbox/mandatory-deny-paths.ts | 17 ++- test/sandbox/mandatory-deny-paths.test.ts | 150 +++++++++++++++++++--- 2 files changed, 146 insertions(+), 21 deletions(-) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index ecbc1de91..6d03bf947 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -37,7 +37,7 @@ const MAX_SYMLINK_HOPS = 40 * and nested `modules` directories; this bounds a hostile or looping tree, not * a real one, and is deliberately unrelated to the ripgrep scan's depth. */ -const MAX_SUBMODULE_WALK_DEPTH = 10 +export const MAX_SUBMODULE_WALK_DEPTH = 10 /** * Entries whose presence makes a directory a git directory. git needs HEAD @@ -219,12 +219,17 @@ function collectSubmoduleGitDirs( if (isGitDir) scan.gitDirs.push(child) if (depth + 1 >= MAX_SUBMODULE_WALK_DEPTH) { - // Nothing below here is inspected, so the directory is denied whole, - // like one the walk could not list: a submodule git directory nested - // deeper would otherwise keep its hooks and config writable. - scan.unreadableDirs.push(child) + // Nothing below here is inspected, so what the walk would have descended + // into is denied whole, like a directory it could not list: a submodule + // git directory nested deeper would otherwise keep its hooks and config + // writable. For a git directory that is the `modules` beneath it (absent + // or not — a sandboxed command must not be able to create one and hide a + // git directory inside), NOT the directory itself, whose objects, refs + // and index stay writable so git still works in that submodule. + const denied = isGitDir ? path.join(child, 'modules') : child + scan.unreadableDirs.push(denied) logForDebugging( - `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}, denying ${child} whole`, + `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}, denying ${denied} whole`, { level: 'warn' }, ) continue diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 0c7e61132..338fb03d7 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -34,6 +34,7 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { GitMetadataError, + MAX_SUBMODULE_WALK_DEPTH, gitDirDenyPaths, gitFileDenyPaths, submoduleGitDirs, @@ -1874,6 +1875,29 @@ describe('Git metadata deny paths - Unit Tests', () => { rmSync(dir, { recursive: true, force: true }) }) + /** Whether bwrap can build the namespaces a wrapped command runs in here. */ + let canNamespace: boolean | undefined + function bwrapCanNamespace(): boolean { + canNamespace ??= + spawnSync( + 'bwrap', + [ + '--unshare-pid', + '--unshare-user', + '--cap-drop', + 'ALL', + '--ro-bind', + '/', + '/', + '--proc', + '/proc', + 'true', + ], + { timeout: 5000 }, + ).status === 0 + return canNamespace + } + /** A directory git would accept as a git directory. */ function makeGitDir(gitDir: string): string { mkdirSync(join(gitDir, 'hooks'), { recursive: true }) @@ -2299,23 +2323,119 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) - it('stops walking modules at its own depth bound', () => { - // Deeper than the bound: a name of 12 segments, which no real submodule - // has, and which a symlink loop could otherwise spin on. - const deep = join(dir, 'modules', ...Array.from({ length: 12 }, () => 'x')) - makeGitDir(deep) + /** The directory the walk stops at: MAX_SUBMODULE_WALK_DEPTH levels down. */ + function boundDir(): string { + return join( + dir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + } - expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([]) - }) + it.each([ + // A git directory below the bound is never seen, so what the walk would + // have descended into is denied whole in its place. + ['a plain directory', false, (bound: string) => bound], + // When the bound directory is a git directory itself it is BOTH: its own + // hooks and config are denied by path, and only the `modules` beneath it + // is denied whole — denying the git directory would take its objects, + // refs and index with it and stop git working in that submodule. + ['a git directory', true, (bound: string) => join(bound, 'modules')], + ])( + 'stops the modules walk at its depth bound, with %s there', + (_label, boundIsGitDir, denied) => { + const bound = boundDir() + if (boundIsGitDir) makeGitDir(bound) + makeGitDir(join(bound, 'deep')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: boundIsGitDir ? [bound] : [], + unreadableDirs: [denied(bound)], + }) + }, + ) - it('denies the directory the modules walk stopped at', () => { - // A git directory one level past the bound: the walk never sees it, so - // the directory it stopped at is denied whole instead. - const bound = join(dir, 'modules', ...Array.from({ length: 10 }, () => 'x')) - makeGitDir(join(bound, 'deep')) + it.if(isLinux)( + 'keeps a bound git directory writable except for its hooks, config and modules', + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const bound = join( + gitDir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + makeGitDir(bound) + mkdirSync(join(bound, 'objects'), { recursive: true }) - const scan = submoduleGitDirs(join(dir, 'modules')) - expect(scan.gitDirs).toEqual([]) - expect(scan.unreadableDirs).toEqual([bound]) + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + // The covering deny is the `modules` beneath it, not the git directory + // itself: denying that whole would take its objects, refs and index. + const command = await wrap('true') + expect(command).not.toContain(`--ro-bind ${bound} ${bound} `) + expect(command).toContain(join(bound, 'modules')) + expect(command).toContain(`--ro-bind ${join(bound, 'hooks')} `) + + // Where bwrap can run, prove it: what git needs writable in that + // submodule still is, and what makes a write into code is not. + if (bwrapCanNamespace()) { + const result = spawnSync( + await wrap( + `sh -c 'echo o > ${join(bound, 'objects', 'x')} && ` + + `! echo h > ${join(bound, 'hooks', 'x')} && ` + + `! echo c > ${join(bound, 'config')} && ` + + `! mkdir -p ${join(bound, 'modules', 'sub')} && ` + + `echo SRT_BOUND_OK'`, + ), + { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, + ) + expect(result.stdout).toContain('SRT_BOUND_OK') + } + } finally { + cleanupBwrapMountPoints({ force: true }) + process.chdir(originalCwd) + } + }, + 60000, + ) + + it('denies the bound git directory by pattern on macOS, without contradiction', () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const bound = join( + gitDir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + makeGitDir(bound) + + const originalCwd = process.cwd() + process.chdir(checkout) + try { + // Profile generation is pure string building, so this runs anywhere. + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect(profile).toContain(`(subpath "${join(bound, 'modules')}")`) + expect(profile).toContain(`(subpath "${join(bound, 'hooks')}")`) + // No deny covering the git directory whole, which would take its + // objects, refs and index with it. + expect(profile).not.toContain(`(subpath "${bound}")`) + } finally { + process.chdir(originalCwd) + } }) }) From 8fad968cf1c65811a2579cf30d71618911a4a70c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 12 Sep 2026 18:33:40 +0000 Subject: [PATCH 013/141] docs: the three whole-directory denies under .git/modules, and what one costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SubmoduleScan doc named two producers and the README one. There are three — a directory the walk cannot list, an entry it cannot stat, and each branch that reaches the depth bound — and for the first two what is recorded is the deepest ancestor this process can reach, which can be .git/modules itself. Say what such a deny costs: everything beneath it is read-only, a submodule's objects, refs and index included. gitDirKind's doc claimed the walk's looser marker set cannot be aimed anywhere. It can, through a symlinked entry under .git/modules, which is exactly what the narrower set here is for. --- README.md | 2 +- src/sandbox/mandatory-deny-paths.ts | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 247a5e753..189a2ea9f 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through — one it cannot list, or the one it stops at ten levels down — is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 6d03bf947..7609f1646 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -72,10 +72,18 @@ export interface SubmoduleScan { /** The submodule git directories. */ gitDirs: string[] /** - * Directories the walk could not see through: one it could not list, and - * the one it stops at on reaching {@link MAX_SUBMODULE_WALK_DEPTH}. What - * lies under them is unknown, so they are denied whole rather than left - * writable with a git directory possibly inside them. + * Directories the walk could not see through: what lies under them is + * unknown, so they are denied whole rather than left writable with a git + * directory possibly inside. Three things produce one — a directory the walk + * could not list, an entry it could not stat, and, once per branch that + * reaches {@link MAX_SUBMODULE_WALK_DEPTH}, the `modules` beneath the + * directory it stopped at (or that directory itself, when it is not a git + * directory). For the first two the recorded path is the deepest ancestor + * this process can reach, which can be the `.git/modules` root itself. + * + * A whole-directory deny is read-only for everything beneath it, a + * submodule's `objects`, `refs` and `index` included, so git writes inside a + * tree that trips one stop working. */ unreadableDirs: string[] } @@ -330,7 +338,10 @@ function gitDirTargetDenyPaths( * * Narrower than {@link GIT_DIR_MARKERS}, which the `.git/modules` walk uses: * a directory here is named by file content a sandboxed command can write, so - * accepting `config` or `hooks` would let that content aim a deny anywhere. + * accepting `config` or `hooks` would let that content aim a deny at any + * directory at all. The walk reaches only what lies under `.git/modules` — + * except through a symlinked entry there, which a command able to write under + * it can aim at one directory of its choosing. */ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] From d843decff53bafce3fc2d6d7a40054f91e803ec1 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 16 Sep 2026 12:28:44 +0000 Subject: [PATCH 014/141] test: the git directories read off disk compile to literal filters A submodule's name is its path and the sandboxed command chooses it, so `.git/modules/a[b/c]d` is an ordinary name for one; so is the directory a `.git` pointer file names, and the one its `commondir` names. Sniffed for glob characters, each compiles to a character class that matches neither the directory it was built from nor anything else, and the deny covers nothing. Profile cases, on any POSIX host: a submodule git directory and a directory the modules walk stops at are denied by subpath; no emitted regex carries a bracket that came off the filesystem; the patterns that have no path to enumerate - a nested repository's submodule, and the `.git` pointer filter - are anchored at the cwd with its own brackets escaped; a pointer file's target and its commondir's target are denied by subpath. Under real sandbox-exec: a hook planted in a bracketed submodule git directory is refused, a write in the directory the walk stopped at is refused, and a write elsewhere in that git directory still succeeds. --- .../macos-literal-deny-brackets.test.ts | 254 +++++++++++++++++- 1 file changed, 253 insertions(+), 1 deletion(-) diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index 790524ccb..1aa58ce74 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, afterAll, beforeAll } from 'bun:test' import { spawnSync } from 'node:child_process' -import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' @@ -393,3 +399,249 @@ describe.if(isMacOS)( }) }, ) + +/** + * `/.git` as a directory, with a submodule git directory and a + * directory the `.git/modules` walk stops at, each named across a bracket + * pair the way a submodule name can be: a submodule's name is its path, so + * `a[b/c]d` is an ordinary one, and it is the sandboxed command that chooses + * it. + */ +interface ModulesTree { + /** Bracket-free write root, so only the denies are under test. */ + root: string + /** The bracketed working directory. */ + work: string + /** `/.git/modules/s[m/o]d`, a submodule git directory. */ + submodule: string + /** `/.git/modules/u[n/r]d`, where the walk stops. */ + unreadable: string +} + +function modulesTree(prefix: string): ModulesTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const modules = join(work, '.git', 'modules') + const submodule = join(modules, 's[m', 'o]d') + const unreadable = join(modules, 'u[n', 'r]d') + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + mkdirSync(join(submodule, 'objects'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(work, '.git', 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(unreadable, { recursive: true }) + // A loop the walk cannot stat, so it stops and denies the directory + // holding it whole — the same record an unlistable directory produces. + symlinkSync('loop', join(unreadable, 'loop')) + return { root, work, submodule, unreadable } +} + +/** + * `/.git` as a pointer file, naming a git directory whose `commondir` + * names another. Every directory here is one the file's own contents chose. + */ +interface PointerTree { + root: string + work: string + /** The `.git` pointer file in the cwd. */ + pointer: string + /** `/g[h/i]j`, the git directory the pointer names. */ + gitDir: string + /** `/c[o/m]n`, the git directory its `commondir` names. */ + commonDir: string +} + +function pointerTree(prefix: string): PointerTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const gitDir = join(root, 'g[h', 'i]j') + const commonDir = join(root, 'c[o', 'm]n') + mkdirSync(work, { recursive: true }) + mkdirSync(gitDir, { recursive: true }) + mkdirSync(commonDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'commondir'), `${commonDir}\n`) + const pointer = join(work, '.git') + writeFileSync(pointer, `gitdir: ${gitDir}\n`) + return { root, work, pointer, gitDir, commonDir } +} + +/** The four paths inside a git directory the mandatory denies name. */ +const GIT_DIR_LEAVES = ['hooks', 'commondir', 'config', 'config.worktree'] + +/** + * The git directories the macOS denies cover are read off the filesystem, + * and what they are called is up to the sandboxed command. Compiled as a + * glob, `[m/o]` is a one-character class, so a deny built from + * `.git/modules/s[m/o]d` matches neither that directory nor anything else — + * and a `.gitmodules` naming that submodule makes the host's next + * `git submodule update --init` run whatever hook was planted in it. + */ +describe.if(!isWindows)( + 'macOS profile: git directories read off disk under a bracketed cwd', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function profile(): string { + return wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + } + + it('denies a submodule git directory by subpath', () => { + const text = profile() + for (const leaf of GIT_DIR_LEAVES) { + const denied = join(tree.submodule, leaf) + expect(text).toContain(`(subpath ${JSON.stringify(denied)})`) + expect(text).not.toContain(sniffedFilter(denied, '(/.*)?$')) + } + }) + + it('denies the directory the walk stopped at by subpath', () => { + const text = profile() + expect(text).toContain(`(subpath ${JSON.stringify(tree.unreadable)})`) + expect(text).not.toContain(sniffedFilter(tree.unreadable, '(/.*)?$')) + }) + + it('leaves no regex carrying a bracket read off the filesystem', () => { + // A spelling sniffed as a pattern keeps its brackets verbatim, since + // they are the glob syntax it is taken to be asking for. An anchored + // pattern escapes the part of it the library built. + for (const regex of emittedRegexes(profile())) { + for (const opening of ['s[m/', 'u[n/', 'a[b/']) { + expect(regex).not.toContain(opening) + } + } + }) + + it('anchors the nested-repository patterns at the escaped cwd', () => { + const anchor = tree.work.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const text = profile() + // A nested repository, and a nested repository's submodule, have no + // path on disk to enumerate: they are matched by pattern, hung off the + // cwd, which is escaped into the regex like any other literal. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git/modules/[^/]*/hooks(/.*)?$`)})`, + ) + // Same for the `.git` pointer filter, matched by vnode type. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git$`)})`, + ) + }) + }, +) + +/** + * A pointer file's target, and the target its `commondir` names, are paths + * the sandboxed command wrote into a file. Both are followed the way git + * follows them, and both are names on disk once followed. + */ +describe.if(!isWindows)( + 'macOS profile: a bracketed git directory named by a pointer file', + () => { + let tree: PointerTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = pointerTree('bracket-pointer-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + it('denies the pointer and both git directories by subpath', () => { + const text = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + const denied = [ + tree.pointer, + ...GIT_DIR_LEAVES.map(leaf => join(tree.gitDir, leaf)), + ...GIT_DIR_LEAVES.map(leaf => join(tree.commonDir, leaf)), + ] + for (const path of denied) { + expect(text).toContain(`(subpath ${JSON.stringify(path)})`) + expect(text).not.toContain(sniffedFilter(path, '(/.*)?$')) + } + }) + }, +) + +describe.if(isMacOS)( + 'macOS sandbox: a bracketed submodule git directory keeps its hooks', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-exec') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function run(command: string): { status: number | null; stderr: string } { + const result = spawnSync( + wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }), + { + shell: true, + encoding: 'utf8', + timeout: 10000, + // Assert on the message, so pin the language it is written in. + env: { ...process.env, LC_ALL: 'C' }, + }, + ) + return { status: result.status, stderr: result.stderr || '' } + } + + it('writes elsewhere in that git directory (sanity check)', () => { + const target = join(tree.submodule, 'objects', 'written') + const result = run(`echo ok > ${JSON.stringify(target)}`) + expect(result.status).toBe(0) + }) + + it('refuses a hook planted in that git directory', () => { + const target = join(tree.submodule, 'hooks', 'pre-commit') + const result = run(`echo planted > ${JSON.stringify(target)}`) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + }) + + it('refuses a write in the directory the walk stopped at', () => { + const target = join(tree.unreadable, 'planted') + const result = run(`echo planted > ${JSON.stringify(target)}`) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + }) + }, +) From 951f08a18deb38e87b3df26eedc2e5380499a0f5 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 21:56:31 +0000 Subject: [PATCH 015/141] test: take the namespace probe from the shared helper The suite carried its own memoised copy of the bwrap namespace probe. The same probe is now a helper every other Linux suite imports, so use that one. --- test/sandbox/mandatory-deny-paths.test.ts | 24 +---------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 779f2b578..ccba35d08 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -25,6 +25,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' import { indexOfMount, lastIndexOfMount } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { wrapCommandWithSandboxMacOS, macGetMandatoryDenyEntries, @@ -1880,29 +1881,6 @@ describe('Git metadata deny paths - Unit Tests', () => { rmSync(dir, { recursive: true, force: true }) }) - /** Whether bwrap can build the namespaces a wrapped command runs in here. */ - let canNamespace: boolean | undefined - function bwrapCanNamespace(): boolean { - canNamespace ??= - spawnSync( - 'bwrap', - [ - '--unshare-pid', - '--unshare-user', - '--cap-drop', - 'ALL', - '--ro-bind', - '/', - '/', - '--proc', - '/proc', - 'true', - ], - { timeout: 5000 }, - ).status === 0 - return canNamespace - } - /** A directory git would accept as a git directory. */ function makeGitDir(gitDir: string): string { mkdirSync(join(gitDir, 'hooks'), { recursive: true }) From 466e011186b2ff563bdeeff205a465e983ed8d3a Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 22:07:16 +0000 Subject: [PATCH 016/141] linux: deny a git directory's commondir without breaking git Denying a path that is not there means mounting something at it, and two of the paths these denies name are ones git reads back: `commondir` and `config.worktree`. The placeholder was /dev/null, which a bind mount serves with nodev, so git found a `commondir` it could not read and refused to run at all - "fatal: failed to read .../commondir: Permission denied" from every git command in the repository, not just the write the deny is for. An empty file is no better: git rejects a commondir it reads zero bytes from. Mount a placeholder git reads as no redirect instead: `.` for `commondir`, which is where git looks when a git directory has no commondir, and nothing for `config.worktree`, which reads as an empty config does. The deny is unchanged - the mount is read-only, so neither file can be written or replaced - and a real one is still bound from itself. A file already in place holding no bytes takes the placeholder too. That is what bubblewrap's own mount point for the absent case looks like until cleanup removes it, so without this a second wrap over the same repository would bind that empty file and break git again. --- README.md | 4 +- src/sandbox/linux-sandbox-utils.ts | 75 +++++++++-- src/sandbox/mandatory-deny-paths.ts | 22 +++ test/sandbox/mandatory-deny-paths.test.ts | 157 +++++++++++++++++++++- 4 files changed, 246 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1e4e921fb..550307574 100644 --- a/README.md +++ b/README.md @@ -704,7 +704,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. What is mounted there is therefore a placeholder git reads as no redirect — `.` for `commondir`, which is the git directory itself, and nothing for `config.worktree` — and a file already in place holding no bytes is covered the same way, bubblewrap's own mount point for the absent case being an empty file until the wrap cleans it up. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -724,7 +724,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; - `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. -**Known limit (both platforms).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On Linux a path found by the scan has its ancestor directories pinned within the scan depth, so this is blocked there for what the scan reached; on macOS it is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. +**Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. **Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and a scan that does not finish in time aborts the command rather than sandboxing it with a partial deny list (a scan that cannot run at all — no `ripgrep` — is still logged and not fatal). diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index cbac12e09..1b5d2030e 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -26,6 +26,7 @@ import { import { gitDirDenyPaths, gitFileDenyPaths, + gitRedirectPlaceholder, submoduleGitDirs, } from './mandatory-deny-paths.js' import type { @@ -251,6 +252,31 @@ function hasFileAncestor(targetPath: string): boolean { return false } +/** Whether `filePath` is a regular file holding no bytes. */ +function isEmptyFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath) + return stat.isFile() && stat.size === 0 + } catch { + return false + } +} + +/** + * A read-only file holding `contents`, to bind where an absent deny path is + * one something else reads and /dev/null would not do - see + * {@link gitRedirectPlaceholder}. It gets a directory of its own so the + * sandboxed command cannot reach the mount source under a name it can write. + */ +function denyPlaceholderFile(contents: string): string { + const file = path.join( + fs.mkdtempSync(path.join(tmpdir(), 'claude-stub-')), + 'placeholder', + ) + fs.writeFileSync(file, contents, { mode: 0o444 }) + return file +} + /** * Find the first non-existent path component. * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists, @@ -1865,15 +1891,24 @@ async function generateFilesystemArgs( } return stubSkipVetoInputs } + const mandatoryDenyPaths = await linuxGetMandatoryDenyPaths( + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + ) + // What to bind where one of those is absent, keyed by the spelling the + // scan produced it with: a caller's own denyWrite that ends in the same + // name is not a git directory's and keeps the /dev/null placeholder. + const gitRedirectStubs = new Map() + for (const denyPath of mandatoryDenyPaths) { + const contents = gitRedirectPlaceholder(denyPath) + if (contents !== undefined) gitRedirectStubs.set(denyPath, contents) + } // Deny writes within allowed paths (user-specified + mandatory denies) const denyPaths = [ ...(writeConfig.denyWithinAllow || []), - ...(await linuxGetMandatoryDenyPaths( - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - )), + ...mandatoryDenyPaths, ] // Duplicate deny entries must be collapsed: a duplicate @@ -2226,12 +2261,21 @@ async function generateFilesystemArgs( `[Sandbox Linux] Mounted empty dir at ${firstNonExistent} to block creation of ${normalizedPath}`, ) } else { - denyWriteArgs.push('--ro-bind', '/dev/null', firstNonExistent) + // A placeholder git reads, where the path is one it reads: a + // commondir it cannot read makes git refuse to run at all, so + // every command in the repository would fail rather than just + // the write this deny is for. Everything else keeps /dev/null. + const gitRedirectStub = gitRedirectStubs.get(pathPattern) + const source = + gitRedirectStub === undefined + ? '/dev/null' + : denyPlaceholderFile(gitRedirectStub) + denyWriteArgs.push('--ro-bind', source, firstNonExistent) denyWriteRawDests.set(firstNonExistent, rawPath) bwrapMountPoints.add(firstNonExistent) registerExitCleanupHandler() logForDebugging( - `[Sandbox Linux] Mounted /dev/null at ${firstNonExistent} to block creation of ${normalizedPath}`, + `[Sandbox Linux] Mounted ${source} at ${firstNonExistent} to block creation of ${normalizedPath}`, ) } } else if (ancestorIsWithinReadOnlyDeny) { @@ -2277,7 +2321,20 @@ async function generateFilesystemArgs( ) } } - denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) + // A redirect file holding nothing git can read as one is bound from + // the placeholder rather than from itself. The usual way to find one + // is a previous wrap's own mount point for the absent case, which is + // an empty file until it is cleaned up: binding that would deny the + // same write and cost the repository every git command, since git + // refuses to run at all against a commondir it cannot read. + const gitRedirectStub = gitRedirectStubs.get(pathPattern) + denyWriteArgs.push( + '--ro-bind', + gitRedirectStub !== undefined && isEmptyFile(normalizedPath) + ? denyPlaceholderFile(gitRedirectStub) + : normalizedPath, + normalizedPath, + ) denyWriteRawDests.set(normalizedPath, rawPath) } else { logForDebugging( diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 7609f1646..3e5c63d78 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -110,6 +110,28 @@ export function gitDirDenyPaths( return denyPaths } +/** + * What must stand in for `denyPath` where it does not exist, or undefined + * for a path git does not read this way. Denying an absent path means + * mounting something at it, and git reads whichever of these two it finds: + * it refuses to run at all against a `commondir` it cannot read, which an + * empty one and a bound /dev/null both are (git rejects a commondir it reads + * zero bytes from, and a bind mount carries nodev, so the device is + * unreadable). `.` is where git looks when a git directory has no commondir + * - the git directory itself - and no config.worktree reads the same as an + * empty one, so both placeholders leave git doing what it already does. + */ +export function gitRedirectPlaceholder(denyPath: string): string | undefined { + switch (path.basename(denyPath)) { + case 'commondir': + return '.\n' + case 'config.worktree': + return '' + default: + return undefined + } +} + /** * Deny paths for a `.git` file, the `gitdir:` pointer of a linked worktree or * submodule checkout: the file itself plus the hooks/ and config git reads diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index ccba35d08..990c73500 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -24,7 +24,11 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' -import { indexOfMount, lastIndexOfMount } from '../helpers/bwrap-argv.js' +import { + indexOfMount, + lastIndexOfMount, + lastMountAt, +} from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { wrapCommandWithSandboxMacOS, @@ -39,6 +43,7 @@ import { MAX_SUBMODULE_WALK_DEPTH, gitDirDenyPaths, gitFileDenyPaths, + gitRedirectPlaceholder, submoduleGitDirs, } from '../../src/sandbox/mandatory-deny-paths.js' import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' @@ -1913,6 +1918,15 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) + it('gives the files git reads a placeholder that is no redirect', () => { + // `.` is the git directory itself, which is where git looks when there + // is no commondir, and no config.worktree reads as an empty one does. + expect(gitRedirectPlaceholder('/repo/.git/commondir')).toBe('.\n') + expect(gitRedirectPlaceholder('/repo/.git/config.worktree')).toBe('') + expect(gitRedirectPlaceholder('/repo/.git/config')).toBeUndefined() + expect(gitRedirectPlaceholder('/repo/.git/hooks')).toBeUndefined() + }) + it('follows a pointer to the git directory it names', () => { const gitDir = makeGitDir(join(dir, 'gitdir')) const pointer = makePointer('checkout', '../gitdir') @@ -2435,3 +2449,144 @@ describe('Git metadata deny paths - Unit Tests', () => { } }) }) +/** + * Denying a path that is not there means mounting something at it, and two + * of these the host's git reads: it refuses to run at all against a + * `commondir` it cannot read, and /dev/null is unreadable through a bind + * mount. So those denies bind a placeholder that says "nothing redirected" + * instead, and they do it for an empty file too - bwrap's own mount point + * for the absent case is one of those until it is cleaned up. + */ +describe.if(isLinux)('Placeholders for the files git reads', () => { + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept, wrapped with it as the cwd. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + function wrapIn(checkout: string, command = 'true'): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + /** What bwrap is given to mount at `dest`, of the words `--flag src dest`. */ + function mountSource(command: string, dest: string): string | undefined { + return lastMountAt(command, dest)?.split(' ')[1] + } + + it('binds a commondir that is not there from a placeholder holding "."', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const source = mountSource(await wrapIn(checkout), commondir) + + expect(source).toBeDefined() + expect(source).not.toBe('/dev/null') + expect(readFileSync(source as string, 'utf8')).toBe('.\n') + }) + + it('binds a commondir that is there from itself', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '../..\n') + + expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + }) + + it('binds a commondir left empty from the placeholder as well', async () => { + // What an earlier wrap's mount point for the absent case looks like + // until cleanup runs: an empty file, which git cannot read as a redirect + // either, so binding it would cost this repository every git command. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '') + + const source = mountSource(await wrapIn(checkout), commondir) + + expect(source).not.toBe(commondir) + expect(readFileSync(source as string, 'utf8')).toBe('.\n') + }) + + it('leaves every other absent deny on /dev/null', async () => { + const checkout = makeCheckout('repo') + + expect(mountSource(await wrapIn(checkout), join(checkout, '.bashrc'))).toBe( + '/dev/null', + ) + }) + + it.if(bwrapCanNamespace() && Bun.which('git') !== null)( + 'leaves git working across wraps, and the commondir unwritable', + async () => { + const checkout = join(dir, 'repo') + mkdirSync(checkout) + writeFileSync(join(checkout, 'index.js'), 'console.log(1)\n') + expect( + spawnSync('git', [ + '-c', + 'init.defaultBranch=main', + 'init', + '-q', + checkout, + ]).status, + ).toBe(0) + + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 20000, + cwd: checkout, + env: { ...process.env, LC_ALL: 'C' }, + }) + // By name: the dotfile denies stub their absent paths, and the mount + // points bwrap leaves for them are not files `git add -A` can stage. + const commit = (file: string) => + `git add ${file} && git -c user.name=t -c user.email=t@t ` + + `-c commit.gpgsign=false -c core.hooksPath=/dev/null ` + + `commit -q -m ${file} && echo COMMIT_OK` + + const first = run(await wrapIn(checkout, commit('index.js'))) + expect(first.stderr).toBe('') + expect(first.stdout).toContain('COMMIT_OK') + + // No cleanupBwrapMountPoints() in between: the first wrap's mount point + // for the absent commondir is still sitting in the git directory, and + // the second wrap's scan finds it there. + writeFileSync(join(checkout, 'two.js'), 'console.log(2)\n') + const second = run(await wrapIn(checkout, commit('two.js'))) + expect(second.stderr).toBe('') + expect(second.stdout).toContain('COMMIT_OK') + + const write = run( + await wrapIn(checkout, 'echo ../decoy > .git/commondir || echo DENIED'), + ) + expect(write.stdout).toContain('DENIED') + expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(true) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(false) + }, + ) +}) From 1b191c5c32d0a9c1ace4cb7c9f464c3915633f31 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 23:25:42 +0000 Subject: [PATCH 017/141] linux: take the git redirect placeholders off one table, and decide from the path The two files git reads back inside a git directory, commondir and config.worktree, were written out twice: once as deny paths and once as the stand-in each needs where it is absent, in a switch whose default arm handed a redirect file added later a silent /dev/null. Both now come off one table, which carries the rationale the five copies around it repeated, and each entry says whether it is denied even where the caller allows writes to the config. The stand-in is also chosen from the resolved deny path rather than from the entry the scan produced it with. Caller denies are applied first and win the dedup on that resolved path, so a denyWrite spelled ./.git/commondir, with a tilde, or with a trailing slash arrived with no stand-in and bound /dev/null - and git refuses to run in a repository whose commondir it cannot read, which is the failure this branch exists to prevent. Every spelling of one file shares a basename, and a file of that name outside a git directory loses nothing by being read the same way: the bind is read-only in both arms. --- src/sandbox/linux-sandbox-utils.ts | 22 +++--- src/sandbox/mandatory-deny-paths.ts | 82 +++++++++++++++-------- test/sandbox/mandatory-deny-paths.test.ts | 55 ++++++++++++--- 3 files changed, 110 insertions(+), 49 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 0445d85c2..8df3db4fd 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2233,14 +2233,6 @@ async function generateFilesystemArgs( allowGitConfig, abortSignal, ) - // What to bind where one of those is absent, keyed by the spelling the - // scan produced it with: a caller's own denyWrite that ends in the same - // name is not a git directory's and keeps the /dev/null placeholder. - const gitRedirectStubs = new Map() - for (const denyPath of mandatoryDenyPaths) { - const contents = gitRedirectPlaceholder(denyPath) - if (contents !== undefined) gitRedirectStubs.set(denyPath, contents) - } // Deny writes within allowed paths (user-specified + mandatory denies) const denyPaths = [ ...(writeConfig.denyWithinAllow || []), @@ -2614,13 +2606,15 @@ async function generateFilesystemArgs( // of /dev/null. This prevents the component from appearing as a file // which breaks tools that expect to traverse it as a directory. const isIntermediate = firstNonExistent !== normalizedPath - // A placeholder git reads, where the leaf is a path it reads: a - // commondir it cannot read makes git refuse to run at all, so every - // command in the repository would fail rather than just the write - // this deny is for. Everything else keeps /dev/null. + // A placeholder git reads, where the leaf is one of the files it + // reads back (gitRedirectPlaceholder): /dev/null there makes git + // refuse to run in the repository at all, not just refuse the write + // this deny is for. Decided from the resolved path, which every + // spelling of one file shares, rather than from the deny entry: + // a caller's own denyWrite naming the same file must not miss it. const gitRedirectStub = isIntermediate ? undefined - : gitRedirectStubs.get(pathPattern) + : gitRedirectPlaceholder(normalizedPath) const source = isIntermediate ? (emptySource ??= ensureEmptyMountSourceDir()) : gitRedirectStub === undefined @@ -2713,7 +2707,7 @@ async function generateFilesystemArgs( // an empty file until it is cleaned up: binding that would deny the // same write and cost the repository every git command, since git // refuses to run at all against a commondir it cannot read. - const gitRedirectStub = gitRedirectStubs.get(pathPattern) + const gitRedirectStub = gitRedirectPlaceholder(normalizedPath) denyWriteArgs.push( '--ro-bind', gitRedirectStub !== undefined && isEmptyFile(normalizedPath) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 3e5c63d78..6e8f0787f 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -88,48 +88,76 @@ export interface SubmoduleScan { unreadableDirs: string[] } +/** + * The files inside a git directory that send git to a git directory or a + * config other than the one it opened, and what must stand in for one where + * it does not exist. + * + * Denying a path that is not there means mounting something at it, and git + * reads whichever of these two it finds: it refuses to run at all against a + * `commondir` it cannot read, which both an empty file and a bound /dev/null + * are (git rejects a commondir it reads zero bytes from, and a bind mount + * carries nodev, so the device is unreadable). Each placeholder is what git + * concludes with the file absent: `.` makes git resolve the git directory it + * opened as its own common directory, and an empty `config.worktree` reads as + * no worktree config at all. + * + * That is not invisible. The file's mere existence sets git's + * `different_commondir`, so `git rev-parse --git-common-dir` and `--git-path` + * print the absolute real path where they printed a relative one, and a + * script that compares `--git-dir` with `--git-common-dir` to decide "this is + * a linked worktree" answers yes for an ordinary repository while the deny + * stands. No content avoids that: the alternative is git refusing to run. + * + * An empty placeholder also says that an empty file at that path is a + * legitimate one to leave alone; a non-empty placeholder says the opposite, + * which is what lets the Linux backend repair an empty `commondir` (see + * `gitRedirectMountPoint` in src/sandbox/linux-sandbox-utils.ts). + */ +const GIT_REDIRECT_FILES: ReadonlyArray<{ + name: string + placeholder: string + /** Denied even where the caller allows writes to the git config. */ + deniedWithConfigAllowed: boolean +}> = [ + // Moves the hooks and config git reads to another directory entirely. + { name: 'commondir', placeholder: '.\n', deniedWithConfigAllowed: true }, + // Read instead of `config` wherever extensions.worktreeConfig is on. + { name: 'config.worktree', placeholder: '', deniedWithConfigAllowed: false }, +] + /** * The paths inside a git directory through which a write becomes code the - * host's git runs later: hooks/ always, `commondir` always (it redirects the - * hooks and config git reads to another directory entirely), and config plus - * `config.worktree` (core.fsmonitor, core.editor, core.hooksPath and the - * like, the latter read when extensions.worktreeConfig is on) unless the - * caller allows config writes. + * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, + * core.hooksPath and the like) unless the caller allows config writes, and + * the redirect files of {@link GIT_REDIRECT_FILES}, each under the same + * condition as the file it redirects. */ export function gitDirDenyPaths( gitDir: string, allowGitConfig: boolean, ): string[] { - const denyPaths = [path.join(gitDir, 'hooks'), path.join(gitDir, 'commondir')] + const redirectFiles = (deniedWithConfigAllowed: boolean): string[] => + GIT_REDIRECT_FILES.filter( + file => file.deniedWithConfigAllowed === deniedWithConfigAllowed, + ).map(file => path.join(gitDir, file.name)) + + const denyPaths = [path.join(gitDir, 'hooks'), ...redirectFiles(true)] if (!allowGitConfig) { - denyPaths.push( - path.join(gitDir, 'config'), - path.join(gitDir, 'config.worktree'), - ) + denyPaths.push(path.join(gitDir, 'config'), ...redirectFiles(false)) } return denyPaths } /** - * What must stand in for `denyPath` where it does not exist, or undefined - * for a path git does not read this way. Denying an absent path means - * mounting something at it, and git reads whichever of these two it finds: - * it refuses to run at all against a `commondir` it cannot read, which an - * empty one and a bound /dev/null both are (git rejects a commondir it reads - * zero bytes from, and a bind mount carries nodev, so the device is - * unreadable). `.` is where git looks when a git directory has no commondir - * - the git directory itself - and no config.worktree reads the same as an - * empty one, so both placeholders leave git doing what it already does. + * What must stand in for `denyPath` where it does not exist, or undefined for + * a path git does not read this way. Decided by the basename, so every + * spelling of one path - a tilde, a relative form, a trailing slash, a + * symlinked prefix - answers the same. See {@link GIT_REDIRECT_FILES}. */ export function gitRedirectPlaceholder(denyPath: string): string | undefined { - switch (path.basename(denyPath)) { - case 'commondir': - return '.\n' - case 'config.worktree': - return '' - default: - return undefined - } + const name = path.basename(denyPath) + return GIT_REDIRECT_FILES.find(file => file.name === name)?.placeholder } /** diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 990c73500..269edd23b 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -1927,6 +1927,17 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(gitRedirectPlaceholder('/repo/.git/hooks')).toBeUndefined() }) + it('gives a placeholder to the denied files that need one, and no others', () => { + // Both lists come off one table, so a redirect file added to it is + // denied AND has a stand-in: neither can gain an entry the other misses, + // which is how a new one would end up mounted from /dev/null. + expect( + gitDirDenyPaths('/repo/.git', false).filter( + denyPath => gitRedirectPlaceholder(denyPath) !== undefined, + ), + ).toEqual(['/repo/.git/commondir', '/repo/.git/config.worktree']) + }) + it('follows a pointer to the git directory it names', () => { const gitDir = makeGitDir(join(dir, 'gitdir')) const pointer = makePointer('checkout', '../gitdir') @@ -2480,20 +2491,36 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { return checkout } - function wrapIn(checkout: string, command = 'true'): Promise { + function wrapIn( + checkout: string, + command = 'true', + denyWithinAllow: string[] = [], + ): Promise { process.chdir(checkout) return wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: false, allowAllUnixSockets: true, readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + writeConfig: { allowOnly: [checkout], denyWithinAllow }, }) } - /** What bwrap is given to mount at `dest`, of the words `--flag src dest`. */ - function mountSource(command: string, dest: string): string | undefined { - return lastMountAt(command, dest)?.split(' ')[1] + /** + * What bwrap is given to mount at `dest`, of the words `--flag src dest`. + * Throws where nothing is mounted there, so an assertion about the source + * cannot pass, or read as undefined, on a wrap that emitted no such mount. + */ + function mountSource(command: string, dest: string): string { + const mount = lastMountAt(command, dest) + if (mount === undefined) { + throw new Error(`nothing is mounted at ${dest}`) + } + const [, source, mounted] = mount.split(' ') + if (source === undefined || mounted !== dest) { + throw new Error(`the mount at ${dest} has no source: ${mount}`) + } + return source } it('binds a commondir that is not there from a placeholder holding "."', async () => { @@ -2502,9 +2529,21 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { const source = mountSource(await wrapIn(checkout), commondir) - expect(source).toBeDefined() expect(source).not.toBe('/dev/null') - expect(readFileSync(source as string, 'utf8')).toBe('.\n') + expect(readFileSync(source, 'utf8')).toBe('.\n') + }) + + it('reads the placeholder off the file, not off the deny entry', async () => { + // A caller's own deny for the same file comes first and wins the dedup, + // so the decision has to be made from the path the entry resolves to: + // made from the entry's spelling, this one missed and bound /dev/null, + // and the repository lost every git command for the length of the wrap. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const command = await wrapIn(checkout, 'true', ['./.git/commondir/']) + + expect(readFileSync(mountSource(command, commondir), 'utf8')).toBe('.\n') }) it('binds a commondir that is there from itself', async () => { @@ -2526,7 +2565,7 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { const source = mountSource(await wrapIn(checkout), commondir) expect(source).not.toBe(commondir) - expect(readFileSync(source as string, 'utf8')).toBe('.\n') + expect(readFileSync(source, 'utf8')).toBe('.\n') }) it('leaves every other absent deny on /dev/null', async () => { From 15e482f20048bd0b1fa87783a37c5bb12e37416b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 23:36:44 +0000 Subject: [PATCH 018/141] linux: keep the host's git working while a deny stands in for a redirect file Denying a path that is not there means mounting something at it, and bubblewrap makes the mount point itself: an empty file, on the HOST. For the two files git reads back inside a git directory that is a fault the whole machine sees. git refuses to run at all in a repository whose commondir it cannot read, and reads no bytes as exactly that, so from the moment a wrap is built until its cleanup, every git command outside the sandbox in that repository - another terminal, an editor, the embedder's own - failed with "fatal: failed to read .../commondir", and a process killed before its cleanup left that state for good. The placeholder this branch added fixed only what the sandbox reads. The wrap now writes the mount point itself, before bubblewrap starts, holding the placeholder, and tracks it: the host reads a redirect git accepts for as long as the command runs, and the cleanup takes the file away while it still holds exactly what was written there. A file left behind by a process that could not clean up is recognised on the next wrap - its own bytes, or the empty file an older release left, which is no one's since git refuses a commondir of zero bytes - and is repaired and removed with the rest. An empty config.worktree is legitimate and is claimed only with the shape bubblewrap leaves (read-only, empty, one link). Nothing writes over a redirect git can read. What is bound over the mount point is a copy in a store, not the file. The placeholders used to be minted one per deny per wrap into a fresh mkdtemp that nothing ever removed, and nothing bound that directory read-only inside the sandbox: with a TMPDIR under an allowed write path the sandboxed command could rewrite the source in place - the bind exposes the inode - and choose what git resolved its own git directory to. They now live in the masked-file store, which is one file per distinct content for the life of the process, mode 0600, and pinned read-only in every sandbox that mounts one, next to the two mount sources that already are. The store is emptied by the forced cleanup, which reset() and the process-exit handler call. The placeholder is also built only once the bind is known to land, so one dropped as hidden by a read-deny tmpfs leaves nothing behind, and the swap is skipped where the file already holds the placeholder. Neither the mount point nor the store file can always be written - a read-only temporary directory, a read-only git directory. There is one decision for both, logged once and fail-closed: /dev/null is the only deny left, and it costs the repository every git command inside the sandbox and, through the mount point, outside it as well, so the command is refused with LinuxSandboxProfileError('deny_placeholder_unavailable') rather than run behind a deny that breaks it. The one mode that is not a reason to refuse is the 0444 bubblewrap leaves on its own mount points, which is chmod'd first. --- README.md | 4 +- src/sandbox/credential-mask-files.ts | 13 +- src/sandbox/linux-sandbox-utils.ts | 287 +++++++++++++-- test/sandbox/mandatory-deny-paths.test.ts | 424 +++++++++++++++++++--- 4 files changed, 642 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index c1c7d1676..6737b5518 100644 --- a/README.md +++ b/README.md @@ -690,7 +690,7 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command — except at a `commondir` or `config.worktree`, where `/dev/null` is what git cannot read, and the placeholder above is written there instead. A leftover empty directory cannot be told from anyone else's and is left alone. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -715,7 +715,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. What is mounted there is therefore a placeholder git reads as no redirect — `.` for `commondir`, which is the git directory itself, and nothing for `config.worktree` — and a file already in place holding no bytes is covered the same way, bubblewrap's own mount point for the absent case being an empty file until the wrap cleans it up. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. The file is removed after the command; one left behind by a killed process is rewritten and removed by the next wrap, and an empty `commondir`, which git refuses outright, is repaired the same way. For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: diff --git a/src/sandbox/credential-mask-files.ts b/src/sandbox/credential-mask-files.ts index cc05e8299..b07318184 100644 --- a/src/sandbox/credential-mask-files.ts +++ b/src/sandbox/credential-mask-files.ts @@ -53,7 +53,9 @@ export interface MaskedFileBind { } /** - * Manager-owned temp dir holding the fake files. + * Manager-owned temp dir holding the fake files. The Linux backend keeps a + * second store of its own for the placeholders its git redirect denies bind + * from, which need this same guarantee for this same reason. * * INVARIANT: this directory must never be writable from inside the sandbox. * The Linux layer enforces this by emitting `--ro-bind ` @@ -66,6 +68,13 @@ export interface MaskedFileBind { export class MaskedFileStore { private dir: string | undefined private readonly byKey = new Map() + private readonly dirPrefix: string + + /** `dirPrefix` names this store's temp directory, so what it holds is + * recognisable on disk and one store's directory is never another's. */ + constructor(dirPrefix: string = MASKED_FILE_STORE_PREFIX) { + this.dirPrefix = dirPrefix + } /** * Write `sentinel` to a fake file for `key` and return its path. @@ -75,7 +84,7 @@ export class MaskedFileStore { */ write(key: string, sentinel: string): string { if (this.dir === undefined) { - this.dir = fs.mkdtempSync(join(tmpdir(), 'srt-credmask-')) + this.dir = fs.mkdtempSync(join(tmpdir(), this.dirPrefix)) } let fakePath = this.byKey.get(key) if (fakePath === undefined) { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 8df3db4fd..b3db01350 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -34,6 +34,7 @@ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, } from './sandbox-schemas.js' +import { MaskedFileStore } from './credential-mask-files.js' import { getApplySeccompBinaryPath } from './generate-seccomp-filter.js' import type { SeccompConfig } from './sandbox-config.js' @@ -253,29 +254,152 @@ function hasFileAncestor(targetPath: string): boolean { return false } -/** Whether `filePath` is a regular file holding no bytes. */ -function isEmptyFile(filePath: string): boolean { +/** + * What `file` holds, when it is a regular file of no more than `limit` + * bytes; undefined for anything else, which is everything this needs to tell + * from the placeholders it compares against. + */ +function fileContentsWithin(file: string, limit: number): string | undefined { try { - const stat = fs.statSync(filePath) - return stat.isFile() && stat.size === 0 + const stat = fs.lstatSync(file) + if (!stat.isFile() || stat.size > limit) return undefined + return fs.readFileSync(file, 'utf8') } catch { - return false + return undefined } } /** - * A read-only file holding `contents`, to bind where an absent deny path is - * one something else reads and /dev/null would not do - see - * {@link gitRedirectPlaceholder}. It gets a directory of its own so the - * sandboxed command cannot reach the mount source under a name it can write. + * Make `dest` ready for the read-only bind that denies it, where it is one + * of the files git reads back (`gitRedirectPlaceholder` in + * src/sandbox/mandatory-deny-paths.ts), and return what to bind there. + * + * bubblewrap makes the mount point for an absent destination itself, with + * ensure_file(): an empty file — and that file is on the HOST, where it is + * what every git command outside the sandbox reads for as long as this one + * runs, and for good if this process is killed. git refuses to run at all in + * a repository whose commondir it cannot read, so the mount point is made + * here instead, holding the placeholder, and tracked for the cleanup. An + * empty file already there is rewritten to the placeholder and tracked the + * same way: no bytes at all is what git refuses, so nothing puts that there + * on purpose. Where the placeholder is itself empty an empty file IS + * legitimate, and only one of the shape bubblewrap leaves + * ({@link isStaleBwrapMountPoint}) is taken for this wrapper's own. + * + * What is bound over it is the store's copy rather than the file, so what + * the sandboxed command reads there is not what a host process rewrites + * between this call and the mount. A destination already holding the + * placeholder, or a redirect git can read, is bound from itself instead. + * + * Throws {@link LinuxSandboxProfileError} when neither can be prepared: what + * is left is /dev/null, which costs the repository every git command inside + * the sandbox and, through the mount point, outside it as well, so the + * command is refused rather than run behind that. */ -function denyPlaceholderFile(contents: string): string { - const file = path.join( - fs.mkdtempSync(path.join(tmpdir(), 'claude-stub-')), - 'placeholder', +function gitRedirectMountPoint(dest: string, placeholder: string): string { + const takeOwnership = (): void => { + redirectMountPointBytes.set(dest, placeholder) + bwrapMountPoints.add(dest) + registerExitCleanupHandler() + } + + try { + // Exclusive: a file that appeared since the deny loop looked is never + // truncated, it falls to the existing-file arms below. + fs.writeFileSync(dest, placeholder, { flag: 'wx', mode: 0o644 }) + takeOwnership() + logForDebugging( + `[Sandbox Linux] Wrote the mount point ${dest} holding ${JSON.stringify(placeholder)}, so git reads no redirect there while the command runs`, + ) + return gitRedirectStoreFile(dest, placeholder) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw placeholderUnavailable( + dest, + 'its mount point could not be made', + err, + ) + } + } + + const contents = fileContentsWithin(dest, Buffer.byteLength(placeholder)) + if (contents === '' && placeholder !== '') { + try { + // bubblewrap makes its mount points read-only (ensure_file(dest, + // 0444)), and the process that owns one need not be root, so the mode + // it was left with is no reason to leave the repository broken. + try { + fs.writeFileSync(dest, placeholder) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EACCES') throw err + fs.chmodSync(dest, 0o644) + fs.writeFileSync(dest, placeholder) + } + } catch (err) { + throw placeholderUnavailable( + dest, + 'the empty file left there could not be rewritten', + err, + ) + } + takeOwnership() + logForDebugging( + `[Sandbox Linux] Rewrote ${dest}, left empty by a sandbox that did not clean up, to ${JSON.stringify(placeholder)}: git reads no bytes there as a fault, not as "no redirect"`, + ) + return gitRedirectStoreFile(dest, placeholder) + } + if ( + contents === placeholder && + (placeholder !== '' || isStaleBwrapMountPoint(dest)) + ) { + // This wrapper's own, left by a process that could not clean up: nothing + // else writes exactly these bytes there, and for an empty placeholder + // nothing else leaves a file of that shape. Bound from itself — the + // swap would put back what is already in it — and removed with the rest. + takeOwnership() + logForDebugging( + `[Sandbox Linux] ${dest} already holds the placeholder ${JSON.stringify(placeholder)} an earlier sandbox left; binding it from itself and taking it away with this wrap's mount points`, + ) + return dest + } + logForDebugging( + `[Sandbox Linux] Binding ${dest} from itself: what it holds is not the ${JSON.stringify(placeholder)} placeholder, so it is not this wrapper's to replace`, + ) + return dest +} + +/** The store's copy of `placeholder`, made on first use of that content. */ +function gitRedirectStoreFile(dest: string, placeholder: string): string { + try { + // Keyed by content, so one file serves every destination that needs it; + // the store rewrites it on each use, which is also what revalidates it. + return gitRedirectStore.write(placeholder, placeholder) + } catch (err) { + throw placeholderUnavailable( + dest, + 'no placeholder file could be written to the temporary directory', + err, + ) + } +} + +/** Refuse the wrap, once, naming what could not be prepared and why. */ +function placeholderUnavailable( + dest: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `Cannot deny ${dest} without stopping git from working in that ` + + `repository: ${what} (${errorText(cause)}). git refuses to run at all ` + + `against a redirect file it cannot read, so the command was not run ` + + `rather than sandboxed behind a deny that breaks it` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError( + 'deny_placeholder_unavailable', + message, + cause, ) - fs.writeFileSync(file, contents, { mode: 0o444 }) - return file } /** @@ -530,6 +654,25 @@ async function linuxGetMandatoryDenyPaths( // be cleaned up explicitly. const bwrapMountPoints: Set = new Set() +// What this wrapper wrote at a mount point of its own making, keyed by a +// path in bwrapMountPoints: the cleanup takes such a file away only while it +// still holds exactly those bytes. Only the git redirect denies make one — +// every other mount point is bwrap's, and empty (see gitRedirectMountPoint). +const redirectMountPointBytes = new Map() + +/** Temp-directory prefix of the store below, so it is nobody else's. */ +export const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' + +// The placeholders the git redirect denies bind over their mount points: one +// file per distinct content for the life of the process, rewritten on each +// use, and pinned read-only inside every sandbox that mounts one. It is the +// masked-file store's own class for the reason that store exists — a bind +// exposes the source file itself, so a command that can reach the source +// under a name it can write chooses what git reads at the denied path. +// Emptied by cleanupBwrapMountPoints({ force: true }), which reset() and the +// process-exit handler call. +const gitRedirectStore = new MaskedFileStore(GIT_REDIRECT_STORE_PREFIX) + // The source of the empty-directory mount points: at most one at a time, made // on first use, reused while a sandbox is running, and removed with the mount // points — never before, because a live bind's source must stay. @@ -861,6 +1004,15 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** + * A deny path git reads back — a git directory's `commondir` or + * `config.worktree` — could not be given a stand-in git accepts: neither + * the mount point on the host nor the placeholder in the temporary + * directory could be written. Mounting /dev/null there instead would stop + * git working in that repository altogether, so the command is refused. + * Whatever the wrap did make is tracked and goes with the next cleanup. + */ + | 'deny_placeholder_unavailable' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the @@ -1024,7 +1176,12 @@ function registerExitCleanupHandler(): void { * stops applying inside that sandbox. * * Pass `{ force: true }` to delete unconditionally — used by the process-exit - * handler and reset() where deferral is not meaningful. + * handler and reset() where deferral is not meaningful. That is also what + * empties the store of git redirect placeholders, which is per process. + * + * A mount point this wrapper wrote itself rather than left to bwrap (a git + * redirect deny's, holding what {@link gitRedirectMountPoint} put there) is + * removed while it still holds exactly that, as an empty one is. * * Also closes the `--args` profiles the wraps of this batch opened. */ @@ -1045,10 +1202,18 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { for (const mountPoint of bwrapMountPoints) { try { - // Only remove if it's still the empty file/directory bwrap created. - // If something else has written real content, leave it alone. + // Only remove if it's still the empty file/directory bwrap created, or + // — for a mount point this wrapper wrote itself, which a git redirect + // deny does — the exact bytes it wrote there. If something else has + // written real content, leave it alone. const stat = fs.statSync(mountPoint) - if (stat.isFile() && stat.size === 0) { + const written = redirectMountPointBytes.get(mountPoint) + const stillAsCreated = + stat.size === 0 || + (written !== undefined && + stat.size === Buffer.byteLength(written) && + fs.readFileSync(mountPoint, 'utf8') === written) + if (stat.isFile() && stillAsCreated) { fs.unlinkSync(mountPoint) logForDebugging( `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, @@ -1069,6 +1234,13 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { } } bwrapMountPoints.clear() + redirectMountPointBytes.clear() + if (opts?.force) { + // The placeholders outlive a batch of wraps the way the masked-file + // store's fakes do: what ends them is the session (or the process), not + // a command. A live bind's source has to stay until then. + gitRedirectStore.dispose() + } if (emptyMountSourceDir !== undefined) { try { // rmdirSync, not a recursive remove: it neither follows a symlink nor @@ -1797,6 +1969,15 @@ async function generateFilesystemArgs( // tampered with in between) would leave the binds already emitted pointing // at a directory this same call has just judged not ours. let emptySource: string | undefined + // Deny destinations that are files git reads back, and the placeholder + // each needs, filled in by the loop below and acted on when the bind is + // emitted: preparing one writes a file on the HOST as well (see + // gitRedirectMountPoint), so a bind the emission drops as hidden by a + // read-deny tmpfs must not have cost anything. + const pendingGitRedirects = new Map() + // The store directory a placeholder came from, pinned read-only with the + // other mount sources at the end. Set only where one was actually used. + let gitRedirectSourceDir: string | undefined // Where a mount given `p` lands: `p` fully resolved, every symlink on the // way and not one hop. One resolution per path per wrap, so every predicate // below sees the same answer, and none before the mandatory-deny scan's @@ -2615,11 +2796,13 @@ async function generateFilesystemArgs( const gitRedirectStub = isIntermediate ? undefined : gitRedirectPlaceholder(normalizedPath) + // The redirect source is not built here: it is a file on the host + // as well as a bind, and this bind may still be dropped below as + // hidden by a read-deny tmpfs. /dev/null stands in the buffer + // until the emission replaces it (pendingGitRedirects). const source = isIntermediate ? (emptySource ??= ensureEmptyMountSourceDir()) - : gitRedirectStub === undefined - ? '/dev/null' - : denyPlaceholderFile(gitRedirectStub) + : '/dev/null' // One mount point per destination. Deny paths are deduplicated on // the deny path, but a placeholder lands on the first MISSING @@ -2634,7 +2817,12 @@ async function generateFilesystemArgs( // deeper deny that asked for a directory. const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent) if (placeholderAt !== undefined) { - if (isIntermediate) denyWriteArgs[placeholderAt] = source + if (isIntermediate) { + denyWriteArgs[placeholderAt] = source + // The destination has to be a directory for the deeper deny, + // so it is no longer a file git reads back. + pendingGitRedirects.delete(firstNonExistent) + } logForDebugging( `[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`, ) @@ -2645,6 +2833,9 @@ async function generateFilesystemArgs( firstNonExistent, denyWriteArgs.length - 2, ) + if (gitRedirectStub !== undefined) { + pendingGitRedirects.set(firstNonExistent, gitRedirectStub) + } // First writer wins for a destination several denies share (the // reuse branch above returns before reaching this), and the record // is purely additive: it only gives the tmpfs and mask comparisons @@ -2655,7 +2846,11 @@ async function generateFilesystemArgs( registerExitCleanupHandler() logForDebugging( `[Sandbox Linux] Mounted ${ - isIntermediate ? 'empty dir' : source + isIntermediate + ? 'empty dir' + : gitRedirectStub === undefined + ? '/dev/null' + : 'a git redirect placeholder' } at ${firstNonExistent} to block creation of ${normalizedPath}`, ) } else if (ancestorIsWithinReadOnlyDeny) { @@ -2701,20 +2896,19 @@ async function generateFilesystemArgs( ) } } - // A redirect file holding nothing git can read as one is bound from - // the placeholder rather than from itself. The usual way to find one - // is a previous wrap's own mount point for the absent case, which is - // an empty file until it is cleaned up: binding that would deny the + // A file git reads back is bound from itself here only if it holds a + // redirect git can read. The usual way to find one that does not is + // a previous wrap's own mount point for the absent case, left empty + // by a process that could not clean up: binding that would deny the // same write and cost the repository every git command, since git - // refuses to run at all against a commondir it cannot read. + // refuses to run at all against a commondir it cannot read. Which of + // the two it is is settled when the bind is emitted, along with what + // the host reads there (gitRedirectMountPoint). const gitRedirectStub = gitRedirectPlaceholder(normalizedPath) - denyWriteArgs.push( - '--ro-bind', - gitRedirectStub !== undefined && isEmptyFile(normalizedPath) - ? denyPlaceholderFile(gitRedirectStub) - : normalizedPath, - normalizedPath, - ) + if (gitRedirectStub !== undefined) { + pendingGitRedirects.set(normalizedPath, gitRedirectStub) + } + denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } else { logForDebugging( @@ -2997,7 +3191,16 @@ async function generateFilesystemArgs( } continue } - args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) + // This bind lands, so the file git reads at `dest` is settled now: the + // mount point on the host is written here, not left to bwrap, and the + // placeholder bound over it comes from the store. + const pendingRedirect = pendingGitRedirects.get(dest) + let source = denyWriteArgs[i + 1]! + if (pendingRedirect !== undefined) { + source = gitRedirectMountPoint(dest, pendingRedirect) + if (source !== dest) gitRedirectSourceDir = path.dirname(source) + } + args.push(denyWriteArgs[i]!, source, dest) 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 @@ -3062,6 +3265,16 @@ async function generateFilesystemArgs( args.push('--ro-bind', maskedFileStoreDir, maskedFileStoreDir) } + // INVARIANT, for the same reason and with the same remedy: the store of git + // redirect placeholders. A bind exposes the source file itself, so a + // command able to write it chooses what git reads at the DENIED path. The + // three mount sources here are siblings under the temp directory, each its + // own mkdtemp, so no one of these binds can cover another whatever the + // order; all three sit after every mount that could cover them. + if (gitRedirectSourceDir !== undefined) { + args.push('--ro-bind', gitRedirectSourceDir, gitRedirectSourceDir) + } + // INVARIANT, for the same reason: the empty directory the placeholders above // bind from must never be writable from inside the sandbox. It lives under // the system temp dir, so a caller's allowWrite or a host $TMPDIR under a diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 269edd23b..5786b0cf1 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -11,6 +11,7 @@ import { spawn, spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, + readdirSync, mkdtempSync, renameSync, rmSync, @@ -37,6 +38,8 @@ import { import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, + GIT_REDIRECT_STORE_PREFIX, + LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' import { GitMetadataError, @@ -312,14 +315,16 @@ describe.if(isSupportedPlatform)( } /** - * The write did not land. On Linux bwrap leaves the empty file it - * mounted over the absent deny path; on macOS nothing is created. + * The write did not land. On Linux the mount point for an absent deny + * path stays until the cleanup: bwrap's own empty file, or, for a file + * git reads back, the placeholder the wrap wrote there (`expected`). On + * macOS nothing is created. */ - function expectNotWritten(absolutePath: string): void { + function expectNotWritten(absolutePath: string, expected = ''): void { const content = existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : '' - expect(content).toBe('') + expect(content).toBe(isLinux ? expected : '') } async function runSandboxedWrite( @@ -595,7 +600,7 @@ describe.if(isSupportedPlatform)( const result = await runSandboxedWrite('.git/commondir', 'decoy') expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, '.git', 'commondir')) + expectNotWritten(join(TEST_DIR, '.git', 'commondir'), '.\n') }) it("blocks creating a commondir in a submodule's git directory", async () => { @@ -605,14 +610,17 @@ describe.if(isSupportedPlatform)( ) expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, '.git', 'modules', 'lib', 'commondir')) + expectNotWritten( + join(TEST_DIR, '.git', 'modules', 'lib', 'commondir'), + '.\n', + ) }) it("blocks creating a nested repository's commondir", async () => { const result = await runSandboxedWrite('nested/.git/commondir', 'decoy') expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, 'nested', '.git', 'commondir')) + expectNotWritten(join(TEST_DIR, 'nested', '.git', 'commondir'), '.\n') }) it('blocks creating .git/config.worktree', async () => { @@ -2461,16 +2469,33 @@ describe('Git metadata deny paths - Unit Tests', () => { }) }) /** - * Denying a path that is not there means mounting something at it, and two - * of these the host's git reads: it refuses to run at all against a - * `commondir` it cannot read, and /dev/null is unreadable through a bind - * mount. So those denies bind a placeholder that says "nothing redirected" - * instead, and they do it for an empty file too - bwrap's own mount point - * for the absent case is one of those until it is cleaned up. + * Denying a path that is not there means mounting something at it, and two of + * these the host's git reads back: it refuses to run at all against a + * `commondir` it cannot read, and neither a bound /dev/null nor an empty file + * is one. So these denies write their own mount point, holding what git + * concludes with no file there, and bind a read-only copy of the same bytes + * over it: the host's git keeps working for as long as the command runs, and + * the sandbox's does too. */ describe.if(isLinux)('Placeholders for the files git reads', () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const LIVE = bwrapCanNamespace() && Bun.which('git') !== null + /** Enough to commit without a hook, an identity or a signature. */ + const IDENT = [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + ] let dir: string const savedCwd = process.cwd() + const savedTmpdir = process.env.TMPDIR beforeEach(() => { dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) @@ -2478,7 +2503,11 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { afterEach(() => { process.chdir(savedCwd) + // Forced cleanup also empties the placeholder store, so no case here + // leaves one of its directories behind. cleanupBwrapMountPoints({ force: true }) + if (savedTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = savedTmpdir rmSync(dir, { recursive: true, force: true }) }) @@ -2494,7 +2523,7 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { function wrapIn( checkout: string, command = 'true', - denyWithinAllow: string[] = [], + opts: { denyWithinAllow?: string[]; allowOnly?: string[] } = {}, ): Promise { process.chdir(checkout) return wrapCommandWithSandboxLinux({ @@ -2502,7 +2531,10 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { needsNetworkRestriction: false, allowAllUnixSockets: true, readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow }, + writeConfig: { + allowOnly: opts.allowOnly ?? [checkout], + denyWithinAllow: opts.denyWithinAllow ?? [], + }, }) } @@ -2523,6 +2555,42 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { return source } + function git( + cwd: string, + args: string[], + ): { status: number | null; stdout: string } { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { status: result.status, stdout: `${result.stdout}${result.stderr}` } + } + + /** A repository with one commit, made by git itself. */ + function gitRepo(name: string): string { + const repo = join(dir, name) + mkdirSync(repo, { recursive: true }) + expect( + git(repo, ['-c', 'init.defaultBranch=main', 'init', '-q', '.']), + ).toMatchObject({ status: 0 }) + writeFileSync(join(repo, 'index.js'), 'console.log(1)\n') + expect(git(repo, ['add', 'index.js'])).toMatchObject({ status: 0 }) + expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'one'])).toMatchObject({ + status: 0, + }) + return repo + } + + async function waitFor(ready: () => boolean, what: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (ready()) return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`timed out waiting for ${what}`) + } + it('binds a commondir that is not there from a placeholder holding "."', async () => { const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') @@ -2531,6 +2599,9 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { expect(source).not.toBe('/dev/null') expect(readFileSync(source, 'utf8')).toBe('.\n') + // The mount point is this wrapper's own rather than the empty file + // bubblewrap would make: it is what the HOST's git reads meanwhile. + expect(readFileSync(commondir, 'utf8')).toBe('.\n') }) it('reads the placeholder off the file, not off the deny entry', async () => { @@ -2541,7 +2612,9 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') - const command = await wrapIn(checkout, 'true', ['./.git/commondir/']) + const command = await wrapIn(checkout, 'true', { + denyWithinAllow: ['./.git/commondir/'], + }) expect(readFileSync(mountSource(command, commondir), 'utf8')).toBe('.\n') }) @@ -2552,20 +2625,78 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { writeFileSync(commondir, '../..\n') expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + }) + + it('takes its own mount point away, and a changed one never', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + await wrapIn(checkout) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + + // A mount point something has written a real redirect into is not this + // wrapper's to remove, the same way an empty one that gained content is + // not: the cleanup compares what is there with what it wrote. + await wrapIn(checkout) + writeFileSync(commondir, '../..\n') + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') }) - it('binds a commondir left empty from the placeholder as well', async () => { - // What an earlier wrap's mount point for the absent case looks like - // until cleanup runs: an empty file, which git cannot read as a redirect - // either, so binding it would cost this repository every git command. + it('repairs a commondir left empty rather than covering it', async () => { + // The shape bubblewrap's own ensure_file() leaves, which the wrap takes + // for a mount point an earlier sandbox left behind and covers with + // /dev/null — the one thing a commondir must never be covered with. const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') - writeFileSync(commondir, '') + writeFileSync(commondir, '', { mode: 0o444 }) const source = mountSource(await wrapIn(checkout), commondir) - expect(source).not.toBe(commondir) + expect(source).not.toBe('/dev/null') expect(readFileSync(source, 'utf8')).toBe('.\n') + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('binds a commondir already holding the placeholder from itself', async () => { + // What a killed process leaves: nothing in memory knows the file is a + // mount point, but nothing else writes exactly those bytes there, so + // the next wrap claims it, binds it from itself and removes it. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '.\n') + + expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('leaves alone an empty config.worktree it did not leave there', async () => { + // An empty config.worktree reads as no worktree config at all, so one is + // legitimate and this wrapper never claims it — unless it has the shape + // bubblewrap leaves, which nothing writing a config on purpose does. + const checkout = makeCheckout('repo') + const legitimate = join(checkout, '.git', 'config.worktree') + writeFileSync(legitimate, '', { mode: 0o644 }) + + expect(mountSource(await wrapIn(checkout), legitimate)).toBe(legitimate) + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(legitimate)).toBe(true) + + // The shape bubblewrap leaves, which nothing writing a config on + // purpose has: read-only, empty, one link. + chmodSync(legitimate, 0o444) + await wrapIn(checkout) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(legitimate)).toBe(false) }) it('leaves every other absent deny on /dev/null', async () => { @@ -2576,56 +2707,259 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { ) }) - it.if(bwrapCanNamespace() && Bun.which('git') !== null)( - 'leaves git working across wraps, and the commondir unwritable', + it('mints one placeholder file for the process, not one per wrap', async () => { + process.env.TMPDIR = dir + const checkout = makeCheckout('repo') + const stores = (): string[] => + readdirSync(dir).filter(entry => + entry.startsWith(GIT_REDIRECT_STORE_PREFIX), + ) + expect(stores()).toHaveLength(0) + + for (let wrap = 0; wrap < 20; wrap++) { + await wrapIn(checkout) + // Not forced: the store is per process, so a batch of wraps ending + // must not empty it — and the mount point goes, so the next wrap + // takes the absent-path branch again. + cleanupBwrapMountPoints() + } + + expect(stores()).toHaveLength(1) + // One file per distinct placeholder, whatever the number of wraps: the + // repository's absent commondir and its absent config.worktree. + expect(readdirSync(join(dir, stores()[0]!))).toHaveLength(2) + }) + + it('refuses the command when no placeholder can be written', async () => { + // Decided once, logged once, and fail-closed: /dev/null is the only + // deny left, and it costs the repository every git command. Refusing + // says so; sandboxing behind it would not. + const checkout = makeCheckout('repo') + // So that no deny needs the shared empty directory, whose own source + // this temp directory would break first. + mkdirSync(join(checkout, '.claude')) + const notADirectory = join(dir, 'not-a-directory') + writeFileSync(notADirectory, '') + process.env.TMPDIR = notADirectory + + const refusal = await wrapIn(checkout).then( + () => undefined, + (err: unknown) => err, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'deny_placeholder_unavailable', + ) + }) + + it.if(LIVE)( + 'keeps the placeholder out of the reach of the sandboxed command', async () => { - const checkout = join(dir, 'repo') - mkdirSync(checkout) - writeFileSync(join(checkout, 'index.js'), 'console.log(1)\n') + // The store sits under $TMPDIR, which is routinely inside an allowed + // write path; the bind exposes the source file itself, so a command + // able to rewrite it would choose what git reads at the denied path. + process.env.TMPDIR = dir + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const source = mountSource( + await wrapIn(repo, 'true', { + allowOnly: [repo, dir], + }), + commondir, + ) + cleanupBwrapMountPoints() + + const probe = [ + `echo ${BOOTED}`, + `chmod 666 ${source} 2>&1 || echo CHMOD_REFUSED`, + `echo poison > ${source} 2>&1 || echo WRITE_REFUSED`, + `cat ${source}`, + `git rev-parse --git-common-dir`, + ].join('; ') + const command = await wrapIn(repo, probe, { allowOnly: [repo, dir] }) + // Memoised by content, so the command names the source this wrap binds. + expect(mountSource(command, commondir)).toBe(source) + + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: repo, + env: { ...process.env, LC_ALL: 'C' }, + }) + const output = `${run.stdout}${run.stderr}` + + expect(output).toContain(BOOTED) + expect(output).toContain('Read-only file system') + expect(output).toContain('CHMOD_REFUSED') + expect(output).toContain('WRITE_REFUSED') + expect(output).not.toContain('poison') + // git still resolves its own git directory as its common directory. + expect(output).toContain(join(repo, '.git')) + }, + ) + + it.if(LIVE)( + "keeps the host's git working while a wrapped command runs", + async () => { + const sub = gitRepo('sub') + const repo = gitRepo('repo') expect( - spawnSync('git', [ + git(repo, [ '-c', - 'init.defaultBranch=main', - 'init', + 'protocol.file.allow=always', + ...IDENT, + 'submodule', + 'add', '-q', - checkout, - ]).status, - ).toBe(0) + sub, + 'lib', + ]), + ).toMatchObject({ status: 0 }) + expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'lib'])).toMatchObject({ + status: 0, + }) + expect( + git(repo, ['worktree', 'add', '-q', join(dir, 'wt')]), + ).toMatchObject({ status: 0 }) + const commondir = join(repo, '.git', 'commondir') + const submoduleCommondir = join( + repo, + '.git', + 'modules', + 'lib', + 'commondir', + ) + + // Runs until the host releases it, so the host's git is exercised with + // the sandbox up and the mount points in place. + const command = await wrapIn( + repo, + `echo ${BOOTED} > up; i=0; while [ ! -f release ] && [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done; echo ${BOOTED}`, + ) + const child = spawn(command, { + shell: true, + cwd: repo, + stdio: 'ignore', + }) + try { + await waitFor( + () => existsSync(join(repo, 'up')), + 'the sandbox to start', + ) + + // Both mount points are on the host, holding a redirect git accepts. + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(readFileSync(submoduleCommondir, 'utf8')).toBe('.\n') + for (const args of [ + ['status', '--porcelain'], + ['log', '--oneline'], + ['worktree', 'list'], + ['rev-parse', '--git-common-dir'], + ]) { + expect({ args, ...git(repo, args) }).toMatchObject({ status: 0 }) + } + expect( + git(repo, [ + ...IDENT, + 'commit', + '--allow-empty', + '-q', + '-m', + 'during', + ]), + ).toMatchObject({ status: 0 }) + expect(git(join(repo, 'lib'), ['status', '--porcelain'])).toMatchObject( + { + status: 0, + }, + ) + } finally { + writeFileSync(join(repo, 'release'), '') + await new Promise(resolve => child.on('close', resolve)) + } + expect(child.exitCode).toBe(0) + + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(existsSync(submoduleCommondir)).toBe(false) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + }, + ) + + it.if(LIVE)( + 'leaves a killed wrap behind a commondir git reads, and repairs an empty one', + async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + + // What a killed process leaves now: the host's git works on it, and + // the next wrap takes it away. + writeFileSync(commondir, '.\n') + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + expect(git(repo, ['log', '--oneline'])).toMatchObject({ status: 0 }) + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + + // What an older release leaves: git refuses every command in the + // repository until the next wrap rewrites it. + writeFileSync(commondir, '', { mode: 0o444 }) + const broken = git(repo, ['status', '--porcelain']) + expect(broken.status).not.toBe(0) + expect(broken.stdout).toContain('commondir') + + await wrapIn(repo) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + }, + ) + + it.if(LIVE)( + 'leaves git working across wraps, and the commondir unwritable', + async () => { + const repo = gitRepo('repo') const run = (command: string) => spawnSync(command, { shell: true, encoding: 'utf8', - timeout: 20000, - cwd: checkout, + timeout: 30000, + cwd: repo, env: { ...process.env, LC_ALL: 'C' }, }) // By name: the dotfile denies stub their absent paths, and the mount // points bwrap leaves for them are not files `git add -A` can stage. const commit = (file: string) => - `git add ${file} && git -c user.name=t -c user.email=t@t ` + - `-c commit.gpgsign=false -c core.hooksPath=/dev/null ` + + `echo ${BOOTED} && git add ${file} && git ${IDENT.join(' ')} ` + `commit -q -m ${file} && echo COMMIT_OK` - const first = run(await wrapIn(checkout, commit('index.js'))) - expect(first.stderr).toBe('') + writeFileSync(join(repo, 'one.js'), 'console.log(1)\n') + const first = run(await wrapIn(repo, commit('one.js'))) + expect(first.stdout).toContain(BOOTED) + expect(first.status).toBe(0) expect(first.stdout).toContain('COMMIT_OK') // No cleanupBwrapMountPoints() in between: the first wrap's mount point // for the absent commondir is still sitting in the git directory, and // the second wrap's scan finds it there. - writeFileSync(join(checkout, 'two.js'), 'console.log(2)\n') - const second = run(await wrapIn(checkout, commit('two.js'))) - expect(second.stderr).toBe('') + writeFileSync(join(repo, 'two.js'), 'console.log(2)\n') + const second = run(await wrapIn(repo, commit('two.js'))) + expect(second.stdout).toContain(BOOTED) + expect(second.status).toBe(0) expect(second.stdout).toContain('COMMIT_OK') const write = run( - await wrapIn(checkout, 'echo ../decoy > .git/commondir || echo DENIED'), + await wrapIn(repo, 'echo ../decoy > .git/commondir || echo DENIED'), ) expect(write.stdout).toContain('DENIED') - expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(true) + expect(existsSync(join(repo, '.git', 'commondir'))).toBe(true) cleanupBwrapMountPoints({ force: true }) - expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(false) + expect(existsSync(join(repo, '.git', 'commondir'))).toBe(false) }, ) }) From e275b42b4ba0f6aeed10b126e1a4cfdf20e47d73 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 17 Sep 2026 23:40:48 +0000 Subject: [PATCH 019/141] violations: do not fail the monitor's start-up over one repository The Linux violation monitor judges a refused write against the same cwd deny paths a wrap applies, and this branch gave that producer a way to throw: a commondir past the size it reads, or a path whose bytes are not valid UTF-8, refuses the wrap rather than sandboxing with a deny list that may name the wrong directory. The monitor calls it once, from initialize(), with no catch - so standing in such a repository failed the whole session's start-up, not one command. It now asks through a wrapper that falls back to the cwd's plain deny paths - its dangerous files and directories, and either the .git pointer file itself or that git directory's own hooks, config and redirect files - and warns. What this decides is which refused write the monitor reports; every wrap in the repository still refuses its command outright until the metadata is readable. --- src/sandbox/linux-sandbox-utils.ts | 59 ++++++++++++++++++++--- src/sandbox/sandbox-manager.ts | 9 ++-- test/sandbox/mandatory-deny-paths.test.ts | 32 ++++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index b3db01350..56a084193 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -491,13 +491,7 @@ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, ): string[] { const cwd = process.cwd() - // Note: Settings files are added at the callsite in sandbox-manager.ts - const denyPaths = [ - // Dangerous files in CWD - ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), - // Dangerous directories in CWD - ...getDangerousDirectories().map(d => path.resolve(cwd, d)), - ] + const denyPaths = cwdDangerousDenyPaths(cwd) const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -517,6 +511,57 @@ export function linuxGetCwdMandatoryDenyPaths( return denyPaths } +/** + * The deny paths `cwd` has whatever its `.git` turns out to be, and whatever + * can be read of it. Settings files are added at the callsite in + * src/sandbox/sandbox-manager.ts. + */ +function cwdDangerousDenyPaths(cwd: string): string[] { + return [ + // Dangerous files in CWD + ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), + // Dangerous directories in CWD + ...getDangerousDirectories().map(d => path.resolve(cwd, d)), + ] +} + +/** + * {@link linuxGetCwdMandatoryDenyPaths} for the violation monitor, which is + * started once for the session and must not fail over one repository: the + * same paths, or - where a `.git` pointer or a `commondir` names something + * that cannot be resolved the way git resolves it - this directory's plain + * deny paths, with a warning. Every wrap in such a repository still refuses + * its command outright, so what this decides is which refused write the + * monitor reports, never what bubblewrap enforces. + */ +export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { + try { + return linuxGetCwdMandatoryDenyPaths(allowGitConfig) + } catch (err) { + const cwd = process.cwd() + const dotGitPath = path.resolve(cwd, '.git') + logForDebugging( + `[Sandbox Linux] Could not resolve ${dotGitPath} the way git does (${errorText(err)}); the violation monitor judges writes against ${cwd}'s plain deny paths instead. Every wrapped command in it is refused until that is fixed.`, + { level: 'warn' }, + ) + let isPointerFile = false + try { + isPointerFile = fs.statSync(dotGitPath).isFile() + } catch { + // Gone since, or unreachable: neither shape's denies apply. + } + return [ + ...cwdDangerousDenyPaths(cwd), + // A pointer file is itself a deny, and what it leads to is exactly + // what could not be followed; a git directory's own hooks and config + // need nothing followed to name. + ...(isPointerFile + ? [dotGitPath] + : gitDirDenyPaths(dotGitPath, allowGitConfig)), + ] + } +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index fedd50196..501c8a716 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -46,7 +46,7 @@ import { checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, - linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { @@ -702,7 +702,10 @@ async function initialize( // normalizePathForSandbox resolves `~`, relative spellings and symlinks), // plus the built-in write denies the wrapper always applies. // It does not reproduce the wrapper's existence and boundary-symlink - // filters, nor the ripgrep scan for nested dangerous paths; and it still + // filters, nor the ripgrep scan for nested dangerous paths; a repository + // whose git metadata cannot be resolved leaves it the cwd's plain denies + // rather than failing this whole start-up (every wrap there is still + // refused); and it still // reports writes bwrap permits through `--dev`, `--proc` and the tmpfs // over each read-denied directory. Started once, so both lists are fixed // at the cwd and configuration of this call. @@ -730,7 +733,7 @@ async function initialize( // them, so the monitor must not judge by them either. ...(config.filesystem.disabled ? [] - : linuxGetCwdMandatoryDenyPaths(getAllowGitConfig())), + : linuxGetMonitorCwdDenyPaths(getAllowGitConfig())), ], ignoreViolations: config.ignoreViolations, resolveCommandText, diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 5786b0cf1..5cdb0061f 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -38,6 +38,8 @@ import { import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, GIT_REDIRECT_STORE_PREFIX, LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' @@ -2270,6 +2272,36 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) }) + it.if(isLinux)( + 'hands the monitor plain deny paths for a repository it cannot read', + () => { + // The violation monitor starts once for the session, so one + // repository whose metadata cannot be resolved must not take the whole + // start-up with it. Every wrap in it is still refused. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + const checkout = join(dir, 'wt-checkout') + const originalCwd = process.cwd() + process.chdir(checkout) + try { + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) + + const monitored = linuxGetMonitorCwdDenyPaths(false) + + expect(monitored).toContain(pointer) + expect(monitored).toContain(join(checkout, '.bashrc')) + } finally { + process.chdir(originalCwd) + } + }, + ) + it.if(!isWindows)( 'does not block on a FIFO left where a git directory keeps its commondir', () => { From 41aa1f977fae51b6aaa220407be9e78a016b1cdd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 08:41:13 +0000 Subject: [PATCH 020/141] linux: refuse the wrap when the mandatory-deny scan cannot deliver its paths The per-command ripgrep scan is where every nested repository below the working directory is found, so a scan that does not deliver one leaves that repository's hooks and config writable. Two arms of the catch still ran the command: - an error that is not a RipgrepError, which is the scan not running at all: the binary could not be spawned, or this process is out of descriptors. That is the missing dependency the start-up check refuses on, met later. An abort is the caller's own answer and travels as the AbortError it is. - a run that exited with an error status while naming no path under the working directory on stderr. An unreadable directory is denied whole in its place; nothing stands in for any other failure, so whatever the run did not reach stayed writable. Both refuse the wrap now, as deny_scan_failed on LinuxSandboxProfileError, which the timed-out case carries too instead of a bare Error. A failed scan is still sandboxed on where every line it printed names a path under the working directory that is then denied whole. --- src/sandbox/linux-sandbox-utils.ts | 105 ++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 26 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 56a084193..d83c1573e 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -434,27 +434,51 @@ function indexOfSegmentRun(segments: string[], parts: string[]): number { } /** - * The paths under `cwd` a failed ripgrep run named in its diagnostics — the - * directories it could not read. Denying them is how the scan fails closed: - * their contents are unknown, so a nested repository inside one must not stay - * writable. rg reports `: `; a path holding `: ` is cut short - * at it, which denies an ancestor and so only ever denies more. + * What a failed ripgrep run said about itself, in the two kinds of line it + * can hold: the paths under `cwd` it could not read, and the lines that name + * no such path. Denying the first is how the scan fails closed — their + * contents are unknown, so a nested repository inside one must not stay + * writable — while nothing here stands in for the second, which is why the + * caller refuses the wrap over one. rg reports `: `; a path + * holding `: ` is cut short at it, which denies an ancestor and so only ever + * denies more. */ -function unreadablePathsFromRipgrepStderr( +function ripgrepFailureDiagnostics( stderr: string, cwd: string, -): string[] { +): { unreadablePaths: string[]; linesNamingNoPath: string[] } { const prefix = cwd + path.sep - const paths = new Set() + const unreadablePaths = new Set() + const linesNamingNoPath: string[] = [] for (const line of stderr.split('\n')) { + if (line.trim() === '') continue const start = line.indexOf(prefix) - if (start === -1) continue - const rest = line.slice(start) + const rest = start === -1 ? '' : line.slice(start) const end = rest.indexOf(': ') const candidate = (end === -1 ? rest : rest.slice(0, end)).trimEnd() - if (candidate.length > prefix.length) paths.add(candidate) + if (candidate.length > prefix.length) unreadablePaths.add(candidate) + else linesNamingNoPath.push(line) } - return [...paths] + return { unreadablePaths: [...unreadablePaths], linesNamingNoPath } +} + +/** + * Refuse the wrap, naming what the scan did instead of delivering the deny + * paths below `cwd`. Sandboxing on a listing that stops somewhere unknown is + * how a nested repository keeps writable hooks, so the wrap is refused. + */ +function denyScanFailed( + cwd: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `The ripgrep scan of ${cwd} ${what} (${errorText(cause)}), so what it ` + + `would have denied below there is unknown: the command was not run ` + + `rather than sandboxed behind a deny list that may leave a nested ` + + `repository's hooks writable` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError('deny_scan_failed', message, cause) } /** @@ -569,9 +593,12 @@ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { * Runs on each command without memoization. `--max-depth` keeps that to * milliseconds on ordinary trees, but `--no-ignore` means gitignored data * within the depth is walked too: measured at about +100 ms per command on a - * tree with 150k ignored files three levels down. A scan that cannot finish - * inside {@link ripGrep}'s timeout aborts the wrap rather than sandboxing - * with a deny list of unknown completeness. + * tree with 150k ignored files three levels down. A scan that does not + * deliver what is below the working directory aborts the wrap rather than + * sandboxing with a deny list of unknown completeness: one that could not be + * run at all, one that does not finish inside {@link ripGrep}'s timeout, and + * one that fails for a reason no deny stands in for. The single failure that + * is not fatal is a directory it could not read, which is denied whole. */ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, @@ -639,23 +666,40 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig, ) } catch (error) { - if (error instanceof RipgrepError && error.timedOut) { + if (!(error instanceof RipgrepError)) { + // The run never got far enough to report anything of its own: the + // binary could not be spawned or this process is out of descriptors — + // the missing dependency the start-up check refuses on, met later — or + // the caller aborted, which is its own answer and travels as itself. + if ((error as { name?: unknown }).name === 'AbortError') throw error + throw denyScanFailed(cwd, 'could not be run', error) + } + if (error.timedOut) { // The command that runs next is the one that could have made the tree // slow to walk, so a truncated listing is not something to sandbox on: // an unreached nested repository would be one with writable hooks. - throw new Error( - `[Sandbox] ripgrep scan of ${cwd} did not finish; refusing to sandbox with mandatory denies of unknown completeness: ${error.message}`, + throw denyScanFailed(cwd, 'did not finish', error) + } + // An unreadable directory makes rg exit non-zero after listing the rest + // of the tree; those matches still count, and each directory it could not + // read is denied whole, since what it holds is unknown. That is the only + // failure a deny stands in for: where the run named something else, or + // nothing at all, whatever it did not reach would stay writable. + const { unreadablePaths, linesNamingNoPath } = ripgrepFailureDiagnostics( + error.stderr, + cwd, + ) + if (unreadablePaths.length === 0 || linesNamingNoPath.length > 0) { + throw denyScanFailed( + cwd, + 'failed for a reason no deny stands in for', + error, ) } - if (error instanceof RipgrepError) { - // An unreadable directory makes rg exit non-zero after listing the rest - // of the tree; those matches still count, and each directory it could - // not read is denied whole, since what it holds is unknown. - matches = error.partialMatches - denyPaths.push(...unreadablePathsFromRipgrepStderr(error.stderr, cwd)) - } + matches = error.partialMatches + denyPaths.push(...unreadablePaths) logForDebugging( - `[Sandbox] ripgrep scan failed, kept ${matches.length} partial matches; mandatory denies below cwd may be incomplete: ${error}`, + `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadablePaths.length} of the directories under it, which are denied whole; the ${matches.length} paths it did list still count: ${error}`, { level: 'warn' }, ) } @@ -1058,6 +1102,15 @@ export type LinuxSandboxProfileErrorCode = * Whatever the wrap did make is tracked and goes with the next cleanup. */ | 'deny_placeholder_unavailable' + /** + * The ripgrep scan the mandatory denies below the working directory come + * from did not deliver them: it could not be run, it was killed before it + * finished, or it failed for a reason that names no path under the working + * directory to deny in its place. Sandboxing on what it did list would + * leave whatever it never reached — a nested repository's hooks — writable, + * so the command is refused instead. + */ + | 'deny_scan_failed' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the From 5adab23a6c6ea7f9585afcfad2f051d9f1f2a360 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 08:41:13 +0000 Subject: [PATCH 021/141] Cover the mandatory-deny scan's failure paths, whatever the uid The scan fails for real on a directory the process cannot read, and the test box and CI run as root, where there is none: none of those paths were exercised. A fake rg does it anywhere - a partial listing with an unreadable directory named on stderr (the listed repository denied, the named directory denied whole, the wrap standing), a run that outlives a short timeout, one that exits with an error status naming no path, one that cannot be spawned, and an aborted signal that comes back as itself. test/utils/ripgrep.test.ts pins the carrying of a failed run's matches and diagnostics the same way. Where the uid allows it the real thing still does it under bubblewrap: one wrapped command takes a directory to mode 000 and the next is still refused the nested repository's hooks. README: the refusals a failed scan now makes, and where a missing ripgrep is caught instead. --- README.md | 2 +- test/sandbox/mandatory-deny-paths.test.ts | 166 +++++++++++++++++++++- test/utils/ripgrep.test.ts | 24 ++++ 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6737b5518..6cc5a4a68 100644 --- a/README.md +++ b/README.md @@ -737,7 +737,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and a scan that does not finish in time aborts the command rather than sandboxing it with a partial deny list (a scan that cannot run at all — no `ripgrep` — is still logged and not fatal). +**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and every other failure aborts the command rather than sandboxing it with a partial deny list — a scan that could not be run at all, one that does not finish in time, and one that fails for a reason naming no path under the working directory to deny in its place. Each of those is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 5cdb0061f..43b40e709 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -52,6 +52,7 @@ import { submoduleGitDirs, } from '../../src/sandbox/mandatory-deny-paths.js' import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' +import type { RipgrepConfig } from '../../src/utils/ripgrep.js' /** * Integration tests for mandatory deny paths. @@ -768,7 +769,10 @@ describe.if(isSupportedPlatform)( }, }).catch((e: unknown) => e) - expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) expect((error as Error).message).toMatch(/did not finish/) }, ) @@ -798,6 +802,166 @@ describe.if(isSupportedPlatform)( }, ) + /** + * What the scan does when it fails, driven by a fake rg: the run that + * fails for real needs a directory this process cannot read, and as + * root — which CI is — there is none, so none of this would be + * exercised there. The last arm is the real thing, where the uid allows. + */ + describe.if(isLinux)('when the scan fails', () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + + const wrapWith = ( + ripgrepConfig: RipgrepConfig, + abortSignal?: AbortSignal, + ): Promise => + wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig, + abortSignal, + }) + + /** A fake rg: `matches` NUL-terminated on stdout, `stderr`, exit 2. */ + const failingRipgrep = ( + matches: string[], + stderr: string, + ): RipgrepConfig => ({ + command: '/bin/sh', + args: [ + '-c', + `printf '%s\\0' ${matches.map(match => `'${match}'`).join(' ')}; ` + + `printf '%s\\n' '${stderr}' >&2; exit 2`, + ], + }) + + it('keeps what it listed and denies the directory it could not read', async () => { + const cwd = process.cwd() + const locked = join(cwd, 'locked') + const hooks = join(cwd, 'nested', '.git', 'hooks') + const config = join(cwd, 'nested', '.git', 'config') + mkdirSync(locked, { recursive: true }) + try { + const command = await wrapWith( + failingRipgrep( + [config], + `rg: ${locked}: Permission denied (os error 13)`, + ), + ) + + // The nested repository the partial listing named is denied as + // if the run had finished, and what it could not read is denied + // whole, since a nested repository inside it would be unseen. + expect(lastMountAt(command, hooks)).toBe( + `--ro-bind ${hooks} ${hooks}`, + ) + expect(lastMountAt(command, config)).toBe( + `--ro-bind ${config} ${config}`, + ) + expect(lastMountAt(command, locked)).toBe( + `--ro-bind ${locked} ${locked}`, + ) + } finally { + rmSync(locked, { recursive: true, force: true }) + } + }) + + it('refuses the wrap when the failure names no path to deny', async () => { + // Nothing here stands in for a failure of this shape, so what the + // run never reached would stay writable. + const error = await wrapWith( + failingRipgrep( + [join(process.cwd(), 'nested', '.git', 'config')], + 'rg: unrecognized option --frobnicate', + ), + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch( + /failed for a reason no deny stands in for/, + ) + expect((error as Error).message).toMatch(/was not run/) + }) + + it('refuses the wrap when the scan could not be run at all', async () => { + const error = await wrapWith({ + command: join(TEST_DIR, 'no-such-ripgrep'), + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/could not be run/) + }) + + it("lets the caller's own abort through as itself", async () => { + const controller = new AbortController() + controller.abort() + + const error = await wrapWith( + { command: '/bin/sh', args: ['-c', 'true'] }, + controller.signal, + ).catch((e: unknown) => e) + + expect((error as Error).name).toBe('AbortError') + expect(error).not.toBeInstanceOf(LinuxSandboxProfileError) + }) + + it.if(process.getuid?.() !== 0 && bwrapCanNamespace())( + 'denies a nested repository the real rg could not walk past', + async () => { + const cwd = process.cwd() + const blind = join(cwd, 'blind') + const hook = join(cwd, 'nested', '.git', 'hooks', 'pre-commit') + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + }) + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd, + }) + + mkdirSync(blind, { recursive: true }) + try { + // The first command leaves a directory the next command's scan + // cannot read: that scan fails, and the hooks of the nested + // repository beside it must be denied all the same. + const first = run(await wrap(`echo ${BOOTED} && chmod 000 blind`)) + expect(first.stdout).toContain(BOOTED) + expect(first.status).toBe(0) + cleanupBwrapMountPoints({ force: true }) + + const second = run( + await wrap(`echo ${BOOTED}; echo X > ${hook} || echo DENIED`), + ) + expect(second.stdout).toContain(BOOTED) + expect(second.stdout).toContain('DENIED') + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + } finally { + chmodSync(blind, 0o755) + rmSync(blind, { recursive: true, force: true }) + } + }, + 60000, + ) + }) + it('still lets a command create a .git file where none exists', async () => { mkdirSync('fresh-checkout', { recursive: true }) try { diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index ace45b949..142f0deac 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -81,6 +81,30 @@ describe('ripGrep', () => { ).rejects.toThrow(/ripgrep failed/) }) + it.if(!isWindows)( + 'carries what a failed run listed and what it said, whatever the uid', + async () => { + // What the caller denies comes off these two, and the run that + // produces them for real needs a directory this process cannot read — + // which as root there is none of. A fake rg fails the same way + // everywhere; the arm below does it with the real one where it can. + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: [ + '-c', + 'printf "/found/a\\0"; ' + + 'printf "rg: /found/locked: Permission denied (os error 13)\\n" >&2; ' + + 'exit 2', + ], + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).partialMatches).toEqual(['/found/a']) + expect((error as RipgrepError).stderr).toContain('/found/locked') + expect((error as RipgrepError).timedOut).toBe(false) + }, + ) + it.if(!isWindows)( 'drops a path a killed run was cut off in the middle of', async () => { From dc45013983062c6192f3308f358eaa314e95525c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 10:06:39 +0000 Subject: [PATCH 022/141] linux: read a failed scan's error codes, and find the unreadable directories here The per-command ripgrep scan is where every nested repository below the working directory is found, and a run that fails still lists most of the tree. Which of those runs could be carried on from was decided by parsing paths out of stderr: each line cut at the first ": ", the rest taken as a path under the working directory and denied whole. A directory's name is chosen by whoever created it and reaches stderr unescaped, so that parse was the name's to steer. A directory named "src: x" holding an unreadable "locked" gave "/src" - a directory that was scanned and must not be denied - while the unreadable one was never denied and the wrap went ahead: the fail-open this scan exists to close. One named ".: x" aimed it at the working directory itself; a trailing space was trimmed off; a newline in a name split one line into two, and the second named no path, which refused every command in the tree. No path comes out of stderr any more. The run is classified by the "(os error N)" tokens anywhere in it, which a name can only add to: - every token ENOENT: entries that went away while rg walked. There is nothing at those paths to deny, and mounting one would plant a file on the host where the command deleted one. The listing still counts. - any EACCES: the wrapper finds the unreadable directories itself, with a bounded readdir of the working directory to the scan's own depth, symlinks not followed and node_modules skipped as the scan's globs skip it, and denies each whole. The walk spends what the scan left of its time budget; a walk that finds none refuses, since the run failed over something this cannot see. - anything else, or nothing at all: refused, with the raw stderr in the message. Also: node_modules is excluded as a directory as well as by its contents, so an unreadable one no longer fails the run over a directory the scan never wanted; the scan runs with --no-config, since RIPGREP_CONFIG_PATH routinely points inside a project at a file a sandboxed command can write and one --max-filesize line in it empties the scan with no error; and a run killed by something other than the timeout is reported as the signal it was. RipgrepConfig.timeoutMs joins the schema the type already had it in. --- src/sandbox/linux-sandbox-utils.ts | 207 +++++++++++++++++++++++------ src/sandbox/sandbox-config.ts | 9 ++ src/utils/ripgrep.ts | 60 ++++++--- 3 files changed, 213 insertions(+), 63 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index d83c1573e..d2c2222c6 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -7,7 +7,11 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { endianness, tmpdir } from 'node:os' import path, { join } from 'node:path' -import { ripGrep, RipgrepError } from '../utils/ripgrep.js' +import { + ripGrep, + RipgrepError, + DEFAULT_RIPGREP_TIMEOUT_MS, +} from '../utils/ripgrep.js' import type { RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { @@ -433,33 +437,118 @@ function indexOfSegmentRun(segments: string[], parts: string[]): number { ) } +/** The errno a walk of an unreadable directory ends on. */ +const EACCES = 13 +/** The errno a walk of something that has gone away ends on. */ +const ENOENT = 2 + +/** + * Every OS error code a failed ripgrep run mentioned, from the `(os error N)` + * tokens rg appends to each diagnostic. NO PATH IS EVER TAKEN FROM THIS TEXT: + * a directory's name is chosen by whoever created it and lands in stderr + * unescaped, so any path read out of a line can be a name rather than the + * file rg failed on. A name can only ADD tokens, and an unrecognised token + * refuses the wrap, so the worst a chosen name does is refuse. + */ +function ripgrepFailureErrnos(stderr: string): Set { + const errnos = new Set() + for (const match of stderr.matchAll(/\(os error (\d+)\)/g)) { + errnos.add(Number(match[1])) + } + return errnos +} + +/** At most `limit` characters of `text`, with what was dropped noted. */ +function truncated(text: string, limit = 400): string { + const collapsed = text.trim() + return collapsed.length <= limit + ? collapsed + : `${collapsed.slice(0, limit)}… (${collapsed.length - limit} more characters)` +} + /** - * What a failed ripgrep run said about itself, in the two kinds of line it - * can hold: the paths under `cwd` it could not read, and the lines that name - * no such path. Denying the first is how the scan fails closed — their - * contents are unknown, so a nested repository inside one must not stay - * writable — while nothing here stands in for the second, which is why the - * caller refuses the wrap over one. rg reports `: `; a path - * holding `: ` is cut short at it, which denies an ancestor and so only ever - * denies more. + * The directories under `cwd` this process cannot read — cannot list at all, + * or holding an entry it cannot type — walked the way the scan walks: + * `readdir` with the kernel's own entry types, symlinks not followed, + * `node_modules` skipped as the scan's globs skip it, no deeper than the + * scan reaches. This is what stands in for parsing paths out of rg's stderr: + * the answer comes off the filesystem, so a directory NAME cannot add to it, + * aim it elsewhere, or empty it. `collectGitDirs` also gathers the `.git` + * directories the walk passes. + * + * Throws {@link LinuxSandboxProfileError} when `deadline` passes: the walk + * spends what is left of the scan's own time budget and no more. */ -function ripgrepFailureDiagnostics( - stderr: string, +function walkScanDirectories( cwd: string, -): { unreadablePaths: string[]; linesNamingNoPath: string[] } { - const prefix = cwd + path.sep - const unreadablePaths = new Set() - const linesNamingNoPath: string[] = [] - for (const line of stderr.split('\n')) { - if (line.trim() === '') continue - const start = line.indexOf(prefix) - const rest = start === -1 ? '' : line.slice(start) - const end = rest.indexOf(': ') - const candidate = (end === -1 ? rest : rest.slice(0, end)).trimEnd() - if (candidate.length > prefix.length) unreadablePaths.add(candidate) - else linesNamingNoPath.push(line) - } - return { unreadablePaths: [...unreadablePaths], linesNamingNoPath } + maxDepth: number, + deadline: number, + collectGitDirs = false, +): { unreadableDirs: string[]; gitDirs: string[] } { + const unreadableDirs: string[] = [] + const gitDirs: string[] = [] + + const isDirectory = ( + entry: fs.Dirent, + child: string, + ): boolean | undefined => { + if (entry.isDirectory()) return true + if ( + entry.isFile() || + entry.isSymbolicLink() || + entry.isFIFO() || + entry.isSocket() || + entry.isBlockDevice() || + entry.isCharacterDevice() + ) { + return false + } + // The filesystem returned no type with the entry (DT_UNKNOWN), so ask. + try { + return fs.lstatSync(child).isDirectory() + } catch (err) { + // Gone since the listing: nothing there to walk or to deny. + return isAbsenceErrno(err) ? false : undefined + } + } + + const walk = (dir: string, depth: number): void => { + if (Date.now() > deadline) { + throw denyScanFailed( + cwd, + 'left too little of its time budget to walk what it could not report on', + new Error(`the walk stopped at ${dir}`), + ) + } + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent means it went away mid-walk, which leaves nothing to deny. + if (!isAbsenceErrno(err)) unreadableDirs.push(dir) + return + } + for (const entry of entries) { + // Excluded from the scan's globs, so rg never reports one either. + if (entry.name === 'node_modules') continue + const child = path.join(dir, entry.name) + const directory = isDirectory(entry, child) + if (directory === undefined) { + unreadableDirs.push(dir) + return + } + if (!directory) continue + if (collectGitDirs && entry.name === '.git' && depth + 2 <= maxDepth) { + // The depth the scan itself reaches a repository at: it recognises + // one by a file directly inside .git, one level further down. + gitDirs.push(child) + } + if (depth + 1 < maxDepth) walk(child, depth + 1) + } + } + + walk(cwd, 0) + return { unreadableDirs, gitDirs } } /** @@ -597,8 +686,9 @@ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { * deliver what is below the working directory aborts the wrap rather than * sandboxing with a deny list of unknown completeness: one that could not be * run at all, one that does not finish inside {@link ripGrep}'s timeout, and - * one that fails for a reason no deny stands in for. The single failure that - * is not fatal is a directory it could not read, which is denied whole. + * one that failed for a reason no deny stands in for. The failures that are + * not fatal are told apart by the `(os error N)` codes on stderr and never + * by the paths printed beside them — see {@link ripgrepFailureErrnos}. */ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, @@ -607,6 +697,10 @@ async function linuxGetMandatoryDenyPaths( abortSignal?: AbortSignal, ): Promise { const cwd = process.cwd() + // The whole budget, scan and any walk of its own this has to make: a walk + // started because the scan failed spends what the scan left of it. + const deadline = + Date.now() + (ripgrepConfig.timeoutMs ?? DEFAULT_RIPGREP_TIMEOUT_MS) // Use provided signal or create a fallback controller const fallbackController = new AbortController() const signal = abortSignal ?? fallbackController.signal @@ -623,6 +717,8 @@ async function linuxGetMandatoryDenyPaths( seenGitDirs.add(gitDir) denyPaths.push(...gitDirTreeDenyPaths(gitDir, allowGitConfig)) } + const walkScan = (collectGitDirs: boolean) => + walkScanDirectories(cwd, maxDepth, deadline, collectGitDirs) // Build iglob args for all patterns in one ripgrep call const iglobArgs: string[] = [] @@ -658,6 +754,11 @@ async function linuxGetMandatoryDenyPaths( '--max-depth', String(maxDepth), ...iglobArgs, + // The directory itself as well as what is under it: excluding only + // the contents leaves rg trying to open an unreadable node_modules, + // which fails the whole run over a directory nothing here wants. + '-g', + '!**/node_modules', '-g', '!**/node_modules/**', ], @@ -680,28 +781,50 @@ async function linuxGetMandatoryDenyPaths( // an unreached nested repository would be one with writable hooks. throw denyScanFailed(cwd, 'did not finish', error) } - // An unreadable directory makes rg exit non-zero after listing the rest - // of the tree; those matches still count, and each directory it could not - // read is denied whole, since what it holds is unknown. That is the only - // failure a deny stands in for: where the run named something else, or - // nothing at all, whatever it did not reach would stay writable. - const { unreadablePaths, linesNamingNoPath } = ripgrepFailureDiagnostics( - error.stderr, - cwd, + // rg lists the rest of the tree and exits non-zero when an entry gets in + // its way, so the matches still count — but only where every code it + // reported is one a deny stands in for. Anything else, and anything that + // reported no code at all, leaves whatever the run did not reach unknown. + const errnos = ripgrepFailureErrnos(error.stderr) + const stoodInFor = [...errnos].every( + errno => errno === ENOENT || errno === EACCES, ) - if (unreadablePaths.length === 0 || linesNamingNoPath.length > 0) { + if (errnos.size === 0 || !stoodInFor) { throw denyScanFailed( cwd, - 'failed for a reason no deny stands in for', + `failed for a reason no deny stands in for: ${truncated(error.stderr) || '(nothing on stderr)'}`, error, ) } matches = error.partialMatches - denyPaths.push(...unreadablePaths) - logForDebugging( - `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadablePaths.length} of the directories under it, which are denied whole; the ${matches.length} paths it did list still count: ${error}`, - { level: 'warn' }, - ) + if (errnos.has(EACCES)) { + // A directory rg could not read holds an unknown tree, so it is denied + // whole. WHICH directories is settled by walking the filesystem, not by + // reading rg's stderr: a name in there is chosen by whoever made the + // directory. A walk finding none means the run failed over something + // this cannot see, so there is nothing to stand in for it. + const { unreadableDirs } = walkScan(false) + if (unreadableDirs.length === 0) { + throw denyScanFailed( + cwd, + `reported a directory it could not read, and none is there now: ${truncated(error.stderr)}`, + error, + ) + } + denyPaths.push(...unreadableDirs) + logForDebugging( + `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadableDirs.length} of the directories under it, which are denied whole; the ${matches.length} paths it did list still count: ${error}`, + { level: 'warn' }, + ) + } else { + // Every code was ENOENT: entries that went away while rg walked. There + // is nothing at those paths to deny, and mounting one would plant a + // file on the host where the command deleted one. + logForDebugging( + `[Sandbox] ripgrep scan of ${cwd} lost entries while it walked, which leaves nothing to deny; the ${matches.length} paths it did list still count: ${error}`, + { level: 'warn' }, + ) + } } const dirPatterns = dangerousDirectories.map(d => diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 5c45eb3c8..dea321ad8 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -952,6 +952,15 @@ export const RipgrepConfigSchema = z.object({ .describe( 'Override argv[0] when spawning (for multicall binaries that dispatch on argv[0])', ), + timeoutMs: z + .number() + .int() + .positive() + .optional() + .describe( + 'How long one ripgrep run may take before it is killed, in milliseconds ' + + '(default: DEFAULT_RIPGREP_TIMEOUT_MS, 10000)', + ), }) /** diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index dd9ed71a1..c2bb635fd 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -7,11 +7,14 @@ export interface RipgrepConfig { args?: string[] /** Override argv[0] when spawning (for multicall binaries that dispatch on argv[0]) */ argv0?: string - /** How long the run may take before it is killed (default: 10 s). */ + /** + * How long the run may take before it is killed (default: + * {@link DEFAULT_RIPGREP_TIMEOUT_MS}). + */ timeoutMs?: number } -const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 +export const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 /** * Check if ripgrep (rg) is available synchronously @@ -25,8 +28,10 @@ export function hasRipgrepSync(): boolean { * ripgrep exited with an error status. `partialMatches` is what it listed * before that: rg reports an unreadable directory with exit code 2 after * printing every match it could reach, and names each one in `stderr`. - * `timedOut` says the run was killed instead of finishing, so what it listed - * is a prefix of an unknown whole rather than everything it could reach. + * `timedOut` says the run lasted as long as it was given and was killed for + * it, so what it listed is a prefix of an unknown whole rather than + * everything it could reach. A run killed by anything else says so in its + * message and is not a timeout. */ export class RipgrepError extends Error { readonly partialMatches: string[] @@ -74,12 +79,21 @@ export async function ripGrep( timeoutMs = DEFAULT_RIPGREP_TIMEOUT_MS, } = config - const child = spawn(command, [...commandArgs, '--null', ...args, target], { - argv0, - signal: abortSignal, - timeout: timeoutMs, - windowsHide: true, - }) + const startedAt = Date.now() + const child = spawn( + // --no-config: RIPGREP_CONFIG_PATH routinely points inside a project, at + // a file a sandboxed command can write, and one line of it + // (--max-filesize=1) is enough to make a scan return nothing at all and + // no error with it. + command, + [...commandArgs, '--no-config', '--null', ...args, target], + { + argv0, + signal: abortSignal, + timeout: timeoutMs, + windowsHide: true, + }, + ) const [stdout, stderr, exit] = await Promise.all([ text(child.stdout), @@ -100,17 +114,21 @@ export async function ripGrep( // Exit code 1 means "no matches found" - this is normal return [] } - // A null exit code means the child was killed rather than exiting. An - // abort rejects through the error handler above, so here it is the timeout. - const timedOut = exit.code === null - throw new RipgrepError( - timedOut - ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${timeoutMs} ms: ${stderr}` - : `ripgrep failed with exit code ${exit.code}: ${stderr}`, - matches, - stderr, - timedOut, - ) + // A null exit code means the child was killed rather than exiting. The + // timeout is one reason for that and not the only one — a Ctrl-C to the + // process group, an OOM kill — so it is the one this claims only where the + // run actually lasted as long as it was given. The rest are reported as + // what they were; upstream refuses either way, with an accurate message. + const killed = exit.code === null + const elapsedMs = Date.now() - startedAt + const timedOut = killed && elapsedMs >= timeoutMs + const killedBy = exit.signal ?? 'a signal' + const failure = timedOut + ? `ripgrep was killed by ${killedBy} after ${elapsedMs} ms, the ${timeoutMs} ms it was given` + : killed + ? `ripgrep was killed by ${killedBy} after ${elapsedMs} ms, inside the ${timeoutMs} ms it was given` + : `ripgrep failed with exit code ${exit.code}` + throw new RipgrepError(`${failure}: ${stderr}`, matches, stderr, timedOut) } /** NUL-terminated records, dropping an unterminated (truncated) last one. */ From a407e9ece878042cbb1c4cb7e3699d98189b253f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 10:06:39 +0000 Subject: [PATCH 023/141] Refuse with a typed error where git's metadata cannot be followed, and deny where a deny will do GitMetadataError escaped the wrap raw: no name, no code, not exported. It is raised by following a .git pointer or a commondir, and creating a new .git pointer inside an allowed write path is deliberately allowed, so one command could leave every later wrap in the tree failing with an untyped error - the command that would delete the file among them. It now carries a name and a code and is exported from the package root, which is the macOS profile generator's own refusal type; on Linux the wrap turns it into a LinuxSandboxProfileError carrying deny_git_metadata_unreadable, and both name the file, since lifting the refusal takes a command run outside the sandbox. A read error on a followed target's commondir - the file is there and cannot be read - propagated to the outer catch, which logged it and returned the deny paths gathered so far: the main repository whose hooks a linked worktree's commits run was then never denied at all. That refuses now. A target the walk classified as unreadable is skipped instead of read: it is already denied whole, and git, running as the same user, cannot read through it either. A directory named by a pointer or a commondir that is not a git directory no longer has its commondir followed either, which is the same rule its own deny paths already obeyed. Nested-repository detection needs a regular file directly inside .git. With allowGitConfig false the config deny keeps one there and it cannot be removed from inside the sandbox; with allowGitConfig true a command could delete them all and hide the repository from the next command's scan, so that case walks the working directory as well and denies the .git directories it finds. Smaller, in the same area: a stat of cwd/.git that fails for anything but absence denies it whole rather than skipping the enumeration, on both backends; the violation monitor's fallback tells an absent .git from a directory, so it no longer hands out four denies the wrapper never makes; gitDirTreeDenyPaths moves beside the producers both backends call; the 40-hop symlink bound is one constant; and the GitMetadata reads are exhaustive switches rather than a ternary with an undefined arm. --- src/index.ts | 5 + src/sandbox/linux-sandbox-utils.ts | 123 ++++++++++++++++-------- src/sandbox/macos-sandbox-utils.ts | 38 ++++---- src/sandbox/mandatory-deny-paths.ts | 142 ++++++++++++++++++++-------- src/sandbox/sandbox-utils.ts | 7 ++ 5 files changed, 223 insertions(+), 92 deletions(-) diff --git a/src/index.ts b/src/index.ts index d0a06954e..a28c48e5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,11 @@ export { LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, } from './sandbox/linux-sandbox-utils.js' +// The macOS profile generator's own wrap-time refusal: a `.git` pointer or a +// `commondir` whose target cannot be worked out the way git works it out. On +// Linux the same condition arrives as a LinuxSandboxProfileError carrying +// `deny_git_metadata_unreadable`. +export { GitMetadataError } from './sandbox/mandatory-deny-paths.js' // Windows install/status API export { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index d2c2222c6..cdc72d6a1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -27,12 +27,14 @@ import { isAtOrUnder, isStrictlyUnder, getDangerousDirectories, + MAX_SYMLINK_RESOLUTION_DEPTH, } from './sandbox-utils.js' import { + GitMetadataError, gitDirDenyPaths, + gitDirTreeDenyPaths, gitFileDenyPaths, gitRedirectPlaceholder, - submoduleGitDirs, } from './mandatory-deny-paths.js' import type { FsReadRestrictionConfig, @@ -155,9 +157,6 @@ function findSymlinkInPath( return null } -/** Bounded depth for chasing dangling symlink chains (kernel ELOOP limit). */ -const MAX_SYMLINK_RESOLUTION_DEPTH = 40 - /** * Canonicalize a deny path through symlinks before any mask or bind is * computed (resolve-before-mask). Deny paths regularly contain symlinked @@ -570,25 +569,6 @@ function denyScanFailed( return new LinuxSandboxProfileError('deny_scan_failed', message, cause) } -/** - * Every git directory a deny must cover once `gitDir` is one: its own - * hooks/ and config, and the same for each submodule git directory under - * its `modules` (what a commit inside that submodule runs), plus whatever - * the walk could not see through. - */ -function gitDirTreeDenyPaths( - gitDir: string, - allowGitConfig: boolean, -): string[] { - const modules = submoduleGitDirs(path.join(gitDir, 'modules')) - return [ - ...modules.unreadableDirs, - ...[gitDir, ...modules.gitDirs].flatMap(dir => - gitDirDenyPaths(dir, allowGitConfig), - ), - ] -} - /** * The part of the mandatory deny set that follows from the cwd alone: the * dangerous files and directories resolved against it, and what its own @@ -610,8 +590,12 @@ export function linuxGetCwdMandatoryDenyPaths( let dotGitStat: fs.Stats | undefined try { dotGitStat = fs.statSync(dotGitPath) - } catch { - // No .git: nothing is denied, since a mount at .git would block `git init`. + } catch (err) { + // Absent is the ordinary case, and nothing is denied then: a mount at + // .git would block `git init`. Anything else means a .git is there and + // could not be looked at, which is no reason to skip the enumeration — + // deny it whole instead. + if (!isAbsenceErrno(err)) return [...denyPaths, dotGitPath] } if (dotGitStat?.isDirectory()) { denyPaths.push(...gitDirTreeDenyPaths(dotGitPath, allowGitConfig)) @@ -657,24 +641,41 @@ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { `[Sandbox Linux] Could not resolve ${dotGitPath} the way git does (${errorText(err)}); the violation monitor judges writes against ${cwd}'s plain deny paths instead. Every wrapped command in it is refused until that is fixed.`, { level: 'warn' }, ) - let isPointerFile = false - try { - isPointerFile = fs.statSync(dotGitPath).isFile() - } catch { - // Gone since, or unreachable: neither shape's denies apply. - } return [ ...cwdDangerousDenyPaths(cwd), - // A pointer file is itself a deny, and what it leads to is exactly - // what could not be followed; a git directory's own hooks and config - // need nothing followed to name. - ...(isPointerFile - ? [dotGitPath] - : gitDirDenyPaths(dotGitPath, allowGitConfig)), + ...monitorDotGitDenyPaths(cwd, allowGitConfig), ] } } +/** + * What the monitor treats as denied under `cwd`'s own `.git`, without + * following anything: the same three answers the wrap gives for the shape + * `.git` turns out to be. Absent is none of them — the wrap denies nothing + * there either, since a mount at `.git` would block `git init`. + */ +function monitorDotGitDenyPaths( + cwd: string, + allowGitConfig: boolean, +): string[] { + const dotGitPath = path.resolve(cwd, '.git') + let dotGitStat: fs.Stats | undefined + try { + dotGitStat = fs.statSync(dotGitPath) + } catch (err) { + // There and not inspectable: denied whole, as the wrap denies it. + return isAbsenceErrno(err) ? [] : [dotGitPath] + } + // A pointer file is itself a deny, and what it leads to is exactly what + // could not be followed; a git directory's own hooks and config need + // nothing followed to name. + if (dotGitStat.isFile()) return [dotGitPath] + if (dotGitStat.isDirectory()) { + return gitDirDenyPaths(dotGitPath, allowGitConfig) + } + return [] +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. @@ -706,7 +707,9 @@ async function linuxGetMandatoryDenyPaths( const signal = abortSignal ?? fallbackController.signal const dangerousDirectories = getDangerousDirectories() - const denyPaths = linuxGetCwdMandatoryDenyPaths(allowGitConfig) + const denyPaths = asProfileRefusal(() => + linuxGetCwdMandatoryDenyPaths(allowGitConfig), + ) // Each nested repository the scan finds, once: the same walk the cwd's own // git directory already had above, which every file listed under it leads @@ -853,13 +856,45 @@ async function linuxGetMandatoryDenyPaths( denyGitDir(path.join(cwd, ...relative.slice(0, gitAt + 1))) } else if (relative.length > 1) { // cwd's own pointer file is handled above, before the scan. - denyPaths.push(...gitFileDenyPaths(match, allowGitConfig)) + denyPaths.push( + ...asProfileRefusal(() => gitFileDenyPaths(match, allowGitConfig)), + ) } } + if (allowGitConfig) { + // The scan recognises a repository by a regular file directly inside its + // .git. With the config deny in place one is always there and cannot be + // removed from inside the sandbox; with config writes allowed a command + // can empty the directory of regular files and hide the repository from + // the next command's scan, so the directories are found here instead. + for (const gitDir of walkScan(true).gitDirs) denyGitDir(gitDir) + } + return [...new Set(denyPaths)] } +/** + * Run `produce`, turning the refusal a git metadata file can raise into the + * wrap's own typed one. A `.git` pointer or a `commondir` whose target cannot + * be worked out the way git works it out refuses the command, and a + * sandboxed command can write one, so the refusal has to reach the caller as + * something it can branch on — and name the file, since lifting it takes a + * command run outside the sandbox. + */ +function asProfileRefusal(produce: () => T): T { + try { + return produce() + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + logForDebugging(`[Sandbox Linux] ${err.message}`, { level: 'warn' }) + throw new LinuxSandboxProfileError( + 'deny_git_metadata_unreadable', + err.message, + err, + ) + } +} // Track mount points created by bwrap for non-existent deny paths. // When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty // file on the host as a mount point. These persist after bwrap exits and must @@ -1234,6 +1269,16 @@ export type LinuxSandboxProfileErrorCode = * so the command is refused instead. */ | 'deny_scan_failed' + /** + * A `.git` pointer file or a `commondir` under the working directory names + * a git directory that cannot be worked out the way git works it out: its + * bytes are not valid UTF-8, it is larger than git reads, or it is there + * and could not be read. The hooks and config git runs through it are + * therefore unknown, so the command is refused rather than sandboxed + * without them. A sandboxed command can write such a file, and the + * message names it: the refusal is lifted only from outside the sandbox. + */ + | 'deny_git_metadata_unreadable' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 28d2a7c10..4d32dd01f 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -18,11 +18,12 @@ import { isStrictlyUnder as isPathStrictlyUnder, DANGEROUS_FILES, getDangerousDirectories, + isAbsenceErrno, } from './sandbox-utils.js' import { gitDirDenyPaths, + gitDirTreeDenyPaths, gitFileDenyPaths, - submoduleGitDirs, } from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' @@ -146,28 +147,33 @@ export function macGetMandatoryDenyEntries( // binds, and every string in them was read off the filesystem, so each // becomes a literal entry here. const dotGit = path.resolve(cwd, '.git') - entries.push( - ...gitDirDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), - ) let dotGitStat: fs.Stats | undefined try { dotGitStat = fs.statSync(dotGit) - } catch { - // no .git here + } catch (err) { + // Absent is the ordinary case. Anything else means a .git is there and + // could not be looked at, which is no reason to skip the enumeration: a + // literal entry is a subpath deny, so this denies it whole, as the Linux + // backend's bind of the same path does. + if (!isAbsenceErrno(err)) return [...entries, toLiteralPathEntry(dotGit)] } - if (dotGitStat?.isFile()) { - // cwd checked out as a linked worktree or submodule: .git is a pointer - // file. Nested pointer files are matched by vnode type instead - // (gitPointerFilter), which cannot follow them. + if (dotGitStat?.isDirectory()) { + entries.push( + ...gitDirTreeDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ) + } else { + // Absent, or a pointer file: the repository's own hooks and config are + // denied either way, so neither can be created under a .git that is not + // there yet. entries.push( - ...gitFileDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ...gitDirDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), ) - } else if (dotGitStat?.isDirectory()) { - const modules = submoduleGitDirs(path.join(dotGit, 'modules')) - entries.push(...modules.unreadableDirs.map(toLiteralPathEntry)) - for (const gitDir of modules.gitDirs) { + if (dotGitStat?.isFile()) { + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFilter), which cannot follow them. entries.push( - ...gitDirDenyPaths(gitDir, allowGitConfig).map(toLiteralPathEntry), + ...gitFileDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), ) } } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 6e8f0787f..2bb42bf04 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -1,11 +1,22 @@ import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' +import { + isAbsenceErrno, + MAX_SYMLINK_RESOLUTION_DEPTH, +} from './sandbox-utils.js' -/** The path is absent, as opposed to unreadable or otherwise unverifiable. */ +/** + * The path is absent, as opposed to unreadable or otherwise unverifiable. + * Narrower than the shared {@link isAbsenceErrno} by two codes: ELOOP must + * read as unreadable here, so a symlink loop is denied whole rather than + * passed over as nothing, and ENAMETOOLONG has its own answer in + * {@link isUnusablePathError}. + */ function isAbsenceError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code - return code === 'ENOENT' || code === 'ENOTDIR' + if (code === 'ELOOP' || code === 'ENAMETOOLONG') return false + return isAbsenceErrno(err) } /** @@ -25,12 +36,6 @@ function isUnusablePathError(err: unknown): boolean { */ const MAX_GIT_METADATA_BYTES = 1024 * 1024 -/** - * Symlink hops allowed while resolving one path, the limit Linux itself - * applies before it gives up with ELOOP. - */ -const MAX_SYMLINK_HOPS = 40 - /** * Depth bound for the `.git/modules` walk. A submodule's name is its path * (`vendor/lib`) and submodules nest, so the walk descends both name segments @@ -64,8 +69,21 @@ type GitMetadata = * {@link gitFileDenyPaths}: sandboxing with a deny list that misses the hooks * and config git reads is worse than not sandboxing, which is the same call * the Linux backend makes for a scan that does not finish in time. + * + * The message names the file, because a sandboxed command can write one and + * the refusal is then lifted only by a command run outside the sandbox. On + * Linux the wrap turns this into a `LinuxSandboxProfileError` carrying + * `deny_git_metadata_unreadable`; on macOS it is the profile generator's own + * refusal and reaches the caller as itself. Branch on `.code`, not on the + * message. */ -export class GitMetadataError extends Error {} +export class GitMetadataError extends Error { + readonly code = 'git_metadata_unreadable' as const + constructor(message: string) { + super(message) + this.name = 'GitMetadataError' + } +} /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { @@ -182,47 +200,77 @@ export function gitFileDenyPaths( const denyPaths = [gitFile] try { const pointer = readGitMetadataFile(gitFile) - if (pointer.kind === 'too-large') { - // git refuses a .git file this large outright, so it leads nowhere. - logForDebugging( - `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, - { level: 'warn' }, - ) - return denyPaths + let target: string | undefined + switch (pointer.kind) { + case 'contents': + target = parseGitdirPointer(pointer.bytes, gitFile) + break + case 'too-large': + // git refuses a .git file this large outright, so it leads nowhere. + logForDebugging( + `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, + { level: 'warn' }, + ) + return denyPaths + case 'none': + break } - const target = - pointer.kind === 'contents' - ? parseGitdirPointer(pointer.bytes, gitFile) - : undefined if (target === undefined) return denyPaths - const gitDirs = gitMetadataTargets(path.dirname(gitFile), target) - for (const gitDir of gitDirs) { - denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) + const gitDirs = gitMetadataTargets(path.dirname(gitFile), target).map( + gitDir => ({ gitDir, kind: gitDirKind(gitDir) }), + ) + for (const { gitDir, kind } of gitDirs) { + denyPaths.push( + ...gitDirTargetDenyPaths(gitDir, kind, allowGitConfig, gitFile), + ) } // A linked worktree's git directory holds the path of the main one, whose // hooks and config its commits run. git reads it out of the directory it // opened, so each candidate above has its own. - for (const gitDir of gitDirs) { + for (const { gitDir, kind } of gitDirs) { + // Only a git directory has a commondir git reads; a directory this + // process could not list is already denied whole, and git, running as + // the same user, cannot read through it either. + if (kind !== 'git-dir') continue const commonFile = path.join(gitDir, 'commondir') - const common = readGitMetadataFile(commonFile) - if (common.kind === 'too-large') { - // git reads commondir whole, with no size limit of its own, so a - // file past this bound still names the directory whose hooks git - // runs. + let common: GitMetadata + try { + common = readGitMetadataFile(commonFile) + } catch (err) { + if (isAbsenceError(err)) continue + // The file is there and could not be read, so the directory whose + // hooks this worktree's commits run is unknown. Returning the denies + // gathered so far would leave that repository's hooks writable. throw new GitMetadataError( - `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + `[Sandbox] ${commonFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, ) } - const commonTarget = - common.kind === 'contents' - ? gitMetadataPath(common.bytes, commonFile) - : undefined + let commonTarget: string | undefined + switch (common.kind) { + case 'contents': + commonTarget = gitMetadataPath(common.bytes, commonFile) + break + case 'too-large': + // git reads commondir whole, with no size limit of its own, so a + // file past this bound still names the directory whose hooks git + // runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + case 'none': + break + } if (commonTarget === undefined) continue for (const commonDir of gitMetadataTargets(gitDir, commonTarget)) { if (commonDir === gitDir) continue denyPaths.push( - ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), + ...gitDirTargetDenyPaths( + commonDir, + gitDirKind(commonDir), + allowGitConfig, + commonFile, + ), ) } } @@ -242,6 +290,26 @@ export function gitFileDenyPaths( return denyPaths } +/** + * Every git directory a deny must cover once `gitDir` is one: its own hooks/ + * and config, and the same for each submodule git directory under its + * `modules` (what a commit inside that submodule runs), plus whatever the + * walk could not see through. Both backends produce their entries from this + * one list. + */ +export function gitDirTreeDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + const modules = submoduleGitDirs(path.join(gitDir, 'modules')) + return [ + ...modules.unreadableDirs, + ...[gitDir, ...modules.gitDirs].flatMap(dir => + gitDirDenyPaths(dir, allowGitConfig), + ), + ] +} + /** * Git directories of the submodules under `modulesDir` (a repository's * .git/modules), nested submodules included. A submodule's name is its path, @@ -349,10 +417,10 @@ function isDirectory( */ function gitDirTargetDenyPaths( target: string, + kind: GitDirKind, allowGitConfig: boolean, source: string, ): string[] { - const kind = gitDirKind(target) switch (kind) { case 'git-dir': case 'absent': @@ -560,7 +628,7 @@ function physicalPath(base: string, target: string): string { // the path folded past it would name a directory this cannot vouch for, // while the link itself reads as unreadable and is denied whole. hops += 1 - if (hops > MAX_SYMLINK_HOPS) return next + if (hops > MAX_SYMLINK_RESOLUTION_DEPTH) return next // A link's own target is walked in its place, from the directory holding // it unless it is absolute. if (path.isAbsolute(link)) current = path.parse(link).root diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 20586576d..1305222ca 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -92,6 +92,13 @@ export function pathSpellings(candidatePath: string): string[] { return [candidatePath] } +/** + * Symlink hops allowed while resolving one path: the limit Linux itself + * applies before it gives up with ELOOP, so a chain this library walks by + * hand stops where the kernel's own walk would. + */ +export const MAX_SYMLINK_RESOLUTION_DEPTH = 40 + /** 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 From 7c06b58b5c4af69eadc916911012f8e1fd7eb887 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 10:06:39 +0000 Subject: [PATCH 024/141] linux: the files a git redirect deny leaves on the host Denying a commondir or a config.worktree that is not there means writing a mount point for it, on the host, where every git command outside the sandbox reads it. Five things about that file were wrong. - It was written 0644, and the only recogniser of one left behind by a killed process requires no write bits, so an empty config.worktree left that way was never claimed and never removed. Written 0444 now, which is the mode bubblewrap gives its own. - A git directory this process cannot write - a root-owned vendored checkout, a read-only mount, one the command itself made unwritable - made the exclusive create fail and refused every command in the project. The git directory is bound read-only whole instead: it needs nothing written to the host, costs what was already unwritable, and closes the chmod route a command could otherwise take to create the file itself. deny_placeholder_unavailable is left for a host that genuinely cannot take the file. - The empty-file repair arm had no shape check, so a zero-byte commondir a host git was between creating and writing was chmod'd, overwritten and later unlinked. It is left exactly as found now, and only covered for the length of the command. - A mount point already holding the placeholder was bound from itself. Another process wrapping a command in the same repository claims the same file, and its cleanup unlinks it: the bind then has no source, or loses it mid-run. The store's copy is bound instead. - A wrap that threw after the files were written left them on the host with no sandbox running, because the catch only decremented the counter. It calls the cleanup, which decrements and, with nothing else running, takes the files away. The store write, which unlinks and rewrites on every call, is made once per placeholder per wrap; the first try around the exclusive create no longer covers it, so its own typed refusal is not re-wrapped or logged twice; and the two parallel tables of mount points are one map from a mount point to the bytes this wrapper wrote there. --- src/sandbox/linux-sandbox-utils.ts | 257 ++++++++++++++++++----------- 1 file changed, 157 insertions(+), 100 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index cdc72d6a1..00e7db3c1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -272,51 +272,76 @@ function fileContentsWithin(file: string, limit: number): string | undefined { } } +/** What the deny of a file git reads back binds, once `dest` is ready. */ +type GitRedirectBind = + /** Bind this over `dest`, the ordinary answer. */ + | { bind: string } + /** Nothing can be put at `dest`: bind this git directory read-only. */ + | { denyGitDirWhole: string } + /** * Make `dest` ready for the read-only bind that denies it, where it is one - * of the files git reads back (`gitRedirectPlaceholder` in - * src/sandbox/mandatory-deny-paths.ts), and return what to bind there. + * of the files git reads back (`GIT_REDIRECT_FILES` in + * src/sandbox/mandatory-deny-paths.ts says which and why), and say what to + * bind. Called at emission time, because it writes on the HOST: a bind the + * emission drops must not have cost anything. * - * bubblewrap makes the mount point for an absent destination itself, with - * ensure_file(): an empty file — and that file is on the HOST, where it is - * what every git command outside the sandbox reads for as long as this one - * runs, and for good if this process is killed. git refuses to run at all in - * a repository whose commondir it cannot read, so the mount point is made - * here instead, holding the placeholder, and tracked for the cleanup. An - * empty file already there is rewritten to the placeholder and tracked the - * same way: no bytes at all is what git refuses, so nothing puts that there - * on purpose. Where the placeholder is itself empty an empty file IS - * legitimate, and only one of the shape bubblewrap leaves - * ({@link isStaleBwrapMountPoint}) is taken for this wrapper's own. + * Three arms. Absent: the mount point is written here rather than left to + * bubblewrap, holding the placeholder — a mount point bubblewrap makes is + * empty, and git refuses to run at all against a `commondir` it cannot read + * — with bubblewrap's own read-only mode, so that a wrap killed before its + * cleanup leaves a file the next wrap can recognise. There, empty and in + * that shape: this wrapper's own to repair and remove. Anything else: the + * caller's file, and only the bind lands on it. * - * What is bound over it is the store's copy rather than the file, so what - * the sandboxed command reads there is not what a host process rewrites - * between this call and the mount. A destination already holding the - * placeholder, or a redirect git can read, is bound from itself instead. + * What is bound is the store's copy, never the mount point itself: a host + * process rewriting the file between this call and the mount would otherwise + * choose what the sandbox reads, and another process's cleanup could unlink + * the source out from under a live bind. * - * Throws {@link LinuxSandboxProfileError} when neither can be prepared: what - * is left is /dev/null, which costs the repository every git command inside - * the sandbox and, through the mount point, outside it as well, so the - * command is refused rather than run behind that. + * Throws {@link LinuxSandboxProfileError} when the placeholder cannot be + * prepared at all: what is left is /dev/null, which costs the repository + * every git command inside the sandbox and, through the mount point, outside + * it as well, so the command is refused rather than run behind that. */ -function gitRedirectMountPoint(dest: string, placeholder: string): string { +function gitRedirectMountPoint( + dest: string, + placeholder: string, + storeFiles: Map, +): GitRedirectBind { const takeOwnership = (): void => { - redirectMountPointBytes.set(dest, placeholder) - bwrapMountPoints.add(dest) + bwrapMountPoints.set(dest, placeholder) registerExitCleanupHandler() } + const bindFromStore = (): GitRedirectBind => ({ + bind: gitRedirectStoreFile(dest, placeholder, storeFiles), + }) + let written = false try { // Exclusive: a file that appeared since the deny loop looked is never - // truncated, it falls to the existing-file arms below. - fs.writeFileSync(dest, placeholder, { flag: 'wx', mode: 0o644 }) - takeOwnership() - logForDebugging( - `[Sandbox Linux] Wrote the mount point ${dest} holding ${JSON.stringify(placeholder)}, so git reads no redirect there while the command runs`, - ) - return gitRedirectStoreFile(dest, placeholder) + // truncated, it falls to the existing-file arms below. Read-only, the + // mode bubblewrap's ensure_file() leaves, so that a process killed + // before the cleanup leaves a file the next wrap can recognise as one of + // these rather than as a file somebody meant to be there. + fs.writeFileSync(dest, placeholder, { flag: 'wx', mode: 0o444 }) + written = true } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') { + // A git directory this process cannot write — a vendored checkout + // owned by another user, a read-only mount, one the command itself + // made unwritable. Refusing every command in the tree over that would + // be a brick a sandboxed command can plant; a read-only bind of the + // directory denies the file and every other way into it at once. + const gitDir = path.dirname(dest) + logForDebugging( + `[Sandbox Linux] ${gitDir} takes no mount point for ${path.basename(dest)} (${errorText(err)}); denying the git directory whole instead, which needs nothing written to it`, + { level: 'warn' }, + ) + return { denyGitDirWhole: gitDir } + } + if (code !== 'EEXIST') { throw placeholderUnavailable( dest, 'its mount point could not be made', @@ -324,9 +349,25 @@ function gitRedirectMountPoint(dest: string, placeholder: string): string { ) } } + if (written) { + takeOwnership() + logForDebugging( + `[Sandbox Linux] Wrote the mount point ${dest} holding ${JSON.stringify(placeholder)}, so git reads no redirect there while the command runs`, + ) + return bindFromStore() + } const contents = fileContentsWithin(dest, Buffer.byteLength(placeholder)) if (contents === '' && placeholder !== '') { + if (!isStaleBwrapMountPoint(dest)) { + // Empty and not the shape bubblewrap leaves: a host git caught between + // creating the file and writing it owns this one. Cover it with the + // placeholder for the length of the command and leave the file alone. + logForDebugging( + `[Sandbox Linux] Binding ${JSON.stringify(placeholder)} over the empty ${dest}: it is not the shape bubblewrap leaves, so it is not this wrapper's to rewrite`, + ) + return bindFromStore() + } try { // bubblewrap makes its mount points read-only (ensure_file(dest, // 0444)), and the process that owns one need not be root, so the mode @@ -349,7 +390,7 @@ function gitRedirectMountPoint(dest: string, placeholder: string): string { logForDebugging( `[Sandbox Linux] Rewrote ${dest}, left empty by a sandbox that did not clean up, to ${JSON.stringify(placeholder)}: git reads no bytes there as a fault, not as "no redirect"`, ) - return gitRedirectStoreFile(dest, placeholder) + return bindFromStore() } if ( contents === placeholder && @@ -357,26 +398,40 @@ function gitRedirectMountPoint(dest: string, placeholder: string): string { ) { // This wrapper's own, left by a process that could not clean up: nothing // else writes exactly these bytes there, and for an empty placeholder - // nothing else leaves a file of that shape. Bound from itself — the - // swap would put back what is already in it — and removed with the rest. + // nothing else leaves a file of that shape. Removed with the rest, and + // bound from the store rather than from itself: another process wrapping + // a command in the same repository can claim and remove this same file, + // and a bind whose source is unlinked while the sandbox runs is a bind + // that no longer denies anything. takeOwnership() logForDebugging( - `[Sandbox Linux] ${dest} already holds the placeholder ${JSON.stringify(placeholder)} an earlier sandbox left; binding it from itself and taking it away with this wrap's mount points`, + `[Sandbox Linux] ${dest} already holds the placeholder ${JSON.stringify(placeholder)} an earlier sandbox left; binding the store's copy over it and taking it away with this wrap's mount points`, ) - return dest + return bindFromStore() } logForDebugging( `[Sandbox Linux] Binding ${dest} from itself: what it holds is not the ${JSON.stringify(placeholder)} placeholder, so it is not this wrapper's to replace`, ) - return dest + return { bind: dest } } -/** The store's copy of `placeholder`, made on first use of that content. */ -function gitRedirectStoreFile(dest: string, placeholder: string): string { +/** + * The store's copy of `placeholder`, made once per wrap: the store unlinks + * and rewrites the file on every call, which is what revalidates it, and one + * wrap needs one such check however many destinations share the content. + */ +function gitRedirectStoreFile( + dest: string, + placeholder: string, + storeFiles: Map, +): string { + const made = storeFiles.get(placeholder) + if (made !== undefined) return made try { - // Keyed by content, so one file serves every destination that needs it; - // the store rewrites it on each use, which is also what revalidates it. - return gitRedirectStore.write(placeholder, placeholder) + // Keyed by content, so one file serves every destination that needs it. + const file = gitRedirectStore.write(placeholder, placeholder) + storeFiles.set(placeholder, file) + return file } catch (err) { throw placeholderUnavailable( dest, @@ -895,17 +950,15 @@ function asProfileRefusal(produce: () => T): T { ) } } -// Track mount points created by bwrap for non-existent deny paths. -// When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty -// file on the host as a mount point. These persist after bwrap exits and must -// be cleaned up explicitly. -const bwrapMountPoints: Set = new Set() - -// What this wrapper wrote at a mount point of its own making, keyed by a -// path in bwrapMountPoints: the cleanup takes such a file away only while it -// still holds exactly those bytes. Only the git redirect denies make one — -// every other mount point is bwrap's, and empty (see gitRedirectMountPoint). -const redirectMountPointBytes = new Map() + +// Mount points on the host for deny paths that are not there, against the +// bytes each holds. When bwrap does --ro-bind /dev/null /nonexistent/path it +// creates an empty file on the host as a mount point, and it persists after +// bwrap exits; the git redirect denies write their own instead, holding the +// placeholder (see gitRedirectMountPoint). undefined is bwrap's, empty one. +// The cleanup takes a file away only while it still holds what is recorded +// here, so one something else has written to is left where it is. +const bwrapMountPoints = new Map() /** Temp-directory prefix of the store below, so it is nobody else's. */ export const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' @@ -1445,10 +1498,6 @@ function registerExitCleanupHandler(): void { * handler and reset() where deferral is not meaningful. That is also what * empties the store of git redirect placeholders, which is per process. * - * A mount point this wrapper wrote itself rather than left to bwrap (a git - * redirect deny's, holding what {@link gitRedirectMountPoint} put there) is - * removed while it still holds exactly that, as an empty one is. - * * Also closes the `--args` profiles the wraps of this batch opened. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { @@ -1466,14 +1515,12 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { activeSandboxCount = 0 } - for (const mountPoint of bwrapMountPoints) { + for (const [mountPoint, written] of bwrapMountPoints) { try { - // Only remove if it's still the empty file/directory bwrap created, or - // — for a mount point this wrapper wrote itself, which a git redirect - // deny does — the exact bytes it wrote there. If something else has - // written real content, leave it alone. + // Still empty, or still holding exactly what this wrote there: a + // mount point something else has taken over is not this one's to + // remove. const stat = fs.statSync(mountPoint) - const written = redirectMountPointBytes.get(mountPoint) const stillAsCreated = stat.size === 0 || (written !== undefined && @@ -1500,7 +1547,6 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { } } bwrapMountPoints.clear() - redirectMountPointBytes.clear() if (opts?.force) { // The placeholders outlive a batch of wraps the way the masked-file // store's fakes do: what ends them is the session (or the process), not @@ -2236,11 +2282,16 @@ async function generateFilesystemArgs( // at a directory this same call has just judged not ours. let emptySource: string | undefined // Deny destinations that are files git reads back, and the placeholder - // each needs, filled in by the loop below and acted on when the bind is - // emitted: preparing one writes a file on the HOST as well (see - // gitRedirectMountPoint), so a bind the emission drops as hidden by a - // read-deny tmpfs must not have cost anything. + // each needs; acted on at emission (gitRedirectMountPoint). const pendingGitRedirects = new Map() + // The store file each placeholder content was written to, for this wrap: + // the store unlinks and rewrites on every call, which is what revalidates + // it, and one wrap needs that once however many destinations share it. + const gitRedirectStoreFiles = new Map() + // Git directories this wrap denies whole because no mount point could be + // made inside them, so the second redirect file in one does not emit a + // second bind of the same directory. + const denyWholeGitDirs = new Set() // The store directory a placeholder came from, pinned read-only with the // other mount sources at the end. Set only where one was actually used. let gitRedirectSourceDir: string | undefined @@ -2972,11 +3023,8 @@ async function generateFilesystemArgs( // track it, so cleanupBwrapMountPoints() takes it away. Same gate as // that branch. Under a read-only denied directory nothing needs // covering (the file is already unwritable there), but it is still - // tracked: it is no more the caller's file for being there. - // - // Not for a file git reads back: /dev/null is exactly what it must not - // be covered with, and the existing-path branch below binds the - // placeholder over an empty one. + // tracked: it is no more the caller's file for being there. Not for a + // file git reads back, which gitRedirectMountPoint settles instead. if ( (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath)) && @@ -2987,7 +3035,7 @@ async function generateFilesystemArgs( denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } - bwrapMountPoints.add(normalizedPath) + bwrapMountPoints.set(normalizedPath, undefined) registerExitCleanupHandler() logForDebugging( `[Sandbox Linux] Re-covering a mount point an earlier sandbox left behind: ${normalizedPath}`, @@ -3053,19 +3101,13 @@ async function generateFilesystemArgs( // of /dev/null. This prevents the component from appearing as a file // which breaks tools that expect to traverse it as a directory. const isIntermediate = firstNonExistent !== normalizedPath - // A placeholder git reads, where the leaf is one of the files it - // reads back (gitRedirectPlaceholder): /dev/null there makes git - // refuse to run in the repository at all, not just refuse the write - // this deny is for. Decided from the resolved path, which every - // spelling of one file shares, rather than from the deny entry: - // a caller's own denyWrite naming the same file must not miss it. + // Decided from the resolved path, which every spelling of one file + // shares, rather than from the deny entry: a caller's own + // denyWrite naming the same file must not miss it. /dev/null + // stands in the buffer until gitRedirectMountPoint replaces it. const gitRedirectStub = isIntermediate ? undefined : gitRedirectPlaceholder(normalizedPath) - // The redirect source is not built here: it is a file on the host - // as well as a bind, and this bind may still be dropped below as - // hidden by a read-deny tmpfs. /dev/null stands in the buffer - // until the emission replaces it (pendingGitRedirects). const source = isIntermediate ? (emptySource ??= ensureEmptyMountSourceDir()) : '/dev/null' @@ -3108,7 +3150,7 @@ async function generateFilesystemArgs( // below a second spelling to test, and `dest` itself is always // tested. denyWriteRawDests.set(firstNonExistent, rawPath) - bwrapMountPoints.add(firstNonExistent) + bwrapMountPoints.set(firstNonExistent, undefined) registerExitCleanupHandler() logForDebugging( `[Sandbox Linux] Mounted ${ @@ -3162,14 +3204,9 @@ async function generateFilesystemArgs( ) } } - // A file git reads back is bound from itself here only if it holds a - // redirect git can read. The usual way to find one that does not is - // a previous wrap's own mount point for the absent case, left empty - // by a process that could not clean up: binding that would deny the - // same write and cost the repository every git command, since git - // refuses to run at all against a commondir it cannot read. Which of - // the two it is is settled when the bind is emitted, along with what - // the host reads there (gitRedirectMountPoint). + // A file git reads back is not necessarily bound from itself: an + // empty one left by a killed wrap is a redirect git refuses. Which + // it is is settled at emission (gitRedirectMountPoint). const gitRedirectStub = gitRedirectPlaceholder(normalizedPath) if (gitRedirectStub !== undefined) { pendingGitRedirects.set(normalizedPath, gitRedirectStub) @@ -3463,7 +3500,25 @@ async function generateFilesystemArgs( const pendingRedirect = pendingGitRedirects.get(dest) let source = denyWriteArgs[i + 1]! if (pendingRedirect !== undefined) { - source = gitRedirectMountPoint(dest, pendingRedirect) + const prepared = gitRedirectMountPoint( + dest, + pendingRedirect, + gitRedirectStoreFiles, + ) + if ('denyGitDirWhole' in prepared) { + const gitDir = prepared.denyGitDirWhole + // Read-only over the directory itself: it needs nothing written to + // the host, and it denies every file inside at once — including the + // other redirect file, whose own turn in this loop then has nothing + // left to do. + if (!denyWholeGitDirs.has(gitDir)) { + denyWholeGitDirs.add(gitDir) + args.push('--ro-bind', gitDir, gitDir) + emittedDenyWriteDests.push(gitDir) + } + continue + } + source = prepared.bind if (source !== dest) gitRedirectSourceDir = path.dirname(source) } args.push(denyWriteArgs[i]!, source, dest) @@ -3961,11 +4016,13 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // Undo the activeSandboxCount increment — the caller won't call - // cleanupBwrapMountPoints() for a wrap that threw. - if (activeSandboxCount > 0) { - activeSandboxCount-- - } + // The caller does not clean up after a wrap that threw, and this one may + // already have written mount points on the host — the files git reads + // back are written before bubblewrap is ever reached. No sandbox of this + // wrap is running, so the cleanup that undoes the activeSandboxCount + // increment takes them away with it; it still defers to any other wrap + // that is still up. + cleanupBwrapMountPoints() throw error } } From e50e5d8248088db9c4b7e31bfe6856d573425c59 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 10:06:39 +0000 Subject: [PATCH 025/141] Cover all of it, and say so Tests for each shape above, at the uid that can reach it: the stderr shapes through a fake rg that writes rg's own bytes, the walk against real directories on disk, and - where the uid allows a directory to be unreadable - the real rg under bubblewrap against a directory named "a: b" taken to mode 000, which the next command is still refused the nested repository beside and refused the chmod that would open it again. The killed-wrap case is driven by a child process that wraps and then SIGKILLs itself, so what the next wrap finds is the wrapper's own leftover rather than a hand-made file. README: how a failed scan is classified and where the directories it denies come from, the refusals a sandboxed command can plant and that lifting them takes a command run outside the sandbox, the node_modules blind spot, the per-process limit on mount-point cleanup, and what happens where a git directory takes no mount point. The 4,000-character bullet on git directories is a short bullet and three sections. Housekeeping: a census test that only re-stated two of its neighbours is gone, as is a duplicate of the commondir-size refusal; a loop with no length guard gained one; the store prefix is no longer exported, since the test that used it can ask the wrap what it bound. --- README.md | 37 +- src/sandbox/linux-sandbox-utils.ts | 2 +- test/sandbox/git-pointer-parity.test.ts | 20 - .../macos-literal-deny-brackets.test.ts | 6 +- test/sandbox/mandatory-deny-paths.test.ts | 592 +++++++++++++++--- test/utils/ripgrep.test.ts | 53 +- 6 files changed, 612 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 6cc5a4a68..789c6340f 100644 --- a/README.md +++ b/README.md @@ -690,7 +690,7 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command — except at a `commondir` or `config.worktree`, where `/dev/null` is what git cannot read, and the placeholder above is written there instead. A leftover empty directory cannot be told from anyone else's and is left alone. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command — except at a `commondir` or `config.worktree`, where `/dev/null` is what git cannot read and a placeholder git accepts is written there instead (see **Mandatory Deny Paths** below). A leftover empty directory cannot be told from anyone else's and is left alone. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -715,7 +715,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. The file is removed after the command; one left behind by a killed process is rewritten and removed by the next wrap, and an empty `commondir`, which git refuses outright, is repaired the same way. For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. **Which git directories are covered** below says how each is found and what is denied where one cannot be. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -727,6 +727,29 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` +**Which git directories are covered.** + +- `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). +- An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. +- A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. +- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one under `.git/modules`: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. +- On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. +- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. + +**The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. + +- The file is removed after the command. One left behind by a killed process is claimed and removed by the next wrap — recognised by its content alone, so a `commondir` holding exactly `.` that somebody else put there is taken away with it — and an empty `commondir`, which git refuses outright, is repaired the same way; an empty `commondir` that still has write bits belongs to a host `git` caught between creating the file and writing it, and is left exactly as found. +- For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. +- Where the git directory takes no mount point at all — one this process cannot write, such as a root-owned vendored checkout or a read-only mount — the git directory is bound read-only whole instead. That needs nothing written to the host, costs what was already unwritable, and is also what stops the command from making the directory writable and creating the file itself. +- Cleanup is deferred per process, not across processes: two processes wrapping commands in one repository can each claim the same mount point, and the one that cleans up first removes it while the other's sandbox is still running, which detaches that bind and stops that one deny applying inside it. The bind's source is the placeholder in the temporary directory rather than the mount point, so the sandbox still starts. + +**Refusals a wrapped command can plant (Linux).** Two of the wrap-time refusals below follow from a file a sandboxed command is allowed to write, and the command that would put it right is refused as well, because every wrap in that working directory hits the same file. Both name the offending path, or the line the scan printed, in the message, and **both are lifted only by a command run outside the sandbox**: + +- `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. +- `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. + +A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. + **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: - removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; @@ -737,7 +760,13 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and every other failure aborts the command rather than sandboxing it with a partial deny list — a scan that could not be run at all, one that does not finish in time, and one that fails for a reason naming no path under the working directory to deny in its place. Each of those is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. +**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: + +- every code `ENOENT`: entries that went away while the scan walked. Nothing is at those paths to deny, and the rest of the listing still counts. +- any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and inside the scan's own time budget, not by reading the paths off stderr. +- anything else, no code at all, or an `EACCES` the walk cannot find a directory for: the command is refused, with the raw stderr (truncated) in the message. + +A scan that could not be run at all and one that does not finish in time are refused too. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. @@ -761,6 +790,8 @@ A wrap that carries no write restrictions at all — `filesystem.disabled` with - Higher values provide more protection but slower performance - Files in CWD (depth 0) are always protected regardless of this setting +A nested repository is recognised by any regular file directly inside its `.git`, which is why it is found at the depth the repository sits at rather than one level further down where the hook files are. With `filesystem.allowGitConfig` off — the default — the deny of `config` keeps such a file there and it cannot be removed from inside the sandbox, so one command cannot hide the repository from the next command's scan. With `allowGitConfig` on it could, so each command additionally walks the working directory to the same depth, skipping `node_modules` as the scan does, and denies the `.git` directories it finds; that walk is the cost of allowing config writes and is not run otherwise. + ### Unix Socket Restrictions (Linux) On Linux, the sandbox uses **seccomp BPF (Berkeley Packet Filter)** to block Unix domain socket creation at the syscall level. This provides an additional layer of security to prevent processes from creating new Unix domain sockets for local IPC (unless explicitly allowed). diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 00e7db3c1..3afb98bcc 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -961,7 +961,7 @@ function asProfileRefusal(produce: () => T): T { const bwrapMountPoints = new Map() /** Temp-directory prefix of the store below, so it is nobody else's. */ -export const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' +const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' // The placeholders the git redirect denies bind over their mount points: one // file per distinct content for the life of the process, rewritten on each diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 62fc53576..e188dc01f 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -753,26 +753,6 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { ) expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) }, 300_000) - - it('refuses to sandbox on a commondir past the size it reads', () => { - // git reads commondir whole, with no bound of its own, so a file past - // this one still names the git directory whose hooks a commit runs. - const caseDir = join(root, `commondir-${caseCount++}`) - const checkout = join(caseDir, 'checkout') - mkdirSync(checkout, { recursive: true }) - const main = makeGitDir(join(caseDir, 'main.git')) - const worktree = join(main, 'worktrees', 'wt') - mkdirSync(join(worktree, 'hooks'), { recursive: true }) - writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') - writeFileSync( - join(worktree, 'commondir'), - '../..'.padEnd(MAX_GITFILE_SIZE + 1, '\n'), - ) - const pointer = join(checkout, '.git') - writeFileSync(pointer, `gitdir: ${worktree}\n`) - - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) - }) }) function mulberry32(seed: number): () => number { diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index 5c78d9122..8e522cec5 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -526,7 +526,9 @@ describe.if(!isWindows)( // A spelling sniffed as a pattern keeps its brackets verbatim, since // they are the glob syntax it is taken to be asking for. An anchored // pattern escapes the part of it the library built. - for (const regex of emittedRegexes(profile())) { + const regexes = emittedRegexes(profile()) + expect(regexes.length).toBeGreaterThan(0) + for (const regex of regexes) { for (const opening of ['s[m/', 'u[n/', 'a[b/']) { expect(regex).not.toContain(opening) } @@ -693,7 +695,7 @@ describe.if(!isWindows)( const regexes = emittedRegexes(wrap(tree, 'true')) const anchored = regexes.filter(regex => regex.startsWith(`^${anchor}/`)) expect(anchored.length).toBeGreaterThan(0) - // `**\/.git/hooks/**` covers a nested repository's hooks under the real + // `**/.git/hooks` covers a nested repository's hooks under the real // working directory, and nothing under the two directories the bracket // class would have matched instead. const nested = join(tree.work, 'vendor/lib/.git/hooks/pre-commit') diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 43b40e709..a009ae2cc 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -23,7 +23,7 @@ import { realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' import { indexOfMount, @@ -40,7 +40,6 @@ import { cleanupBwrapMountPoints, linuxGetCwdMandatoryDenyPaths, linuxGetMonitorCwdDenyPaths, - GIT_REDIRECT_STORE_PREFIX, LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' import { @@ -52,6 +51,7 @@ import { submoduleGitDirs, } from '../../src/sandbox/mandatory-deny-paths.js' import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' +import { quote } from '../../src/utils/shell-quote.js' import type { RipgrepConfig } from '../../src/utils/ripgrep.js' /** @@ -276,7 +276,7 @@ describe.if(isSupportedPlatform)( async function runSandboxed( command: string, opts: SandboxRunOptions = {}, - ): Promise<{ success: boolean; stderr: string }> { + ): Promise<{ success: boolean; stderr: string; stdout: string }> { const platform = getPlatform() // Allow writes to current directory, but mandatory denies should still block dangerous files @@ -314,6 +314,7 @@ describe.if(isSupportedPlatform)( return { success: result.status === 0, stderr: result.stderr || '', + stdout: result.stdout || '', } } @@ -334,7 +335,7 @@ describe.if(isSupportedPlatform)( filePath: string, content: string, opts: SandboxRunOptions = {}, - ): Promise<{ success: boolean; stderr: string }> { + ): Promise<{ success: boolean; stderr: string; stdout: string }> { return runSandboxed(`echo '${content}' > '${filePath}'`, opts) } @@ -467,6 +468,29 @@ describe.if(isSupportedPlatform)( ) }) + it('finds a nested repository with no file directly inside its .git', async () => { + // The scan recognises a repository by a regular file directly inside + // its .git. With config writes allowed a command can delete every + // one of them — HEAD, config, index, description, packed-refs — and + // hide the repository from the next command's scan, so the walk + // looks for the directory itself instead. + for (const sub of ['hooks', 'objects', 'refs']) { + mkdirSync(join('emptied', '.git', sub), { recursive: true }) + } + try { + const result = await runSandboxedWrite( + 'emptied/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(existsSync('emptied/.git/hooks/pre-commit')).toBe(false) + } finally { + rmSync('emptied', { recursive: true, force: true }) + } + }) + it('blocks creating a new hook in a nested repository', async () => { const result = await runSandboxedWrite( 'nested/.git/hooks/post-checkout', @@ -790,9 +814,12 @@ describe.if(isSupportedPlatform)( chmodSync(join(TEST_DIR, 'locked'), 0o000) try { const result = await runSandboxed( - 'chmod 755 locked && echo X > locked/.git/hooks/pre-commit', + 'echo BOOTED; chmod 755 locked && echo X > locked/.git/hooks/pre-commit', ) + // The sandbox ran, and the directory it could not read is + // read-only inside it, so the chmod that would open it fails. + expect(result.stdout).toContain('BOOTED') expect(result.success).toBe(false) expect(result.stderr).not.toBe('') } finally { @@ -834,50 +861,65 @@ describe.if(isSupportedPlatform)( command: '/bin/sh', args: [ '-c', - `printf '%s\\0' ${matches.map(match => `'${match}'`).join(' ')}; ` + - `printf '%s\\n' '${stderr}' >&2; exit 2`, + `printf '%s\\0' ${quote(matches)}; ` + + // %s, not the format string: a backslash in a directory's name + // reaches rg's stderr as itself and must reach this one too. + `printf '%s' ${quote([stderr])} >&2; exit 2`, ], }) - it('keeps what it listed and denies the directory it could not read', async () => { - const cwd = process.cwd() - const locked = join(cwd, 'locked') - const hooks = join(cwd, 'nested', '.git', 'hooks') - const config = join(cwd, 'nested', '.git', 'config') - mkdirSync(locked, { recursive: true }) - try { - const command = await wrapWith( - failingRipgrep( - [config], - `rg: ${locked}: Permission denied (os error 13)`, - ), - ) + /** + * The deny bind of `denyPath`, as the wrapper spells it in the + * command. Matched as text because a directory's name is chosen by + * whoever made it: a space, a colon or a newline in one is + * shell-quoted, and the argv helpers refuse a token of that shape. + */ + const denyBindOf = (denyPath: string): string => + `--ro-bind ${quote([denyPath])} ${quote([denyPath])}` + + /** + * The part of the command that carries the denies: everything after + * the writable bind of the working directory. An ancestor pin is + * spelled with the same words as a deny bind and sits before that + * bind, buried under it, so only position tells the two apart. + */ + function denySectionOf(command: string): string { + const cwd = quote([process.cwd()]) + const writeRoot = `--bind ${cwd} ${cwd}` + const at = command.indexOf(writeRoot) + expect(at).toBeGreaterThan(-1) + return command.slice(at + writeRoot.length) + } - // The nested repository the partial listing named is denied as - // if the run had finished, and what it could not read is denied - // whole, since a nested repository inside it would be unseen. - expect(lastMountAt(command, hooks)).toBe( - `--ro-bind ${hooks} ${hooks}`, - ) - expect(lastMountAt(command, config)).toBe( - `--ro-bind ${config} ${config}`, - ) - expect(lastMountAt(command, locked)).toBe( - `--ro-bind ${locked} ${locked}`, - ) - } finally { - rmSync(locked, { recursive: true, force: true }) + const nestedConfig = (): string => + join(process.cwd(), 'nested', '.git', 'config') + + /** An unreadable directory, and the line real rg prints about it. */ + function lockDirectory(name: string): { + path: string + stderr: string + release: () => void + } { + const locked = join(process.cwd(), name) + mkdirSync(locked, { recursive: true }) + chmodSync(locked, 0o000) + return { + path: locked, + stderr: `rg: ${locked}: Permission denied (os error 13)\n`, + release: () => { + chmodSync(locked, 0o755) + rmSync(locked, { recursive: true, force: true }) + }, } - }) + } - it('refuses the wrap when the failure names no path to deny', async () => { + it('refuses the wrap when the failure names no error code', async () => { // Nothing here stands in for a failure of this shape, so what the - // run never reached would stay writable. + // run never reached would stay writable — and the raw line goes in + // the message, since only a command run outside the sandbox can + // act on it. const error = await wrapWith( - failingRipgrep( - [join(process.cwd(), 'nested', '.git', 'config')], - 'rg: unrecognized option --frobnicate', - ), + failingRipgrep([nestedConfig()], 'rg: unrecognized option --frob'), ).catch((e: unknown) => e) expect(error).toBeInstanceOf(LinuxSandboxProfileError) @@ -887,9 +929,168 @@ describe.if(isSupportedPlatform)( expect((error as Error).message).toMatch( /failed for a reason no deny stands in for/, ) + expect((error as Error).message).toContain( + 'rg: unrecognized option --frob', + ) expect((error as Error).message).toMatch(/was not run/) }) + it('refuses the wrap on an error code no deny stands in for', async () => { + const error = await wrapWith( + failingRipgrep( + [nestedConfig()], + 'rg: /proc/self/fd/9: Input/output error (os error 5)\n', + ), + ).catch((e: unknown) => e) + + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain('(os error 5)') + }) + + it('refuses the wrap when the run said nothing at all', async () => { + const error = await wrapWith( + failingRipgrep([nestedConfig()], ''), + ).catch((e: unknown) => e) + + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain('(nothing on stderr)') + }) + + it('carries on when the run only lost entries it had listed', async () => { + // A file the command deleted while rg walked. There is nothing at + // that path to deny, and mounting one would plant a file on the + // host exactly where the command removed one. + const vanished = join(process.cwd(), 'nested', 'src', 'gone.txt') + const config = nestedConfig() + const command = await wrapWith( + failingRipgrep( + [config], + `rg: ${vanished}: No such file or directory (os error 2)\n`, + ), + ) + + expect(lastMountAt(command, config)).toBe( + `--ro-bind ${config} ${config}`, + ) + expect(lastMountAt(command, vanished)).toBeUndefined() + expect(existsSync(vanished)).toBe(false) + }) + + it('refuses the wrap when nothing unreadable is there to deny', async () => { + // rg said it could not read something and the walk finds every + // directory readable: the run failed over something this cannot + // see, and nothing stands in for that. + const error = await wrapWith( + failingRipgrep( + [nestedConfig()], + `rg: ${join(process.cwd(), 'nested')}: Permission denied (os error 13)\n`, + ), + ).catch((e: unknown) => e) + + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/none is there now/) + }) + + describe.if(process.getuid?.() !== 0)( + 'with a directory this process really cannot read', + () => { + it('denies the unreadable directory and nothing beside it', async () => { + // The name holds rg's own separator. Cut at it, the path reads + // as the directory above — which is readable, was scanned, and + // must not be denied — and the unreadable one never is. + const parent = join(process.cwd(), 'src: x') + mkdirSync(parent, { recursive: true }) + const locked = lockDirectory(join('src: x', 'locked')) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + const denies = denySectionOf(command) + expect(denies).toContain(denyBindOf(locked.path)) + expect(denies).not.toContain(denyBindOf(parent)) + } finally { + locked.release() + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('never denies the working directory over a name', async () => { + const parent = join(process.cwd(), '.: x') + mkdirSync(parent, { recursive: true }) + const locked = lockDirectory(join('.: x', 'locked')) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + const denies = denySectionOf(command) + expect(denies).toContain(denyBindOf(locked.path)) + expect(denies).not.toContain(denyBindOf(process.cwd())) + } finally { + locked.release() + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('denies a name ending in a space, which no trim survives', async () => { + const locked = lockDirectory('locked ') + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + } finally { + locked.release() + } + }) + + it('denies a name holding a newline whole, rather than refusing', async () => { + const locked = lockDirectory('bad\nname') + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + } finally { + locked.release() + } + }) + + it('denies what it can read of a run that both lost and was refused', async () => { + const vanished = join(process.cwd(), 'nested', 'gone.txt') + const locked = lockDirectory('mixed-locked') + try { + const command = await wrapWith( + failingRipgrep( + [nestedConfig()], + `rg: ${vanished}: No such file or directory (os error 2)\n${locked.stderr}`, + ), + ) + + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + expect(lastMountAt(command, vanished)).toBeUndefined() + } finally { + locked.release() + } + }) + }, + ) + it('refuses the wrap when the scan could not be run at all', async () => { const error = await wrapWith({ command: join(TEST_DIR, 'no-such-ripgrep'), @@ -919,7 +1120,10 @@ describe.if(isSupportedPlatform)( 'denies a nested repository the real rg could not walk past', async () => { const cwd = process.cwd() - const blind = join(cwd, 'blind') + // Named with rg's own separator between a path and its message, + // so the whole round trip runs against a name that defeats + // reading paths out of stderr. + const blind = join(cwd, 'a: b') const hook = join(cwd, 'nested', '.git', 'hooks', 'pre-commit') const wrap = (command: string): Promise => wrapCommandWithSandboxLinux({ @@ -942,16 +1146,24 @@ describe.if(isSupportedPlatform)( // The first command leaves a directory the next command's scan // cannot read: that scan fails, and the hooks of the nested // repository beside it must be denied all the same. - const first = run(await wrap(`echo ${BOOTED} && chmod 000 blind`)) + const first = run( + await wrap(`echo ${BOOTED} && chmod 000 ${quote([blind])}`), + ) expect(first.stdout).toContain(BOOTED) expect(first.status).toBe(0) cleanupBwrapMountPoints({ force: true }) const second = run( - await wrap(`echo ${BOOTED}; echo X > ${hook} || echo DENIED`), + await wrap( + `echo ${BOOTED}; echo X > ${hook} || echo DENIED; ` + + `chmod 755 ${quote([blind])} || echo BLIND_DENIED`, + ), ) expect(second.stdout).toContain(BOOTED) expect(second.stdout).toContain('DENIED') + // The directory the scan could not read is read-only inside the + // sandbox, so the command cannot open it up for the next scan. + expect(second.stdout).toContain('BLIND_DENIED') expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) } finally { chmodSync(blind, 0o755) @@ -962,6 +1174,34 @@ describe.if(isSupportedPlatform)( ) }) + it.if(isLinux)( + 'scans with the ripgrep config file left out of it', + async () => { + // RIPGREP_CONFIG_PATH routinely points inside a project, at a file + // a sandboxed command can write, and one line of it is enough to + // make the scan list nothing and report no error for it. + const rc = join(TEST_DIR, 'scan.rgrc') + writeFileSync(rc, '--max-filesize=1\n') + const saved = process.env.RIPGREP_CONFIG_PATH + process.env.RIPGREP_CONFIG_PATH = rc + try { + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + if (saved === undefined) delete process.env.RIPGREP_CONFIG_PATH + else process.env.RIPGREP_CONFIG_PATH = saved + rmSync(rc, { force: true }) + } + }, + ) + it('still lets a command create a .git file where none exists', async () => { mkdirSync('fresh-checkout', { recursive: true }) try { @@ -2101,17 +2341,6 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(gitRedirectPlaceholder('/repo/.git/hooks')).toBeUndefined() }) - it('gives a placeholder to the denied files that need one, and no others', () => { - // Both lists come off one table, so a redirect file added to it is - // denied AND has a stand-in: neither can gain an entry the other misses, - // which is how a new one would end up mounted from /dev/null. - expect( - gitDirDenyPaths('/repo/.git', false).filter( - denyPath => gitRedirectPlaceholder(denyPath) !== undefined, - ), - ).toEqual(['/repo/.git/commondir', '/repo/.git/config.worktree']) - }) - it('follows a pointer to the git directory it names', () => { const gitDir = makeGitDir(join(dir, 'gitdir')) const pointer = makePointer('checkout', '../gitdir') @@ -2460,12 +2689,122 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(monitored).toContain(pointer) expect(monitored).toContain(join(checkout, '.bashrc')) + // A pointer file is itself the deny; the hooks and config of a git + // directory that is not there are not denies the wrapper makes. + expect(monitored).not.toContain(join(pointer, 'hooks')) + } finally { + process.chdir(originalCwd) + } + }, + ) + + it.if(isLinux)('gives the monitor no git denies without a .git', () => { + // A mount at an absent .git would block `git init`, so the wrapper makes + // none there and the monitor must not judge writes against four it will + // never see refused. + const checkout = join(dir, 'no-repo') + mkdirSync(checkout, { recursive: true }) + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const monitored = linuxGetMonitorCwdDenyPaths(false) + + expect(monitored).toContain(join(checkout, '.bashrc')) + expect(monitored).not.toContain(join(checkout, '.git', 'hooks')) + expect(monitored).not.toContain(join(checkout, '.git', 'config')) + } finally { + process.chdir(originalCwd) + } + }) + + it.if(process.getuid?.() !== 0)( + 'refuses to sandbox at all on a commondir it cannot read', + () => { + // Returning the denies gathered so far would leave the main + // repository's hooks — the ones this worktree's commits run — writable + // for the next command, which is what made this worth planting. + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) + const commondir = join(worktreeGitDir, 'commondir') + writeFileSync(commondir, '../..\n') + chmodSync(commondir, 0o000) + const pointer = makePointer('wt-checkout', worktreeGitDir) + try { + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + } finally { + chmodSync(commondir, 0o644) + } + }, + ) + + it.if(process.getuid?.() !== 0)( + 'denies a git directory it cannot read whole, rather than refusing', + () => { + // git, running as the same user, cannot read through it either, so the + // repository is already broken for everyone. Refusing every command in + // the checkout would be a brick one chmod plants; the directory is + // denied whole instead, and nothing under it stays writable. + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + chmodSync(worktreeGitDir, 0o000) + try { + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + worktreeGitDir, + ]) + } finally { + chmodSync(worktreeGitDir, 0o755) + } + }, + ) + + it.if(isLinux)( + 'carries an unresolvable pointer to the wrap as a typed refusal', + async () => { + // A sandboxed command can write one of these, and the command that + // would delete it is refused too, so the caller has to be able to tell + // this case from the others and the message has to name the file. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + Buffer.concat([ + Buffer.from(`gitdir: ${gitDir}`), + Buffer.from([0xff]), + Buffer.from('\n'), + ]), + ) + const originalCwd = process.cwd() + process.chdir(join(dir, 'checkout')) + try { + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_git_metadata_unreadable', + ) + expect((error as Error).message).toContain(pointer) } finally { process.chdir(originalCwd) } }, ) + it('names itself, and carries a code, wherever it surfaces', () => { + // macOS has no profile-error type of its own, so this IS the refusal the + // caller branches on there; it is exported from the package root. + const error = new GitMetadataError('x') + + expect(error.name).toBe('GitMetadataError') + expect(error.code).toBe('git_metadata_unreadable') + }) + it.if(!isWindows)( 'does not block on a FIFO left where a git directory keeps its commondir', () => { @@ -2677,6 +3016,11 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { /** Echoed by every command that runs for real, so nothing concludes * anything from a sandbox that never started. */ const BOOTED = 'BOOTED' + /** Imported by the child process below, which wraps and is then killed. */ + const LINUX_BACKEND_SOURCE = new URL( + '../../src/sandbox/linux-sandbox-utils.ts', + import.meta.url, + ).pathname const LIVE = bwrapCanNamespace() && Bun.which('git') !== null /** Enough to commit without a hook, an identity or a signature. */ const IDENT = [ @@ -2802,9 +3146,9 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { it('reads the placeholder off the file, not off the deny entry', async () => { // A caller's own deny for the same file comes first and wins the dedup, - // so the decision has to be made from the path the entry resolves to: - // made from the entry's spelling, this one missed and bound /dev/null, - // and the repository lost every git command for the length of the wrap. + // so the decision has to be made from the path the entry resolves to. An + // entry's own spelling does not name the file: /dev/null lands there + // instead, and the repository loses every git command for the wrap. const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') @@ -2838,6 +3182,9 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { // wrapper's to remove, the same way an empty one that gained content is // not: the cleanup compares what is there with what it wrote. await wrapIn(checkout) + // Taken over by something that wanted a real redirect there, mode and + // all: what the mount point holds is no longer what this wrote. + chmodSync(commondir, 0o644) writeFileSync(commondir, '../..\n') cleanupBwrapMountPoints({ force: true }) expect(readFileSync(commondir, 'utf8')).toBe('../..\n') @@ -2860,20 +3207,61 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { expect(existsSync(commondir)).toBe(false) }) - it('binds a commondir already holding the placeholder from itself', async () => { + it('claims a commondir already holding the placeholder, and binds the store', async () => { // What a killed process leaves: nothing in memory knows the file is a - // mount point, but nothing else writes exactly those bytes there, so - // the next wrap claims it, binds it from itself and removes it. + // mount point, but nothing else writes exactly those bytes there, so the + // next wrap claims it and removes it. The bind comes off the store, not + // off the file: another process wrapping a command in this repository + // claims the same file, and its cleanup would leave this bind sourceless. const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') writeFileSync(commondir, '.\n') - expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + const source = mountSource(await wrapIn(checkout), commondir) + expect(source).not.toBe(commondir) + expect(readFileSync(source, 'utf8')).toBe('.\n') cleanupBwrapMountPoints({ force: true }) expect(existsSync(commondir)).toBe(false) }) + it('claims the config.worktree a killed wrap of its own left behind', async () => { + // The wrapper's own leftover, not a hand-made one: an empty + // config.worktree is a legitimate file nothing here may remove, so the + // mount point is written with the mode bubblewrap gives its own and is + // recognised by that shape alone once the process holding it is gone. + const checkout = makeCheckout('repo') + const configWorktree = join(checkout, '.git', 'config.worktree') + const script = join(dir, 'killed-wrap.ts') + writeFileSync( + script, + [ + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LINUX_BACKEND_SOURCE)}`, + `process.chdir(${JSON.stringify(checkout)})`, + 'await wrapCommandWithSandboxLinux({', + " command: 'true',", + ' needsNetworkRestriction: false,', + ' allowAllUnixSockets: true,', + ' readConfig: undefined,', + ` writeConfig: { allowOnly: [${JSON.stringify(checkout)}], denyWithinAllow: [] },`, + '})', + // No exit event, so nothing of that wrap's cleanup runs. + "process.kill(process.pid, 'SIGKILL')", + ].join('\n'), + ) + + const killed = spawnSync(process.execPath, [script], { + encoding: 'utf8', + timeout: 60000, + }) + expect(`${killed.signal} ${killed.stderr}`).toBe('SIGKILL ') + expect(readFileSync(configWorktree, 'utf8')).toBe('') + + await wrapIn(checkout) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(configWorktree)).toBe(false) + }) + it('leaves alone an empty config.worktree it did not leave there', async () => { // An empty config.worktree reads as no worktree config at all, so one is // legitimate and this wrapper never claims it — unless it has the shape @@ -2895,6 +3283,72 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { expect(existsSync(legitimate)).toBe(false) }) + it('leaves a zero-byte commondir a host git is mid-write on', async () => { + // git creates commondir and writes it as two steps, so an empty one with + // write bits is a file another process owns right now — not the shape + // bubblewrap leaves. The sandbox still gets a commondir it can read, off + // the store, and the host's file is neither rewritten nor removed. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '', { mode: 0o644 }) + + const source = mountSource(await wrapIn(checkout), commondir) + expect(source).not.toBe(commondir) + expect(source).not.toBe('/dev/null') + expect(readFileSync(source, 'utf8')).toBe('.\n') + expect(readFileSync(commondir, 'utf8')).toBe('') + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(true) + expect(readFileSync(commondir, 'utf8')).toBe('') + }) + + it.if(process.getuid?.() !== 0)( + 'denies a git directory whole when it takes no mount point', + async () => { + // A vendored checkout the wrapper cannot write: refusing every command + // in the tree would be a brick a sandboxed command can plant with one + // chmod, and a read-only bind of the directory needs nothing written to + // the host, costs what was already unwritable, and closes that route. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + chmodSync(gitDir, 0o555) + try { + const command = await wrapIn(checkout) + + expect(lastMountAt(command, gitDir)).toBe( + `--ro-bind ${gitDir} ${gitDir}`, + ) + expect(existsSync(join(gitDir, 'commondir'))).toBe(false) + expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) + } finally { + chmodSync(gitDir, 0o755) + } + }, + ) + + it('leaves no mount point behind when the wrap itself throws', async () => { + // The files git reads back are written before bubblewrap is reached, and + // the caller does not clean up after a wrap that threw. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const refusal = await wrapIn(checkout, 'true', { + denyWithinAllow: Array.from({ length: 4000 }, (_unused, n) => + join(checkout, `deny-${n}`), + ), + }).then( + () => undefined, + (err: unknown) => err, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'too_many_arguments', + ) + expect(existsSync(commondir)).toBe(false) + }) + it('leaves every other absent deny on /dev/null', async () => { const checkout = makeCheckout('repo') @@ -2906,24 +3360,22 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { it('mints one placeholder file for the process, not one per wrap', async () => { process.env.TMPDIR = dir const checkout = makeCheckout('repo') - const stores = (): string[] => - readdirSync(dir).filter(entry => - entry.startsWith(GIT_REDIRECT_STORE_PREFIX), - ) - expect(stores()).toHaveLength(0) + const commondir = join(checkout, '.git', 'commondir') + const stores = new Set() for (let wrap = 0; wrap < 20; wrap++) { - await wrapIn(checkout) + const command = await wrapIn(checkout) + stores.add(dirname(mountSource(command, commondir))) // Not forced: the store is per process, so a batch of wraps ending // must not empty it — and the mount point goes, so the next wrap // takes the absent-path branch again. cleanupBwrapMountPoints() } - expect(stores()).toHaveLength(1) + expect(stores.size).toBe(1) // One file per distinct placeholder, whatever the number of wraps: the // repository's absent commondir and its absent config.worktree. - expect(readdirSync(join(dir, stores()[0]!))).toHaveLength(2) + expect(readdirSync([...stores][0]!)).toHaveLength(2) }) it('refuses the command when no placeholder can be written', async () => { diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 142f0deac..7988bb2b1 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -75,6 +75,31 @@ describe('ripGrep', () => { } }) + it('leaves the ripgrep config file out of every run', async () => { + // RIPGREP_CONFIG_PATH routinely points inside a project, at a file a + // sandboxed command can write; one --max-filesize line in it is a scan + // that lists nothing and reports no error for it. + const dir = mkdtempSync(join(tmpdir(), 'rg-noconfig-')) + try { + const script = join(dir, 'echo-args.cjs') + writeFileSync( + script, + "process.stdout.write(process.argv.slice(2).join('\\0') + '\\0')", + ) + + const results = await ripGrep( + ['--files'], + dir, + new AbortController().signal, + { command: process.execPath, args: [script] }, + ) + + expect(results).toContain('--no-config') + } finally { + rmSync(dir, { recursive: true }) + } + }) + it('rejects on exit code > 1', () => { expect( ripGrep(['--invalid-flag-xyz'], '.', new AbortController().signal), @@ -113,9 +138,12 @@ describe('ripGrep', () => { const error = await ripGrep([], dir, new AbortController().signal, { command: '/bin/sh', // A complete record, then a truncated one, then a run that outlives - // the timeout. exec so the kill reaches whatever holds stdout. + // the timeout. exec so the kill reaches whatever holds stdout. The + // budget is far past what starting a shell and printing two records + // takes on any machine: cut fine, the output would be the empty + // prefix of a run that had not got to writing it yet. args: ['-c', 'printf "/found/a\\0/trunc"; exec sleep 30'], - timeoutMs: 200, + timeoutMs: 5_000, }).catch((e: unknown) => e) expect(error).toBeInstanceOf(RipgrepError) @@ -125,6 +153,27 @@ describe('ripGrep', () => { rmSync(dir, { recursive: true }) } }, + 30_000, + ) + + it.if(!isWindows)( + 'calls a kill that is not the timeout what it was', + async () => { + // Ctrl-C to the process group, an OOM kill: a run that ended inside + // the time it was given did not run out of it, and reporting one as + // the other tells the operator to look at the wrong thing. Both refuse + // the wrap upstream either way. + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'kill -TERM $$'], + timeoutMs: 60_000, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).timedOut).toBe(false) + expect((error as Error).message).toContain('SIGTERM') + expect((error as Error).message).toContain('inside the 60000 ms') + }, ) it.if(!isWindows)( From 97485100b8cf656d449a303779e92d67857e413f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 22:04:52 +0000 Subject: [PATCH 026/141] The submodule walk has no depth bound, and spends the scan's own time A submodule's name is its path and submodules nest, so how deep a .git/modules tree goes is a question about the repository. The walk stopped at ten levels and denied whole what it would have descended into, which in a deeply nested tree took a submodule's objects, refs and index read-only along with its hooks. Nothing about ten was real. What bounds the walk now is time: the deadline the ripgrep scan and its unreadable-directory walk already share, so one budget covers everything the mandatory denies cost, and running out of it refuses the command with deny_scan_failed rather than handing back a listing that stops somewhere unknown. That is the answer a bind mount pointed back at its own ancestor needs, which gives every level a real path of its own and so has no end the visited set can see; a symlink back into the tree is still the dead end it was. The walk keeps its own stack, so a chain two hundred directories deep cannot overflow this process's. macOS shares the producer, so it loses the depth bound too, and gets the same deadline with a default of its own. SubmoduleWalkBudgetError is exported beside GitMetadataError for callers that branch on it there. The scan's results are sorted, so one tree gives one deny list whatever order the filesystem lists it in. --- README.md | 3 +- src/index.ts | 8 +- src/sandbox/linux-sandbox-utils.ts | 22 ++- src/sandbox/mandatory-deny-paths.ts | 156 +++++++++-------- test/sandbox/mandatory-deny-paths.test.ts | 194 +++++++++++++++------- 5 files changed, 253 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 789c6340f..5fe81234b 100644 --- a/README.md +++ b/README.md @@ -732,7 +732,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. -- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one under `.git/modules`: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. +- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list and an entry it cannot stat, and for both of them what is denied is the deepest ancestor the walk can reach, which may be `.git/modules` itself. +- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — the same budget the `ripgrep` scan gets on Linux, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed`) rather than sandboxing on the submodules the walk did reach. - On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. - On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. diff --git a/src/index.ts b/src/index.ts index a28c48e5d..ce8de6742 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,13 @@ export { // `commondir` whose target cannot be worked out the way git works it out. On // Linux the same condition arrives as a LinuxSandboxProfileError carrying // `deny_git_metadata_unreadable`. -export { GitMetadataError } from './sandbox/mandatory-deny-paths.js' +// The other refusal the shared producers raise: the `.git/modules` walk ran +// out of the time it was given, which on Linux arrives as a +// LinuxSandboxProfileError carrying `deny_scan_failed`. +export { + GitMetadataError, + SubmoduleWalkBudgetError, +} from './sandbox/mandatory-deny-paths.js' // Windows install/status API export { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 3afb98bcc..07f1888f3 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -31,6 +31,7 @@ import { } from './sandbox-utils.js' import { GitMetadataError, + SubmoduleWalkBudgetError, gitDirDenyPaths, gitDirTreeDenyPaths, gitFileDenyPaths, @@ -637,6 +638,7 @@ function denyScanFailed( */ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, + options: { deadline?: number } = {}, ): string[] { const cwd = process.cwd() const denyPaths = cwdDangerousDenyPaths(cwd) @@ -653,7 +655,7 @@ export function linuxGetCwdMandatoryDenyPaths( if (!isAbsenceErrno(err)) return [...denyPaths, dotGitPath] } if (dotGitStat?.isDirectory()) { - denyPaths.push(...gitDirTreeDenyPaths(dotGitPath, allowGitConfig)) + denyPaths.push(...gitDirTreeDenyPaths(dotGitPath, allowGitConfig, options)) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. @@ -763,7 +765,7 @@ async function linuxGetMandatoryDenyPaths( const dangerousDirectories = getDangerousDirectories() const denyPaths = asProfileRefusal(() => - linuxGetCwdMandatoryDenyPaths(allowGitConfig), + linuxGetCwdMandatoryDenyPaths(allowGitConfig, { deadline }), ) // Each nested repository the scan finds, once: the same walk the cwd's own @@ -773,7 +775,11 @@ async function linuxGetMandatoryDenyPaths( const denyGitDir = (gitDir: string): void => { if (seenGitDirs.has(gitDir)) return seenGitDirs.add(gitDir) - denyPaths.push(...gitDirTreeDenyPaths(gitDir, allowGitConfig)) + denyPaths.push( + ...asProfileRefusal(() => + gitDirTreeDenyPaths(gitDir, allowGitConfig, { deadline }), + ), + ) } const walkScan = (collectGitDirs: boolean) => walkScanDirectories(cwd, maxDepth, deadline, collectGitDirs) @@ -941,6 +947,13 @@ function asProfileRefusal(produce: () => T): T { try { return produce() } catch (err) { + if (err instanceof SubmoduleWalkBudgetError) { + // The same answer a ripgrep scan that runs out of its time gets, for + // the same reason: a listing that stops somewhere unknown is not + // something to sandbox on. The walk shares that budget. + logForDebugging(`[Sandbox Linux] ${err.message}`, { level: 'warn' }) + throw new LinuxSandboxProfileError('deny_scan_failed', err.message, err) + } if (!(err instanceof GitMetadataError)) throw err logForDebugging(`[Sandbox Linux] ${err.message}`, { level: 'warn' }) throw new LinuxSandboxProfileError( @@ -1319,7 +1332,8 @@ export type LinuxSandboxProfileErrorCode = * finished, or it failed for a reason that names no path under the working * directory to deny in its place. Sandboxing on what it did list would * leave whatever it never reached — a nested repository's hooks — writable, - * so the command is refused instead. + * so the command is refused instead. The `.git/modules` walk spends the + * same budget and running out of it is the same answer. */ | 'deny_scan_failed' /** diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 2bb42bf04..99b088a45 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -1,6 +1,7 @@ import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' +import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' import { isAbsenceErrno, MAX_SYMLINK_RESOLUTION_DEPTH, @@ -37,12 +38,13 @@ function isUnusablePathError(err: unknown): boolean { const MAX_GIT_METADATA_BYTES = 1024 * 1024 /** - * Depth bound for the `.git/modules` walk. A submodule's name is its path - * (`vendor/lib`) and submodules nest, so the walk descends both name segments - * and nested `modules` directories; this bounds a hostile or looping tree, not - * a real one, and is deliberately unrelated to the ripgrep scan's depth. + * Time the `.git/modules` walk gets when the caller sets no deadline of its + * own — the macOS backend and the violation monitor, neither of which runs + * the ripgrep scan whose deadline the Linux wrap shares with it. The same + * figure the scan gets, for the same reason: a tree that takes longer than + * this to walk is one the command about to run could have made. */ -export const MAX_SUBMODULE_WALK_DEPTH = 10 +const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS /** * Entries whose presence makes a directory a git directory. git needs HEAD @@ -85,19 +87,35 @@ export class GitMetadataError extends Error { } } +/** + * The `.git/modules` walk ran out of the time it was given. The tree it was + * walking is one the command about to run could have made — a bind mount + * pointed back at its own ancestor gives every level a real path of its own, + * which the visited set cannot fold together — so a half-finished listing is + * not something to sandbox on: the submodule git directories it never reached + * would be left with writable hooks. The Linux wrap turns this into a + * `LinuxSandboxProfileError` carrying `deny_scan_failed`, the same answer the + * ripgrep scan's own overrun gets; on macOS it reaches the caller as itself. + */ +export class SubmoduleWalkBudgetError extends Error { + readonly code = 'submodule_walk_budget_exhausted' as const + constructor(message: string) { + super(message) + this.name = 'SubmoduleWalkBudgetError' + } +} + /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { - /** The submodule git directories. */ + /** The submodule git directories, sorted. */ gitDirs: string[] /** * Directories the walk could not see through: what lies under them is * unknown, so they are denied whole rather than left writable with a git - * directory possibly inside. Three things produce one — a directory the walk - * could not list, an entry it could not stat, and, once per branch that - * reaches {@link MAX_SUBMODULE_WALK_DEPTH}, the `modules` beneath the - * directory it stopped at (or that directory itself, when it is not a git - * directory). For the first two the recorded path is the deepest ancestor - * this process can reach, which can be the `.git/modules` root itself. + * directory possibly inside. Two things produce one — a directory the walk + * could not list and an entry it could not stat — and the recorded path is + * the deepest ancestor this process can reach, which can be the + * `.git/modules` root itself. Sorted, like `gitDirs`. * * A whole-directory deny is read-only for everything beneath it, a * submodule's `objects`, `refs` and `index` included, so git writes inside a @@ -296,77 +314,83 @@ export function gitFileDenyPaths( * `modules` (what a commit inside that submodule runs), plus whatever the * walk could not see through. Both backends produce their entries from this * one list. + * + * `deadline` bounds the walk (see {@link submoduleGitDirs}). */ export function gitDirTreeDenyPaths( gitDir: string, allowGitConfig: boolean, + options: { deadline?: number } = {}, ): string[] { - const modules = submoduleGitDirs(path.join(gitDir, 'modules')) + const modules = submoduleGitDirs( + path.join(gitDir, 'modules'), + options.deadline, + ) return [ + ...gitDirDenyPaths(gitDir, allowGitConfig), ...modules.unreadableDirs, - ...[gitDir, ...modules.gitDirs].flatMap(dir => - gitDirDenyPaths(dir, allowGitConfig), - ), + ...modules.gitDirs.flatMap(dir => gitDirDenyPaths(dir, allowGitConfig)), ] } /** * Git directories of the submodules under `modulesDir` (a repository's - * .git/modules), nested submodules included. A submodule's name is its path, - * so one can sit several levels down (modules/vendor/lib), hence the walk. + * .git/modules), nested submodules included, sorted. A submodule's name is + * its path, so one can sit several levels down (modules/vendor/lib), hence + * the walk. + * + * Every directory is visited once, keyed by where it really is, so a symlink + * pointing back into the tree ends the branch that reached it rather than + * looping - but a bind mount gives the same directory a second real path, and + * one pointed at its own ancestor has no end the visited set can see. `deadline` + * is what bounds that, and running out of it throws + * {@link SubmoduleWalkBudgetError} rather than handing back a listing that + * stops somewhere unknown. Nothing bounds how DEEP a real tree may be: a + * submodule nested a hundred levels down has its hooks denied like any other, + * and the walk keeps its own stack so that such a tree cannot overflow this + * one's. */ -export function submoduleGitDirs(modulesDir: string): SubmoduleScan { +export function submoduleGitDirs( + modulesDir: string, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): SubmoduleScan { const scan: SubmoduleScan = { gitDirs: [], unreadableDirs: [] } - collectSubmoduleGitDirs(modulesDir, 0, scan, new Set()) - return scan -} - -function collectSubmoduleGitDirs( - dir: string, - depth: number, - scan: SubmoduleScan, - visited: Set, -): void { - const entries = listDirectory(dir, scan) - if (entries === undefined) return - for (const entry of entries) { - const child = path.join(dir, entry.name) - // git accepts a symlinked entry under .git/modules, and Dirent.isDirectory - // is false for one, so the link is followed — and the realpath recorded, - // since a link back up would otherwise loop until the depth bound. - if (!isDirectory(entry, child, scan)) continue - const visitKey = realPathOrSelf(child) - if (visited.has(visitKey)) continue - visited.add(visitKey) - - const childEntries = listDirectory(child, scan) - if (childEntries === undefined) continue - const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) - if (isGitDir) scan.gitDirs.push(child) - - if (depth + 1 >= MAX_SUBMODULE_WALK_DEPTH) { - // Nothing below here is inspected, so what the walk would have descended - // into is denied whole, like a directory it could not list: a submodule - // git directory nested deeper would otherwise keep its hooks and config - // writable. For a git directory that is the `modules` beneath it (absent - // or not — a sandboxed command must not be able to create one and hide a - // git directory inside), NOT the directory itself, whose objects, refs - // and index stay writable so git still works in that submodule. - const denied = isGitDir ? path.join(child, 'modules') : child - scan.unreadableDirs.push(denied) - logForDebugging( - `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}, denying ${denied} whole`, - { level: 'warn' }, + // Where the walk still has to look, and where it has already been. The root + // counts as visited: an entry linked straight back to it is then the same + // dead end as one linked to any other directory already walked. + const pending = [modulesDir] + const visited = new Set([realPathOrSelf(modulesDir)]) + while (pending.length > 0) { + const dir = pending.pop() as string + if (Date.now() > deadline) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${modulesDir} ran out of the time it was given with ${pending.length + 1} directories left to look at (at ${dir}); refusing to sandbox on the submodule git directories it did reach`, ) - continue } - collectSubmoduleGitDirs( - isGitDir ? path.join(child, 'modules') : child, - depth + 1, - scan, - visited, - ) + const entries = listDirectory(dir, scan) + if (entries === undefined) continue + for (const entry of entries) { + const child = path.join(dir, entry.name) + // git accepts a symlinked entry under .git/modules, and + // Dirent.isDirectory is false for one, so the link is followed — and + // the real path recorded, since a link back up would otherwise loop. + if (!isDirectory(entry, child, scan)) continue + const visitKey = realPathOrSelf(child) + if (visited.has(visitKey)) continue + visited.add(visitKey) + + const childEntries = listDirectory(child, scan) + if (childEntries === undefined) continue + const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) + if (isGitDir) scan.gitDirs.push(child) + // A git directory keeps its own submodules under `modules`; anything + // else is a segment of a submodule name (`vendor` of `vendor/lib`). + pending.push(isGitDir ? path.join(child, 'modules') : child) + } } + scan.gitDirs.sort() + scan.unreadableDirs.sort() + return scan } /** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index a009ae2cc..465f19b81 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -44,8 +44,9 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { GitMetadataError, - MAX_SUBMODULE_WALK_DEPTH, + SubmoduleWalkBudgetError, gitDirDenyPaths, + gitDirTreeDenyPaths, gitFileDenyPaths, gitRedirectPlaceholder, submoduleGitDirs, @@ -2874,50 +2875,130 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) - /** The directory the walk stops at: MAX_SUBMODULE_WALK_DEPTH levels down. */ - function boundDir(): string { - return join( - dir, - 'modules', - ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), - ) - } + /** + * A submodule name is a path and submodules nest, so how deep a real tree + * can go is a question about the repository and not about this walk. There + * is no depth bound: what bounds the walk is the time it is given, and what + * stops it looping is where each directory really is. + */ + it('walks a chain 200 directories deep, and denies every level of it', () => { + // A hundred submodules each nested inside the one above, which is two + // hundred directories of `/modules` below .git/modules — far past + // any bound the walk used to have, and enough of its own stack to + // overflow a recursive one on some runtimes. + const gitDirs: string[] = [] + let at = join(dir, 'modules') + for (let level = 0; level < 100; level++) { + at = join(at, 'x') + makeGitDir(at) + gitDirs.push(at) + at = join(at, 'modules') + } - it.each([ - // A git directory below the bound is never seen, so what the walk would - // have descended into is denied whole in its place. - ['a plain directory', false, (bound: string) => bound], - // When the bound directory is a git directory itself it is BOTH: its own - // hooks and config are denied by path, and only the `modules` beneath it - // is denied whole — denying the git directory would take its objects, - // refs and index with it and stop git working in that submodule. - ['a git directory', true, (bound: string) => join(bound, 'modules')], - ])( - 'stops the modules walk at its depth bound, with %s there', - (_label, boundIsGitDir, denied) => { - const bound = boundDir() - if (boundIsGitDir) makeGitDir(bound) - makeGitDir(join(bound, 'deep')) - - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ - gitDirs: boundIsGitDir ? [bound] : [], - unreadableDirs: [denied(bound)], - }) + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.unreadableDirs).toEqual([]) + expect(scan.gitDirs).toEqual([...gitDirs].sort()) + // Every level's hooks and config, not just the ones above a bound. + const denies = gitDirTreeDenyPaths(dir, false) + for (const gitDir of gitDirs) { + expect(denies).toContain(join(gitDir, 'hooks')) + expect(denies).toContain(join(gitDir, 'config')) + } + }) + + it.if(!isWindows)('ends at a symlink pointing back into the tree', () => { + // modules/a/back -> modules, which the walk would follow for ever if it + // did not record where each directory really is. + mkdirSync(join(dir, 'modules', 'a'), { recursive: true }) + symlinkSync(join(dir, 'modules'), join(dir, 'modules', 'a', 'back')) + const gitDir = makeGitDir(join(dir, 'modules', 'a', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + }) + }) + + it.if(!isWindows)('ends at a symlink loop, denying it whole', () => { + // A loop cannot be stat'ed at all (ELOOP), which reads as unreadable + // rather than as absent: the deepest directory the walk can reach for it + // is denied whole. + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) + symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [], + unreadableDirs: [join(dir, 'modules'), join(dir, 'modules')], + }) + }) + + it('refuses rather than hand back a walk that ran out of its time', () => { + makeGitDir(join(dir, 'modules', 'a', 'lib')) + makeGitDir(join(dir, 'modules', 'b', 'lib')) + + // A deadline already past: the first directory the walk takes off its + // stack is where it stops. + expect(() => + submoduleGitDirs(join(dir, 'modules'), Date.now() - 1), + ).toThrow(SubmoduleWalkBudgetError) + const error = (() => { + try { + submoduleGitDirs(join(dir, 'modules'), Date.now() - 1) + return undefined + } catch (err) { + return err as SubmoduleWalkBudgetError + } + })() + expect(error?.name).toBe('SubmoduleWalkBudgetError') + expect(error?.code).toBe('submodule_walk_budget_exhausted') + expect(error?.message).toContain(join(dir, 'modules')) + }) + + it.if(isLinux)( + 'refuses the wrap where the modules walk runs out of its time', + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + makeGitDir(join(gitDir, 'modules', 'lib')) + + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + // The walk shares the scan's budget, so one millisecond of it is + // gone before the walk starts. + ripgrepConfig: { command: 'rg', timeoutMs: 1 }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + } finally { + cleanupBwrapMountPoints({ force: true }) + process.chdir(originalCwd) + } }, + 30000, ) it.if(isLinux)( - 'keeps a bound git directory writable except for its hooks, config and modules', + 'keeps a deeply nested git directory writable except for its hooks and config', async () => { const checkout = join(dir, 'repo') const gitDir = makeGitDir(join(checkout, '.git')) - const bound = join( + const deep = join( gitDir, 'modules', - ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ...Array.from({ length: 12 }, () => 'x'), ) - makeGitDir(bound) - mkdirSync(join(bound, 'objects'), { recursive: true }) + makeGitDir(deep) + mkdirSync(join(deep, 'objects'), { recursive: true }) const originalCwd = process.cwd() process.chdir(checkout) @@ -2930,12 +3011,11 @@ describe('Git metadata deny paths - Unit Tests', () => { writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - // The covering deny is the `modules` beneath it, not the git directory - // itself: denying that whole would take its objects, refs and index. - // An ancestor pin spells that same self-bind, so where it sits is - // what tells the two apart: pins are spliced in before the write - // root's own bind, which makes the tree writable again, and a deny - // bind is emitted after it. + // No deny covering the git directory whole, which would take its + // objects, refs and index with it. An ancestor pin spells that same + // self-bind, so where it sits is what tells the two apart: pins are + // spliced in before the write root's own bind, which makes the tree + // writable again, and a deny bind is emitted after it. const command = await wrap('true') const writeRootBind = indexOfMount( command, @@ -2944,26 +3024,24 @@ describe('Git metadata deny paths - Unit Tests', () => { checkout, ) expect(writeRootBind).toBeGreaterThan(-1) - expect( - lastIndexOfMount(command, '--ro-bind', bound, bound), - ).toBeLessThan(writeRootBind) - expect(command).toContain(join(bound, 'modules')) - expect(command).toContain(`--ro-bind ${join(bound, 'hooks')} `) + expect(lastIndexOfMount(command, '--ro-bind', deep, deep)).toBeLessThan( + writeRootBind, + ) + expect(command).toContain(`--ro-bind ${join(deep, 'hooks')} `) // Where bwrap can run, prove it: what git needs writable in that // submodule still is, and what makes a write into code is not. if (bwrapCanNamespace()) { const result = spawnSync( await wrap( - `sh -c 'echo o > ${join(bound, 'objects', 'x')} && ` + - `! echo h > ${join(bound, 'hooks', 'x')} && ` + - `! echo c > ${join(bound, 'config')} && ` + - `! mkdir -p ${join(bound, 'modules', 'sub')} && ` + - `echo SRT_BOUND_OK'`, + `sh -c 'echo o > ${join(deep, 'objects', 'x')} && ` + + `! echo h > ${join(deep, 'hooks', 'x')} && ` + + `! echo c > ${join(deep, 'config')} && ` + + `echo SRT_DEEP_OK'`, ), { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, ) - expect(result.stdout).toContain('SRT_BOUND_OK') + expect(result.stdout).toContain('SRT_DEEP_OK') } } finally { cleanupBwrapMountPoints({ force: true }) @@ -2973,15 +3051,15 @@ describe('Git metadata deny paths - Unit Tests', () => { 60000, ) - it('denies the bound git directory by pattern on macOS, without contradiction', () => { + it('denies a deeply nested git directory by literal path on macOS', () => { const checkout = join(dir, 'repo') const gitDir = makeGitDir(join(checkout, '.git')) - const bound = join( + const deep = join( gitDir, 'modules', - ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ...Array.from({ length: 12 }, () => 'x'), ) - makeGitDir(bound) + makeGitDir(deep) const originalCwd = process.cwd() process.chdir(checkout) @@ -2993,16 +3071,16 @@ describe('Git metadata deny paths - Unit Tests', () => { readConfig: undefined, writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - expect(profile).toContain(`(subpath "${join(bound, 'modules')}")`) - expect(profile).toContain(`(subpath "${join(bound, 'hooks')}")`) + expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) // No deny covering the git directory whole, which would take its // objects, refs and index with it. - expect(profile).not.toContain(`(subpath "${bound}")`) + expect(profile).not.toContain(`(subpath "${deep}")`) } finally { process.chdir(originalCwd) } }) }) + /** * Denying a path that is not there means mounting something at it, and two of * these the host's git reads back: it refuses to run at all against a From 09461f7a8f095ef9a8ee5e7ed946d3aefdd9ed84 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 22:05:56 +0000 Subject: [PATCH 027/141] Collapse the submodule denies rather than refuse a repository that has too many bubblewrap parses at most 9000 words and takes three of them for each mount, and a submodule git directory costs five: four denies and an ancestor pin. Past 594 of them every command in the repository was refused up front with too_many_arguments. The ceiling is bubblewrap's and not this library's to lift, but refusing the repository is the wrong answer to it: organisations with thousands of submodules exist, and what they get today is no sandbox at all. So the denies degrade instead, fail-closed and only as far as they have to. A budget counted in mounts is spent across the wrap, precise denies while they fit; the submodule git directories that do not fit are denied whole, one read-only bind each, which costs git writes inside those submodules and nothing outside them; and where not even one bind each fits, the enclosing modules directory is denied whole. Which submodules keep their precise denies is settled by sorted order, so two wraps of one tree agree and the second command in it is sandboxed exactly like the first. The nested repositories the scan finds are sorted before they take what is left, because ripgrep lists a tree in whatever order its threads finish it in. The working directory's own repository is served first, which is what lets the violation monitor - it sees that half and no more, and makes a budget of its own - collapse exactly where the wrap did, so the write it reports is the one bubblewrap refused. Measured here: 559 submodules still keep every precise deny; 700 keep 524 of them; 2000 keep 199 and the profile builds in 349 ms against 215 ms at 594; 10000 go behind one bind of .git/modules. A whole-directory deny has fewer destinations to pin ancestors from, and the one it does have is the git directory itself, so renaming .git/modules/ out from under the deny still fails EBUSY. too_many_arguments stays for a profile that still does not fit, which now takes something other than submodules to cause. --- README.md | 9 +- src/sandbox/linux-sandbox-utils.ts | 79 +++++- src/sandbox/mandatory-deny-paths.ts | 161 ++++++++++- test/sandbox/mandatory-deny-paths.test.ts | 318 +++++++++++++++++++++- 4 files changed, 546 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5fe81234b..2caa2a346 100644 --- a/README.md +++ b/README.md @@ -698,7 +698,7 @@ Filesystem restrictions are enforced at the OS level: - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. -- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. +- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. What a repository's submodules cost no longer reaches that: past what the profile has room for they are denied whole instead — see **Which git directories are covered**. - Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. ### Mandatory Deny Paths (Auto-Protected Files) @@ -734,8 +734,13 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list and an entry it cannot stat, and for both of them what is denied is the deepest ancestor the walk can reach, which may be `.git/modules` itself. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — the same budget the `ripgrep` scan gets on Linux, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed`) rather than sandboxing on the submodules the walk did reach. +- **Past what bubblewrap takes arguments for, the submodule denies are collapsed (Linux).** A profile takes three of bubblewrap's 9000 words per mount, and a submodule git directory costs five of them: four denies and an ancestor pin. A repository with thousands of submodules therefore asks for more than the profile has room for, and the ceiling is not this library's to lift — so the denies are degraded instead, fail-closed and only as far as they have to be. The working directory's own repository is served first, then the nested repositories the scan found, in sorted order: + 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while they fit; + 2. the submodule git directories that do not fit are denied WHOLE, one read-only bind each — git writes inside those submodules fail read-only, their `objects`, `refs` and `index` with them, and nothing outside them changes. Which ones is settled by sorted order, the last first, so two wraps of one tree collapse the same submodules and a command is sandboxed the same way each time; + 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule under it goes with it. + One warning per wrap says how many were collapsed and at which level. `too_many_arguments` is still there for a profile that does not fit after all of this, which now takes something other than submodules to cause. - On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. -- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. +- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there: a Seatbelt profile is a text file with no cap on how many entries it may carry, so every submodule keeps its precise denies however many there are. **The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 07f1888f3..fd9003ccd 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -31,6 +31,7 @@ import { } from './sandbox-utils.js' import { GitMetadataError, + SubmoduleDenyBudget, SubmoduleWalkBudgetError, gitDirDenyPaths, gitDirTreeDenyPaths, @@ -638,7 +639,7 @@ function denyScanFailed( */ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, - options: { deadline?: number } = {}, + options: { budget?: SubmoduleDenyBudget; deadline?: number } = {}, ): string[] { const cwd = process.cwd() const denyPaths = cwdDangerousDenyPaths(cwd) @@ -655,7 +656,17 @@ export function linuxGetCwdMandatoryDenyPaths( if (!isAbsenceErrno(err)) return [...denyPaths, dotGitPath] } if (dotGitStat?.isDirectory()) { - denyPaths.push(...gitDirTreeDenyPaths(dotGitPath, allowGitConfig, options)) + denyPaths.push( + ...gitDirTreeDenyPaths(dotGitPath, allowGitConfig, { + // A caller with no budget of its own gets a whole one, which is what + // makes the violation monitor collapse exactly where the wrap did: + // both spend it on this directory first. + budget: + options.budget ?? + new SubmoduleDenyBudget(SUBMODULE_DENY_MOUNT_BUDGET), + deadline: options.deadline, + }), + ) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. @@ -690,6 +701,8 @@ function cwdDangerousDenyPaths(cwd: string): string[] { */ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { try { + // No budget of its own: linuxGetCwdMandatoryDenyPaths makes a whole one, + // which is the same one the wrap spends on this directory first. return linuxGetCwdMandatoryDenyPaths(allowGitConfig) } catch (err) { const cwd = process.cwd() @@ -764,22 +777,27 @@ async function linuxGetMandatoryDenyPaths( const signal = abortSignal ?? fallbackController.signal const dangerousDirectories = getDangerousDirectories() + // Spent on the working directory's own submodules first and on the nested + // repositories the scan finds with what is left, so that the violation + // monitor - which sees only the working directory's half and makes a budget + // of its own - collapses exactly where this does. + const budget = new SubmoduleDenyBudget(SUBMODULE_DENY_MOUNT_BUDGET) const denyPaths = asProfileRefusal(() => - linuxGetCwdMandatoryDenyPaths(allowGitConfig, { deadline }), + linuxGetCwdMandatoryDenyPaths(allowGitConfig, { budget, deadline }), ) // Each nested repository the scan finds, once: the same walk the cwd's own // git directory already had above, which every file listed under it leads - // back to. + // back to. Collected rather than denied as they are met, because ripgrep + // lists a tree in whatever order its threads finish it in and the budget + // above is spent in the order it is asked: two wraps of one tree would + // otherwise collapse different repositories. const seenGitDirs = new Set([path.resolve(cwd, '.git')]) + const nestedGitDirs: string[] = [] const denyGitDir = (gitDir: string): void => { if (seenGitDirs.has(gitDir)) return seenGitDirs.add(gitDir) - denyPaths.push( - ...asProfileRefusal(() => - gitDirTreeDenyPaths(gitDir, allowGitConfig, { deadline }), - ), - ) + nestedGitDirs.push(gitDir) } const walkScan = (collectGitDirs: boolean) => walkScanDirectories(cwd, maxDepth, deadline, collectGitDirs) @@ -932,6 +950,20 @@ async function linuxGetMandatoryDenyPaths( for (const gitDir of walkScan(true).gitDirs) denyGitDir(gitDir) } + for (const gitDir of nestedGitDirs.sort()) { + denyPaths.push( + ...asProfileRefusal(() => + gitDirTreeDenyPaths(gitDir, allowGitConfig, { budget, deadline }), + ), + ) + } + if (budget.collapsed) { + logForDebugging( + `[Sandbox Linux] ${cwd} has more submodules than bubblewrap takes arguments for, so ${budget.describeCollapse()}`, + { level: 'warn' }, + ) + } + return [...new Set(denyPaths)] } @@ -1207,6 +1239,27 @@ const ARG_HEADROOM_BYTES = 4096 /** bwrap's cap on parsed words, the command line and `--args` file together. */ const BWRAP_MAX_ARGS = 9000 +/** Words bubblewrap takes for one mount: the option and its two paths. */ +const BWRAP_WORDS_PER_MOUNT = 3 + +/** + * Mounts kept back from the submodule denies for everything else in the + * profile: the allow binds, the read denies and what they restore, the + * working directory's own denies, the nested repositories the scan finds and + * the mount sources pinned at the end. A wrap of a repository with one + * submodule and the default configuration emits a couple of dozen; the rest + * is room to spare, because a profile that overruns it is a refused command. + */ +const PROFILE_MOUNT_HEADROOM = 200 + +/** + * What one wrap's submodule denies may take before they are collapsed. The + * ceiling is bubblewrap's own, so this is where it is turned into the mount + * count {@link SubmoduleDenyBudget} is spent in. + */ +const SUBMODULE_DENY_MOUNT_BUDGET = + Math.floor(BWRAP_MAX_ARGS / BWRAP_WORDS_PER_MOUNT) - PROFILE_MOUNT_HEADROOM + /** * The fd the `--args` file is opened on: a single digit, since dash rejects * multi-digit redirections, and high, since embedders hand the command low @@ -1304,7 +1357,13 @@ function errorText(error: unknown): string { /** Why a bubblewrap profile could not be run. */ export type LinuxSandboxProfileErrorCode = - /** More arguments than bubblewrap parses, on the line or through a file. */ + /** + * More arguments than bubblewrap parses, on the line or through a file. + * What a repository's submodules cost no longer reaches this: past what + * the profile has room for they are denied whole instead (see + * `SubmoduleDenyBudget`), so something else in the configuration is what + * gets a profile here now. + */ | 'too_many_arguments' /** A mount path holds a NUL byte, which no carrier of arguments can hold. */ | 'nul_in_path' diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 99b088a45..5fa5b5562 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -124,6 +124,91 @@ export interface SubmoduleScan { unreadableDirs: string[] } +/** + * What a wrap has left for its submodule denies, and what it has collapsed to + * stay inside that. + * + * bubblewrap parses a bounded number of words, the command line and an + * `--args` file together, and takes three of them for each mount: the option + * and its two paths. A repository with thousands of submodules asks for more + * mounts than that on its own — four denies and an ancestor pin per submodule + * git directory — and the ceiling is not this library's to lift. Refusing + * every command in such a repository is the wrong answer to it, so the denies + * are degraded instead, fail-closed and only as far as they have to be: + * + * 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while + * they fit; + * 2. the submodule git directories that do not fit are denied WHOLE, one + * read-only bind each. git writes inside those submodules fail read-only; + * nothing else does. Which ones is settled by sorted order, the last + * first, so two wraps of the same tree agree; + * 3. where even that does not fit, the enclosing `modules` directory is + * denied whole and everything under it goes with it. + * + * A budget is spent across the whole wrap, working directory first, which is + * what lets the violation monitor - which sees only the working directory's + * half and makes its own budget - collapse exactly where the wrap did. + * macOS passes none: Seatbelt's profile has no such cap, so nothing there is + * collapsed. + */ +export class SubmoduleDenyBudget { + private mountsLeft: number + /** Submodule git directories left with their precise denies. */ + preciseGitDirs = 0 + /** Submodule git directories denied whole instead. */ + wholeGitDirs = 0 + /** `modules` directories denied whole, taking every submodule under each. */ + readonly wholeModulesDirs: string[] = [] + + constructor(mounts: number) { + this.mountsLeft = mounts + } + + /** Whether `mounts` are still there, without taking them. */ + fits(mounts: number): boolean { + return mounts <= this.mountsLeft + } + + /** + * Takes `mounts` whether they are there or not. For what a wrap must have + * however little is left: one bind over a whole `modules` directory is the + * least the denies under it can cost, and giving that up would leave a + * tree of writable hooks. Overspending here is what `too_many_arguments` + * is still there to catch. + */ + spend(mounts: number): void { + this.mountsLeft -= mounts + } + + /** Takes `mounts` where they are there, and says whether they were. */ + take(mounts: number): boolean { + if (!this.fits(mounts)) return false + this.mountsLeft -= mounts + return true + } + + /** Whether anything was collapsed, and so whether the wrap says so. */ + get collapsed(): boolean { + return this.wholeGitDirs > 0 || this.wholeModulesDirs.length > 0 + } + + /** One line naming what was collapsed and at which level. */ + describeCollapse(): string { + const parts: string[] = [] + if (this.wholeGitDirs > 0) { + parts.push( + `${this.wholeGitDirs} of ${this.wholeGitDirs + this.preciseGitDirs} submodule git directories are denied whole rather than by path (git writes inside those submodules fail read-only; the other ${this.preciseGitDirs} keep their precise denies)`, + ) + } + if (this.wholeModulesDirs.length > 0) { + parts.push( + `${this.wholeModulesDirs.join(', ')} ${this.wholeModulesDirs.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${this.wholeModulesDirs.length === 1 ? 'it' : 'them'}`, + ) + } + return parts.join('; ') + } +} + /** * The files inside a git directory that send git to a git directory or a * config other than the one it opened, and what must stand in for one where @@ -315,24 +400,84 @@ export function gitFileDenyPaths( * walk could not see through. Both backends produce their entries from this * one list. * - * `deadline` bounds the walk (see {@link submoduleGitDirs}). + * `deadline` bounds the walk (see {@link submoduleGitDirs}). `budget` bounds + * how many mounts the submodule denies may take, and is what degrades them + * where a repository has more submodules than the backend can carry mounts + * for (see {@link SubmoduleDenyBudget}); it is spent as it goes, so a caller + * that passes one to several git directories gets the first served first. */ export function gitDirTreeDenyPaths( gitDir: string, allowGitConfig: boolean, - options: { deadline?: number } = {}, + options: { budget?: SubmoduleDenyBudget; deadline?: number } = {}, ): string[] { - const modules = submoduleGitDirs( - path.join(gitDir, 'modules'), - options.deadline, - ) + const modulesDir = path.join(gitDir, 'modules') + const modules = submoduleGitDirs(modulesDir, options.deadline) return [ ...gitDirDenyPaths(gitDir, allowGitConfig), - ...modules.unreadableDirs, - ...modules.gitDirs.flatMap(dir => gitDirDenyPaths(dir, allowGitConfig)), + ...submoduleDenyPaths(modulesDir, modules, allowGitConfig, options.budget), ] } +/** + * What the submodule git directories under `modulesDir` cost and what they + * are denied with, inside what `budget` has left. No budget means no ceiling + * to stay under and every one of them keeps its precise denies, which is the + * macOS answer and what the Linux wrap does until a repository is large + * enough to need otherwise. See {@link SubmoduleDenyBudget} for the order. + */ +function submoduleDenyPaths( + modulesDir: string, + modules: SubmoduleScan, + allowGitConfig: boolean, + budget: SubmoduleDenyBudget | undefined, +): string[] { + const precise = (gitDir: string): string[] => + gitDirDenyPaths(gitDir, allowGitConfig) + if (budget === undefined) { + return [...modules.unreadableDirs, ...modules.gitDirs.flatMap(precise)] + } + + // A directory the walk could not see through is already a whole-directory + // deny and cannot be degraded further, so it is counted with the git + // directories at their cheapest form. One more mount for the `modules` + // directory itself, whose ancestor pin every deny under it shares. + const wholeMounts = modules.unreadableDirs.length + modules.gitDirs.length + 1 + if (!budget.fits(wholeMounts)) { + // Even one bind each is past what is left: the whole tree goes behind one, + // the directories the walk could not see through included — except one + // recorded ABOVE `modules`, which is what the walk falls back to when + // `modules` itself stopped being reachable, and which this would not + // cover. + budget.spend(1) + budget.wholeModulesDirs.push(modulesDir) + const above = modules.unreadableDirs.filter( + dir => dir !== modulesDir && !dir.startsWith(`${modulesDir}${path.sep}`), + ) + budget.spend(above.length) + return [modulesDir, ...above] + } + + // Every one of them fits whole. Spend what is left upgrading them to their + // precise denies, in sorted order, so that the ones collapsed are the last + // — the deepest nested submodules among them — and two wraps agree. An + // upgrade costs one mount per deny path, the whole-directory bind it + // replaces paying for the git directory's own ancestor pin. + budget.spend(wholeMounts) + const denyPaths: string[] = [...modules.unreadableDirs] + for (const gitDir of modules.gitDirs) { + const denies = precise(gitDir) + if (budget.take(denies.length)) { + budget.preciseGitDirs += 1 + denyPaths.push(...denies) + continue + } + budget.wholeGitDirs += 1 + denyPaths.push(gitDir) + } + return denyPaths +} + /** * Git directories of the submodules under `modulesDir` (a repository's * .git/modules), nested submodules included, sorted. A submodule's name is diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 465f19b81..3940f47e3 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -44,6 +44,7 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { GitMetadataError, + SubmoduleDenyBudget, SubmoduleWalkBudgetError, gitDirDenyPaths, gitDirTreeDenyPaths, @@ -3073,7 +3074,8 @@ describe('Git metadata deny paths - Unit Tests', () => { }) expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) // No deny covering the git directory whole, which would take its - // objects, refs and index with it. + // objects, refs and index with it. Seatbelt has no argument cap, so + // nothing is ever collapsed there. expect(profile).not.toContain(`(subpath "${deep}")`) } finally { process.chdir(originalCwd) @@ -3081,6 +3083,320 @@ describe('Git metadata deny paths - Unit Tests', () => { }) }) +/** + * bubblewrap parses at most 9000 words and takes three for each mount, so a + * repository with thousands of submodules asks for more than it can be given. + * The ceiling is not this library's to lift; refusing every command in such a + * repository is the wrong answer to it, so the denies are degraded instead - + * precise while they fit, then a whole git directory at a time, then the + * `modules` directory whole. Seatbelt has no such cap, so none of this + * applies on macOS. + */ +describe.if(isSupportedPlatform)( + 'Submodule denies past the argument cap', + () => { + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-submodule-budget-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir + } + + /** A repository whose .git/modules holds `count` submodule git directories, + * named so that sorted order is the order they were made in. */ + function makeSuperproject(count: number): { + checkout: string + subs: string[] + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < count; i++) { + subs.push( + makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(5, '0')}`)), + ) + } + return { checkout, subs } + } + + it('keeps the precise denies that fit and denies the rest of the submodules whole', () => { + const { checkout, subs } = makeSuperproject(5) + const gitDir = join(checkout, '.git') + // One mount for every submodule at its cheapest, one for the `modules` + // directory's own ancestor pin, and enough left over to upgrade two of + // them to their four precise denies. + const budget = new SubmoduleDenyBudget(5 + 1 + 2 * 4) + + const denies = gitDirTreeDenyPaths(gitDir, false, { budget }) + + expect(budget.preciseGitDirs).toBe(2) + expect(budget.wholeGitDirs).toBe(3) + expect(budget.wholeModulesDirs).toEqual([]) + expect(budget.collapsed).toBe(true) + for (const sub of subs.slice(0, 2)) { + expect(denies).toContain(join(sub, 'hooks')) + expect(denies).toContain(join(sub, 'config')) + expect(denies).toContain(join(sub, 'commondir')) + expect(denies).toContain(join(sub, 'config.worktree')) + expect(denies).not.toContain(sub) + } + // The last in sorted order are the ones collapsed, so two wraps of one + // tree collapse the same submodules. + for (const sub of subs.slice(2)) { + expect(denies).toContain(sub) + expect(denies).not.toContain(join(sub, 'hooks')) + } + // The repository's own denies are never collapsed: it is one git + // directory however many submodules it has. + expect(denies).toContain(join(gitDir, 'hooks')) + }) + + it('denies the modules directory whole where not even one bind each fits', () => { + const { checkout, subs } = makeSuperproject(5) + const gitDir = join(checkout, '.git') + const modules = join(gitDir, 'modules') + const budget = new SubmoduleDenyBudget(5) + + const denies = gitDirTreeDenyPaths(gitDir, false, { budget }) + + expect(budget.wholeModulesDirs).toEqual([modules]) + expect(budget.preciseGitDirs).toBe(0) + expect(budget.wholeGitDirs).toBe(0) + expect(denies).toContain(modules) + for (const sub of subs) expect(denies).not.toContain(sub) + expect(denies).toContain(join(gitDir, 'hooks')) + }) + + it('serves the repositories that ask first, which is what a nested one gets', () => { + // One budget is spent across the whole wrap: the working directory's own + // repository first and the nested repositories the scan found after it, + // so it is the last of them that collapses. + const first = makeGitDir(join(dir, 'first', '.git')) + const second = makeGitDir(join(dir, 'second', '.git')) + for (const gitDir of [first, second]) { + for (let i = 0; i < 3; i++) makeGitDir(join(gitDir, 'modules', `s${i}`)) + } + // Room for the first repository's three submodules precisely, and for + // one bind each of the second's. + const budget = new SubmoduleDenyBudget(3 + 1 + 3 * 4 + 3 + 1) + + gitDirTreeDenyPaths(first, false, { budget }) + expect(budget.preciseGitDirs).toBe(3) + expect(budget.wholeGitDirs).toBe(0) + + const denies = gitDirTreeDenyPaths(second, false, { budget }) + expect(budget.preciseGitDirs).toBe(3) + expect(budget.wholeGitDirs).toBe(3) + expect(denies).toContain(join(second, 'modules', 's2')) + }) + + it('leaves every submodule its precise denies where no budget is given', () => { + // Which is the macOS answer: its profile has no cap to stay under. + const { checkout, subs } = makeSuperproject(5) + + const denies = gitDirTreeDenyPaths(join(checkout, '.git'), false) + + for (const sub of subs) { + expect(denies).toContain(join(sub, 'hooks')) + expect(denies).not.toContain(sub) + } + }) + + it('says what it collapsed, once, naming the level', () => { + const budget = new SubmoduleDenyBudget(0) + expect(budget.collapsed).toBe(false) + budget.preciseGitDirs = 300 + budget.wholeGitDirs = 1700 + budget.wholeModulesDirs.push('/repo/.git/modules') + + expect(budget.collapsed).toBe(true) + expect(budget.describeCollapse()).toContain('1700 of 2000') + expect(budget.describeCollapse()).toContain('/repo/.git/modules') + }) + + it.if(isLinux)( + 'wraps a command in a repository with 2000 submodules', + async () => { + const { checkout, subs } = makeSuperproject(2000) + process.chdir(checkout) + const wrap = (): Promise => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + // Before this it was refused up front with too_many_arguments, and + // every command in the repository with it. + const command = await wrap() + expect(command).toContain('bwrap') + + // The mounts are past what one command line carries at this size, so + // they are in the argument file bwrap reads - which is where its own + // cap on parsed words is spent. Under it, with room to spare for the + // handful still on the line. + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + expect(profile).toBeDefined() + const words = readFileSync(profile as string, 'utf8').split('\0') + words.pop() + expect(words.length).toBeLessThan(9000) + + // The first submodules in sorted order keep their precise denies; the + // rest are denied whole, one read-only bind of the git directory each. + // (A git directory bound whole is spelled like its own ancestor pin, so + // what tells the two apart is whether the hooks have a deny of their + // own.) + const denied = new Set(words) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + expect(precise.length).toBeGreaterThan(0) + expect(precise.length).toBeLessThan(subs.length) + expect(precise).toEqual(subs.slice(0, precise.length)) + for (const sub of subs.slice(precise.length)) + expect(denied.has(sub)).toBe(true) + const last = subs[subs.length - 1] as string + expect(denied.has(last)).toBe(true) + expect(denied.has(join(last, 'hooks'))).toBe(false) + + // Two wraps of one tree agree, so a second command in it is sandboxed + // exactly as the first was. + const again = await wrap() + const profileAgain = /\/proc\/\d+\/fd\/\d+/.exec(again)?.[0] + expect(readFileSync(profileAgain as string, 'utf8')).toBe( + readFileSync(profile as string, 'utf8'), + ) + + // The violation monitor judges a refused write against the working + // directory's own denies, which it works out for itself: it has to + // collapse where the wrap collapsed or it would report the wrong path. + const monitorDenies = new Set(linuxGetMonitorCwdDenyPaths(false)) + expect(monitorDenies.has(join(subs[0] as string, 'hooks'))).toBe(true) + expect(monitorDenies.has(last)).toBe(true) + + // Seatbelt has no such cap: every submodule keeps its precise denies + // there, whatever this repository costs bubblewrap. Read off the + // entries rather than the profile text, which folds same-shaped + // literals into alternation regexes at this size. + const macEntries = new Set( + macGetMandatoryDenyEntries(false).map(entry => entry.path), + ) + for (const sub of [subs[0] as string, last]) { + expect(macEntries.has(join(sub, 'hooks'))).toBe(true) + expect(macEntries.has(sub)).toBe(false) + } + }, + 300000, + ) + + it.if(isLinux)( + 'holds a collapsed submodule read-only while the rest of the tree works', + async () => { + const git = Bun.which('git') + if (!bwrapCanNamespace() || git === null) return + + // Seven hundred real submodule git directories, made here rather than + // cloned: past what precise denies fit in, so the tail is collapsed. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + writeFileSync(join(checkout, 'index.js'), 'console.log(1)\n') + run('-C', checkout, 'add', 'index.js') + run( + '-C', + checkout, + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '-q', + '-m', + 'one', + ) + const subs: string[] = [] + for (let i = 0; i < 700; i++) { + subs.push( + makeGitDir( + join( + checkout, + '.git', + 'modules', + `s${String(i).padStart(5, '0')}`, + ), + ), + ) + } + process.chdir(checkout) + + const collapsed = subs[subs.length - 1] as string + const precise = subs[0] as string + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(collapsed, 'hooks', 'pre-commit')} || echo COLLAPSED_HOOK_WRITABLE; ` + + `! echo x > ${join(precise, 'hooks', 'pre-commit')} || echo PRECISE_HOOK_WRITABLE; ` + + // A rename of the collapsed git directory itself is what the + // ancestor pins are there to stop: the deny would otherwise move + // with it and a fresh directory take its place. + `! mv ${collapsed} ${collapsed}.moved || echo COLLAPSED_RENAMABLE; ` + + `echo ok > ${join(checkout, 'index.js')} || echo PROJECT_FILE_READONLY; ` + + `${git} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('COLLAPSED_HOOK_WRITABLE') + expect(result.stdout).not.toContain('PRECISE_HOOK_WRITABLE') + expect(result.stdout).not.toContain('COLLAPSED_RENAMABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + + // And the host's git still works in the superproject afterwards. + cleanupBwrapMountPoints({ force: true }) + const status = spawnSync(git, ['status', '--porcelain'], { + cwd: checkout, + encoding: 'utf8', + timeout: 60000, + }) + expect(status.status).toBe(0) + }, + 300000, + ) + }, +) + /** * Denying a path that is not there means mounting something at it, and two of * these the host's git reads back: it refuses to run at all against a From a024b94c1fdc7dd8b91be6417592827c0069cc1b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 18 Sep 2026 22:27:54 +0000 Subject: [PATCH 028/141] Tests: size the two walk cases to the platform rather than to the clock The chain the walk is measured on was a hundred nested submodule git directories, which is a thousand bytes of path and past what macOS opens at all: both macOS legs failed on ENAMETOOLONG. How many levels it builds now comes from what the temporary directory leaves of the platform's longest path, and the case asserts it got enough of them to be far past the ten the walk used to stop at. The overrun case gave the wrap a one-millisecond budget over a tree of one submodule, which the walk can finish inside - and then the ripgrep scan finished inside it too, and the wrap succeeded. One run in six. The tree it walks is four hundred submodules now, which that budget cannot cover on any machine, and the refusal has to name the modules directory the walk gave up in. --- test/sandbox/mandatory-deny-paths.test.ts | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 3940f47e3..c8d7f964c 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -2882,14 +2882,20 @@ describe('Git metadata deny paths - Unit Tests', () => { * is no depth bound: what bounds the walk is the time it is given, and what * stops it looping is where each directory really is. */ - it('walks a chain 200 directories deep, and denies every level of it', () => { - // A hundred submodules each nested inside the one above, which is two - // hundred directories of `/modules` below .git/modules — far past - // any bound the walk used to have, and enough of its own stack to - // overflow a recursive one on some runtimes. + it('walks a chain hundreds of directories deep, and denies every level', () => { + // Submodules each nested inside the one above, two directories of + // `/modules` per level — far past any bound the walk used to have, + // and enough of its own stack to overflow a recursive one on some + // runtimes. How many levels fit is what the platform's longest path + // leaves room for once the temporary directory has had its share: macOS + // stops at 1024 bytes, and a path past it cannot be opened at all. + const budget = getPlatform() === 'macos' ? 950 : 4000 + const levels = Math.floor((budget - dir.length) / '/x/modules'.length) + expect(levels).toBeGreaterThan(50) + const gitDirs: string[] = [] let at = join(dir, 'modules') - for (let level = 0; level < 100; level++) { + for (let level = 0; level < levels; level++) { at = join(at, 'x') makeGitDir(at) gitDirs.push(at) @@ -2961,7 +2967,12 @@ describe('Git metadata deny paths - Unit Tests', () => { async () => { const checkout = join(dir, 'repo') const gitDir = makeGitDir(join(checkout, '.git')) - makeGitDir(join(gitDir, 'modules', 'lib')) + // Enough submodules that the walk cannot get through them inside the + // millisecond below, whatever the machine: it is the walk that must + // run out here, not the scan, which runs after it. + for (let i = 0; i < 400; i++) { + makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(4, '0')}`)) + } const originalCwd = process.cwd() process.chdir(checkout) @@ -2972,7 +2983,7 @@ describe('Git metadata deny paths - Unit Tests', () => { readConfig: undefined, writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, // The walk shares the scan's budget, so one millisecond of it is - // gone before the walk starts. + // all the walk gets. ripgrepConfig: { command: 'rg', timeoutMs: 1 }, }).catch((e: unknown) => e) @@ -2980,6 +2991,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect((error as LinuxSandboxProfileError).code).toBe( 'deny_scan_failed', ) + expect((error as Error).message).toContain(join(gitDir, 'modules')) } finally { cleanupBwrapMountPoints({ force: true }) process.chdir(originalCwd) From b44e67da209434bab95b626d0c9b1c7cd7ebb089 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 10:10:48 +0000 Subject: [PATCH 029/141] Degrade the submodule denies by what the profile costs, not by a model of it The wrap asked a counter in the backend-neutral producer how much its submodule denies could take: five mounts per submodule git directory, which is exact only for a flat `.git/modules/` with nothing checked out. A checked-out submodule's `.git` pointer file and the pin of the directory holding it were never in that, so a few hundred of those reached `too_many_arguments` while the counter reported that everything had fit; an extra name segment and a nested `modules` level were not in it either. The counter also charged for mounts the profile never paid, held mutable state shared across the wrap, and went negative once the last arm was taken. The producer now returns every precise deny and the structure to fold them by: which paths belong to which submodule git directory, and which `modules` directory encloses them. The Linux backend builds the profile, counts the words bubblewrap will parse, and while that is past the cap degrades one step and builds again, so what is given up is measured rather than predicted. The levels are the ones already described: precise denies, then a submodule git directory whole, then the `modules` directory whole. A count of the deny paths inside the write allowlist says where the passes start, so that a repository with ten thousand submodules is not built out in full first; it is a floor, and the measurement is what settles on a level. A `.git/modules` denied whole no longer takes an entry the walk reached through a symlink whose real path is outside it: a bind over `modules` does not cover one, and it keeps the deny it had. A repository with nothing under its `modules` has nothing to degrade and nothing denied, so no /dev/null is planted where a `.git/modules` would go. The violation monitor reads the same function over the same inputs with nothing degraded. It is computed once for the session, so a command that forces a degrade later leaves its report naming the precise path inside a submodule git directory the wrap has by then denied whole - a write refused either way. The walk answers differently in three places: what it could not see through is a set, an entry that is a symlink is denied where it LEADS rather than by the directory holding it, and one that is a symlink loop is denied nothing at all, since nothing can be reached through it. It looks at the clock as the entries go by rather than once per directory, lists each directory once, and a walk made after the ripgrep scan takes a budget of its own of the same length rather than what a slow scan left of the scan's. --- src/sandbox/linux-deny-collapse.ts | 243 +++++++++++++++++++ src/sandbox/linux-sandbox-utils.ts | 303 +++++++++++++++++------- src/sandbox/macos-sandbox-utils.ts | 5 +- src/sandbox/mandatory-deny-paths.ts | 350 ++++++++++++---------------- 4 files changed, 614 insertions(+), 287 deletions(-) create mode 100644 src/sandbox/linux-deny-collapse.ts diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts new file mode 100644 index 000000000..0ca3c8b9a --- /dev/null +++ b/src/sandbox/linux-deny-collapse.ts @@ -0,0 +1,243 @@ +import * as fs from 'fs' +import type { GitDirTreeDenies } from './mandatory-deny-paths.js' +import { isAtOrUnder } from './sandbox-utils.js' + +/** + * The submodule git directories of one repository, and the `.git/modules` + * directory they live under. Built from {@link GitDirTreeDenies}; the deny + * paths are the ones the plan's list already carries, so collapsing one of + * these drops them from wherever they came from — a checked-out submodule's + * `.git` pointer names the same git directory as the walk does, and the + * pointer's copy of its denies is the same string. + */ +export interface RepositorySubmodules { + modulesDir: string + /** Sorted by path, which is the order a collapse works back through. */ + gitDirs: Array<{ gitDir: string; denyPaths: string[] }> + /** Directories the walk could not see through: whole-directory denies. */ + unreadableDirs: string[] +} + +/** What one wrap denies, and what of that can be degraded. */ +export interface SubmoduleDenyPlan { + /** Every deny path the wrap found, in the order it found them. */ + denyPaths: string[] + /** + * One per repository whose `.git/modules` the wrap walked and found + * something under, in the order they were served: the working directory's + * own repository first, then the nested ones the scan found, sorted. A + * repository with nothing under its `modules` is not here at all, so an + * absent one is never denied and never costs anything. + */ + repositories: RepositorySubmodules[] +} + +/** + * How far the submodule denies have been degraded, counted from the END of + * the order above, so that the repositories served first keep their precise + * denies longest and two wraps of one tree degrade the same entries. + */ +export interface CollapseLevel { + /** Submodule git directories denied whole, one read-only bind each. */ + wholeGitDirs: number + /** `.git/modules` directories denied whole, taking what is under them. */ + wholeModulesDirs: number +} + +/** Nothing degraded: every submodule keeps its precise denies. */ +export const NO_COLLAPSE: CollapseLevel = { + wholeGitDirs: 0, + wholeModulesDirs: 0, +} + +/** The submodule denies of one repository, or undefined where it has none. */ +export function repositorySubmodules( + denies: GitDirTreeDenies, +): RepositorySubmodules | undefined { + if (denies.submodules.length === 0 && denies.unreadableDirs.length === 0) { + return undefined + } + return { + modulesDir: denies.modulesDir, + gitDirs: denies.submodules, + unreadableDirs: denies.unreadableDirs, + } +} + +/** + * The deny paths of `plan` at `level`. The wrap and the violation monitor + * both go through this, so what the monitor judges a refused write against is + * what the wrap enforced at the level it passes. + * + * A degraded submodule git directory's own deny paths are dropped from the + * list wherever they sit and one read-only bind of the git directory is added + * in their place; a `.git/modules` denied whole takes everything under it the + * same way. A `.git` pointer file's own deny is never one of them: it names a + * file, and a file has nothing to collapse into. + */ +export function collapsedDenyPaths( + plan: SubmoduleDenyPlan, + level: CollapseLevel, +): string[] { + if (level.wholeGitDirs === 0 && level.wholeModulesDirs === 0) { + return plan.denyPaths + } + const order = collapsibleGitDirs(plan) + const wholeGitDirs = new Set( + order.slice(order.length - level.wholeGitDirs).map(entry => entry.gitDir), + ) + const wholeModulesDirs = new Set( + plan.repositories + .slice(plan.repositories.length - level.wholeModulesDirs) + .map(repository => repository.modulesDir), + ) + + // Paths a bind above them now covers, and the binds that cover them. The + // binds go at the end: the deny loop's passes are order-independent, so + // where a path sits in the list is nothing to preserve, while leaving the + // rest of the list alone is what makes a level that degrades nothing + // exactly the list the wrap found. + const covered = new Set() + const wholeBinds: string[] = [] + for (const repository of plan.repositories) { + const wholeModules = wholeModulesDirs.has(repository.modulesDir) + // A bind of `modules` covers the paths that are really under it, and only + // those: an entry the walk reached through a symlink can have its real + // path anywhere, and it keeps the deny it already has. + const coveredByModules = wholeModules + ? underDirectory(repository.modulesDir) + : () => false + if (wholeModules) wholeBinds.push(repository.modulesDir) + for (const submodule of repository.gitDirs) { + if (coveredByModules(submodule.gitDir)) { + for (const denyPath of submodule.denyPaths) covered.add(denyPath) + continue + } + if (!wholeGitDirs.has(submodule.gitDir)) continue + for (const denyPath of submodule.denyPaths) covered.add(denyPath) + wholeBinds.push(submodule.gitDir) + } + for (const unreadableDir of repository.unreadableDirs) { + if (coveredByModules(unreadableDir)) covered.add(unreadableDir) + } + } + return [ + ...plan.denyPaths.filter(denyPath => !covered.has(denyPath)), + ...wholeBinds, + ] +} + +/** + * `level` degraded far enough to give back `mountsOver` mounts, or undefined + * where there is nothing left to degrade — which is where the profile is + * refused with `too_many_arguments`. + * + * How much each step gives back is what the caller measures; the arithmetic + * here only decides how many entries one step takes, so that a repository + * with thousands of submodules is not rebuilt once per submodule. A + * submodule git directory gives back one mount per deny path it loses, plus + * the ancestor pin of the git directory itself, which the bind that replaces + * them is. + */ +export function collapseFurther( + plan: SubmoduleDenyPlan, + level: CollapseLevel, + mountsOver: number, +): CollapseLevel | undefined { + const order = collapsibleGitDirs(plan) + if (level.wholeGitDirs < order.length) { + const taken = stepBack( + order.length - level.wholeGitDirs, + mountsOver, + at => order[at]?.denyPaths.length ?? 0, + ) + return { ...level, wholeGitDirs: level.wholeGitDirs + taken } + } + if (level.wholeModulesDirs < plan.repositories.length) { + const taken = stepBack( + plan.repositories.length - level.wholeModulesDirs, + mountsOver, + // Each submodule under it is one bind by now, and the one bind of + // `modules` replaces the lot. + at => (plan.repositories[at]?.gitDirs.length ?? 1) - 1, + ) + return { ...level, wholeModulesDirs: level.wholeModulesDirs + taken } + } + return undefined +} + +/** How many entries before `from` it takes for `gives` to reach `mounts`, at + * least one: a step that degraded nothing would loop for ever. */ +function stepBack( + from: number, + mounts: number, + gives: (at: number) => number, +): number { + let given = 0 + let taken = 0 + for (let at = from - 1; at >= 0 && given < mounts; at--) { + given += gives(at) + taken += 1 + } + return Math.max(taken, 1) +} + +/** One line naming what `level` degraded, for the wrap's warning. */ +export function describeCollapse( + plan: SubmoduleDenyPlan, + level: CollapseLevel, +): string { + const order = collapsibleGitDirs(plan) + const parts: string[] = [] + if (level.wholeGitDirs > 0) { + const collapsed = order.slice(order.length - level.wholeGitDirs) + const repositories = [ + ...new Set(collapsed.map(entry => entry.modulesDir)), + ].join(', ') + parts.push( + `${level.wholeGitDirs} of the ${order.length} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, + ) + } + if (level.wholeModulesDirs > 0) { + const collapsed = plan.repositories + .slice(plan.repositories.length - level.wholeModulesDirs) + .map(repository => repository.modulesDir) + parts.push( + `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${collapsed.length === 1 ? 'it' : 'them'}`, + ) + } + return parts.join('; ') +} + +/** Every submodule git directory the plan may degrade, in the order a + * collapse works back through: by repository as served, each repository's + * own sorted. */ +function collapsibleGitDirs(plan: SubmoduleDenyPlan): Array<{ + modulesDir: string + gitDir: string + denyPaths: string[] +}> { + return plan.repositories.flatMap(repository => + repository.gitDirs.map(submodule => ({ + modulesDir: repository.modulesDir, + ...submodule, + })), + ) +} + +/** Whether a path really lies under `dir`, both sides resolved: what a + * read-only bind of `dir` actually covers. */ +function underDirectory(dir: string): (candidate: string) => boolean { + const real = realPathOrSelf(dir) + return candidate => isAtOrUnder(realPathOrSelf(candidate), real) +} + +/** `target` with symlinks resolved, or itself when that fails - which leaves + * a path that cannot be resolved outside the bind, and so denied as it was. */ +function realPathOrSelf(target: string): string { + try { + return fs.realpathSync(target) + } catch { + return target + } +} diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index fd9003ccd..753092963 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -31,13 +31,25 @@ import { } from './sandbox-utils.js' import { GitMetadataError, - SubmoduleDenyBudget, SubmoduleWalkBudgetError, gitDirDenyPaths, + gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenyPaths, gitRedirectPlaceholder, } from './mandatory-deny-paths.js' +import type { + CollapseLevel, + RepositorySubmodules, + SubmoduleDenyPlan, +} from './linux-deny-collapse.js' +import { + NO_COLLAPSE, + collapseFurther, + collapsedDenyPaths, + describeCollapse, + repositorySubmodules, +} from './linux-deny-collapse.js' import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, @@ -639,10 +651,23 @@ function denyScanFailed( */ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, - options: { budget?: SubmoduleDenyBudget; deadline?: number } = {}, ): string[] { + return collapsedDenyPaths( + cwdMandatoryDenyPlan(allowGitConfig, undefined), + NO_COLLAPSE, + ) +} + +/** {@link linuxGetCwdMandatoryDenyPaths} with the working directory's own + * submodules still separable, which is what a wrap whose profile does not + * fit degrades. */ +function cwdMandatoryDenyPlan( + allowGitConfig: boolean, + deadline: number | undefined, +): SubmoduleDenyPlan { const cwd = process.cwd() const denyPaths = cwdDangerousDenyPaths(cwd) + const repositories: RepositorySubmodules[] = [] const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -653,27 +678,22 @@ export function linuxGetCwdMandatoryDenyPaths( // .git would block `git init`. Anything else means a .git is there and // could not be looked at, which is no reason to skip the enumeration — // deny it whole instead. - if (!isAbsenceErrno(err)) return [...denyPaths, dotGitPath] + if (!isAbsenceErrno(err)) { + return { denyPaths: [...denyPaths, dotGitPath], repositories } + } } if (dotGitStat?.isDirectory()) { - denyPaths.push( - ...gitDirTreeDenyPaths(dotGitPath, allowGitConfig, { - // A caller with no budget of its own gets a whole one, which is what - // makes the violation monitor collapse exactly where the wrap did: - // both spend it on this directory first. - budget: - options.budget ?? - new SubmoduleDenyBudget(SUBMODULE_DENY_MOUNT_BUDGET), - deadline: options.deadline, - }), - ) + const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) + denyPaths.push(...gitDirTreeDenyPaths(denies)) + const repository = repositorySubmodules(denies) + if (repository !== undefined) repositories.push(repository) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig)) } - return denyPaths + return { denyPaths, repositories } } /** @@ -693,22 +713,25 @@ function cwdDangerousDenyPaths(cwd: string): string[] { /** * {@link linuxGetCwdMandatoryDenyPaths} for the violation monitor, which is * started once for the session and must not fail over one repository: the - * same paths, or - where a `.git` pointer or a `commondir` names something - * that cannot be resolved the way git resolves it - this directory's plain - * deny paths, with a warning. Every wrap in such a repository still refuses - * its command outright, so what this decides is which refused write the - * monitor reports, never what bubblewrap enforces. + * same paths, or - where the enumeration refuses - this directory's plain + * deny paths, with a warning naming which refusal it was. What this decides + * is which refused write the monitor reports, never what bubblewrap + * enforces. + * + * Nothing is collapsed here. A wrap collapses only where its own profile does + * not fit, which is measured per command, while this is computed once for the + * session: a command that forces a collapse later leaves the monitor naming + * the precise path under a submodule git directory the wrap has by then + * denied whole. The write is refused either way; the path in the report is + * the one inside it. */ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { try { - // No budget of its own: linuxGetCwdMandatoryDenyPaths makes a whole one, - // which is the same one the wrap spends on this directory first. return linuxGetCwdMandatoryDenyPaths(allowGitConfig) } catch (err) { const cwd = process.cwd() - const dotGitPath = path.resolve(cwd, '.git') logForDebugging( - `[Sandbox Linux] Could not resolve ${dotGitPath} the way git does (${errorText(err)}); the violation monitor judges writes against ${cwd}'s plain deny paths instead. Every wrapped command in it is refused until that is fixed.`, + `[Sandbox Linux] ${monitorFallbackReason(cwd, err)}; the violation monitor judges writes against ${cwd}'s plain deny paths instead.`, { level: 'warn' }, ) return [ @@ -718,6 +741,15 @@ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { } } +/** Why the monitor is falling back, in the terms of the thing that failed: + * the two refusals differ in what a wrapped command in this directory gets. */ +function monitorFallbackReason(cwd: string, err: unknown): string { + if (err instanceof SubmoduleWalkBudgetError) { + return `The walk of this repository's submodule git directories ran out of the time it was given (${errorText(err)}), which a wrap retakes from the clock and may well finish` + } + return `Could not resolve ${path.resolve(cwd, '.git')} the way git does (${errorText(err)}). Every wrapped command in this directory is refused until that is fixed` +} + /** * What the monitor treats as denied under `cwd`'s own `.git`, without * following anything: the same three answers the wrap gives for the shape @@ -766,32 +798,34 @@ async function linuxGetMandatoryDenyPaths( maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, -): Promise { +): Promise { const cwd = process.cwd() - // The whole budget, scan and any walk of its own this has to make: a walk - // started because the scan failed spends what the scan left of it. - const deadline = - Date.now() + (ripgrepConfig.timeoutMs ?? DEFAULT_RIPGREP_TIMEOUT_MS) + const timeoutMs = ripgrepConfig.timeoutMs ?? DEFAULT_RIPGREP_TIMEOUT_MS + // What the scan gets, and what the walk of the working directory's own + // submodules that runs before it gets. Every walk made AFTER the scan takes + // a budget of its own of the same length: the scan has its own timeout, and + // a slow but successful one would otherwise refuse a repository whose + // submodules a walk given the time would have got through. + const deadline = Date.now() + timeoutMs + const walkDeadline = (): number => Date.now() + timeoutMs // Use provided signal or create a fallback controller const fallbackController = new AbortController() const signal = abortSignal ?? fallbackController.signal const dangerousDirectories = getDangerousDirectories() - // Spent on the working directory's own submodules first and on the nested - // repositories the scan finds with what is left, so that the violation - // monitor - which sees only the working directory's half and makes a budget - // of its own - collapses exactly where this does. - const budget = new SubmoduleDenyBudget(SUBMODULE_DENY_MOUNT_BUDGET) - const denyPaths = asProfileRefusal(() => - linuxGetCwdMandatoryDenyPaths(allowGitConfig, { budget, deadline }), + // The working directory's own repository is the first the collapse works + // back through, so it is the last to lose its precise denies. + const plan = asProfileRefusal(() => + cwdMandatoryDenyPlan(allowGitConfig, deadline), ) + const denyPaths = plan.denyPaths // Each nested repository the scan finds, once: the same walk the cwd's own // git directory already had above, which every file listed under it leads // back to. Collected rather than denied as they are met, because ripgrep - // lists a tree in whatever order its threads finish it in and the budget - // above is spent in the order it is asked: two wraps of one tree would - // otherwise collapse different repositories. + // lists a tree in whatever order its threads finish it in and a collapse + // works back through them in the order they were served: two wraps of one + // tree would otherwise degrade different repositories. const seenGitDirs = new Set([path.resolve(cwd, '.git')]) const nestedGitDirs: string[] = [] const denyGitDir = (gitDir: string): void => { @@ -800,7 +834,7 @@ async function linuxGetMandatoryDenyPaths( nestedGitDirs.push(gitDir) } const walkScan = (collectGitDirs: boolean) => - walkScanDirectories(cwd, maxDepth, deadline, collectGitDirs) + walkScanDirectories(cwd, maxDepth, walkDeadline(), collectGitDirs) // Build iglob args for all patterns in one ripgrep call const iglobArgs: string[] = [] @@ -951,20 +985,19 @@ async function linuxGetMandatoryDenyPaths( } for (const gitDir of nestedGitDirs.sort()) { - denyPaths.push( - ...asProfileRefusal(() => - gitDirTreeDenyPaths(gitDir, allowGitConfig, { budget, deadline }), - ), - ) - } - if (budget.collapsed) { - logForDebugging( - `[Sandbox Linux] ${cwd} has more submodules than bubblewrap takes arguments for, so ${budget.describeCollapse()}`, - { level: 'warn' }, + const denies = asProfileRefusal(() => + gitDirTreeDenies(gitDir, allowGitConfig, { deadline: walkDeadline() }), ) + denyPaths.push(...gitDirTreeDenyPaths(denies)) + const repository = repositorySubmodules(denies) + if (repository !== undefined) plan.repositories.push(repository) } - return [...new Set(denyPaths)] + // Deduplicated here rather than at emission: a checked-out submodule's + // `.git` pointer names the same git directory the walk of `.git/modules` + // reached, so the two produce the same deny paths, and a collapse of that + // git directory has to leave no copy of them behind. + return { ...plan, denyPaths: [...new Set(denyPaths)] } } /** @@ -1243,22 +1276,15 @@ const BWRAP_MAX_ARGS = 9000 const BWRAP_WORDS_PER_MOUNT = 3 /** - * Mounts kept back from the submodule denies for everything else in the - * profile: the allow binds, the read denies and what they restore, the - * working directory's own denies, the nested repositories the scan finds and - * the mount sources pinned at the end. A wrap of a repository with one - * submodule and the default configuration emits a couple of dozen; the rest - * is room to spare, because a profile that overruns it is a refused command. + * Words the wrap adds AFTER the mounts, which the mounts must leave room for. + * Fifteen today — `--dev /dev`, `--unshare-pid`, `--unshare-user` and at most + * four capability words, `--bind /proc /proc`, `--` with the shell and `-c`, + * and the one word the command itself is — plus the two `--args ` takes + * when the mounts move off the command line, and a little over. Everything + * BEFORE the mounts is counted as it is, not estimated: the wrap has already + * built it when it asks for them. */ -const PROFILE_MOUNT_HEADROOM = 200 - -/** - * What one wrap's submodule denies may take before they are collapsed. The - * ceiling is bubblewrap's own, so this is where it is turned into the mount - * count {@link SubmoduleDenyBudget} is spent in. - */ -const SUBMODULE_DENY_MOUNT_BUDGET = - Math.floor(BWRAP_MAX_ARGS / BWRAP_WORDS_PER_MOUNT) - PROFILE_MOUNT_HEADROOM +const BWRAP_TRAILING_WORDS = 24 /** * The fd the `--args` file is opened on: a single digit, since dash rejects @@ -1359,10 +1385,11 @@ function errorText(error: unknown): string { export type LinuxSandboxProfileErrorCode = /** * More arguments than bubblewrap parses, on the line or through a file. - * What a repository's submodules cost no longer reaches this: past what - * the profile has room for they are denied whole instead (see - * `SubmoduleDenyBudget`), so something else in the configuration is what - * gets a profile here now. + * The submodule denies of a repository are degraded until the profile fits + * (see src/sandbox/linux-deny-collapse.ts), so what reaches this is a + * profile that does not fit with every one of them collapsed: the allow + * binds, the read denies and what they restore, the `denyWrite` entries + * the caller passed and the ancestor pins under them. */ | 'too_many_arguments' /** A mount path holds a NUL byte, which no carrier of arguments can hold. */ @@ -2309,7 +2336,22 @@ function pushReadDenyDirMounts( } /** - * Generate filesystem bind mount arguments for bwrap + * Generate filesystem bind mount arguments for bwrap, inside `wordsAvailable` + * of bubblewrap's cap on parsed words. + * + * The mandatory-deny scan runs once. What it found is then built into mounts, + * counted, and — while the count is past what the caller has room for — + * degraded a step at a time and built again, each pass measuring the profile + * the previous one produced rather than predicting it: the ancestor pins a + * deny needs are known only once the deny list is complete, so the mounts a + * repository's submodules really cost cannot be worked out before this point + * (see src/sandbox/linux-deny-collapse.ts for what a step gives up, and + * `LinuxSandboxProfileErrorCode`'s `too_many_arguments` for what is left when + * there is nothing to degrade). + * + * A pass that is built and then degraded may already have written mount + * points on the host for the denies it dropped; they are recorded like any + * others and go with the same cleanup. */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -2320,7 +2362,101 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, + wordsAvailable: number = Number.POSITIVE_INFINITY, ): Promise { + const plan = + writeConfig === undefined + ? undefined + : await linuxGetMandatoryDenyPaths( + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + ) + let level = + plan === undefined + ? NO_COLLAPSE + : collapseFloor(plan, writeConfig?.allowOnly ?? [], wordsAvailable) + for (;;) { + const args = buildFilesystemArgs( + readConfig, + writeConfig, + maskedFileBinds, + maskedFileStoreDir, + plan === undefined ? [] : collapsedDenyPaths(plan, level), + ) + if (plan === undefined) return args + const degraded = + args.length <= wordsAvailable + ? undefined + : collapseFurther( + plan, + level, + Math.ceil((args.length - wordsAvailable) / BWRAP_WORDS_PER_MOUNT), + ) + if (degraded !== undefined) { + level = degraded + continue + } + // Settled: either it fits, or there is nothing left to degrade and + // renderBwrapInvocation refuses it with too_many_arguments. One warning + // per wrap, naming what the profile it returns gave up. + const told = describeCollapse(plan, level) + if (told !== '') { + logForDebugging( + `[Sandbox Linux] The profile for this command did not fit what bubblewrap parses, so ${told}`, + { level: 'warn' }, + ) + } + return args + } +} + +/** + * Where the passes above start: the level counting alone says the profile + * cannot do without, since every deny path inside the write allowlist takes + * a mount of its own and the ancestor pins beneath each are more on top. + * Without it a repository with ten thousand submodules is built out in full + * before anything is degraded, and a wrapped command can make such a tree: + * what one costs every command after it is not free to leave at whatever it + * comes to. + * + * It is not the answer, and the measurement above is: what it leaves out can + * only mean more collapsing, and the one way it can ask for too much — a + * deny path counted here that another deny turns out to cover — is a mount + * the profile saves rather than one it needs. + */ +function collapseFloor( + plan: SubmoduleDenyPlan, + allowOnly: string[], + wordsAvailable: number, +): CollapseLevel { + const allowedWritePaths = allowOnly.map(p => normalizePathForSandbox(p)) + const mounts = Math.floor(wordsAvailable / BWRAP_WORDS_PER_MOUNT) + let level = NO_COLLAPSE + for (;;) { + const denies = collapsedDenyPaths(plan, level).filter(denyPath => + allowedWritePaths.some(allowedPath => isAtOrUnder(denyPath, allowedPath)), + ).length + if (denies <= mounts) return level + const degraded = collapseFurther(plan, level, denies - mounts) + if (degraded === undefined) return level + level = degraded + } +} + +/** + * The mounts for one deny list: `mandatoryDenyPaths` are the mandatory denies + * as {@link generateFilesystemArgs} has settled them, and the caller's own + * `denyWithinAllow` entries are added to them here. + */ +function buildFilesystemArgs( + readConfig: FsReadRestrictionConfig | undefined, + writeConfig: FsWriteRestrictionConfig | undefined, + maskedFileBinds: Array<{ realPath: string; fakePath: string }> | undefined, + maskedFileStoreDir: string | undefined, + mandatoryDenyPaths: string[], +): string[] { const args: string[] = [] // fs already imported @@ -2369,10 +2505,10 @@ async function generateFilesystemArgs( // other mount sources at the end. Set only where one was actually used. let gitRedirectSourceDir: string | undefined // Where a mount given `p` lands: `p` fully resolved, every symlink on the - // way and not one hop. One resolution per path per wrap, so every predicate - // below sees the same answer, and none before the mandatory-deny scan's - // await: that scan can run arbitrarily long, and a realpath taken ahead of - // it would miss a symlink retargeted meanwhile. + // way and not one hop. One resolution per path per pass, so every predicate + // below sees the same answer, and none before the mandatory-deny scan the + // caller runs first: that scan can run arbitrarily long, and a realpath + // taken ahead of it would miss a symlink retargeted meanwhile. const canonicalFormCache = new Map() // Paths whose canonical location could not be LOOKED AT — a settled errno, // or a transient one that outlived the retry — so the recorded spelling @@ -2599,7 +2735,7 @@ async function generateFilesystemArgs( // `liftedFile` marks a file deny an allowRead entry naming that very file // cancels — the entry mounts nothing in that case either. Lazy and // memoised like readDenyEntries(): it resolves symlinks, so it must not run - // before the mandatory-deny scan's await. + // before the mandatory-deny scan the caller runs first. let readDenyPlanMemo: ReadDenyPlanEntry[] | undefined const readDenyPlan = (): ReadDenyPlanEntry[] => (readDenyPlanMemo ??= readDenyEntries() @@ -2703,9 +2839,9 @@ async function generateFilesystemArgs( // recorded read-only deny dir — commands with no such covering directory // skip the extra stat/realpath/readdir syscalls entirely. Lazy // evaluation also means the derivation runs from inside the deny loop, - // AFTER the (unbounded) mandatory-deny ripgrep await below, keeping the - // snapshot as close as possible to the denyRead loop that later acts on - // the real filesystem. + // AFTER the (unbounded) mandatory-deny ripgrep scan the caller runs + // first, keeping the snapshot as close as possible to the denyRead loop + // that later acts on the real filesystem. type StubSkipVetoInputs = | { /** The derivation held; the arrays below describe this wrap. */ @@ -2798,12 +2934,6 @@ async function generateFilesystemArgs( } return stubSkipVetoInputs } - const mandatoryDenyPaths = await linuxGetMandatoryDenyPaths( - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - ) // Deny writes within allowed paths (user-specified + mandatory denies) const denyPaths = [ ...(writeConfig.denyWithinAllow || []), @@ -3987,6 +4117,9 @@ export async function wrapCommandWithSandboxLinux( mandatoryDenySearchDepth, allowGitConfig, abortSignal, + // What bubblewrap will parse, less the environment and network words + // already built above and the few that come after the mounts. + BWRAP_MAX_ARGS - bwrapArgs.length - BWRAP_TRAILING_WORDS, ) const mountsStart = bwrapArgs.length bwrapArgs.push(...fsArgs) diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4d32dd01f..d498a9851 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -22,6 +22,7 @@ import { } from './sandbox-utils.js' import { gitDirDenyPaths, + gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenyPaths, } from './mandatory-deny-paths.js' @@ -159,7 +160,9 @@ export function macGetMandatoryDenyEntries( } if (dotGitStat?.isDirectory()) { entries.push( - ...gitDirTreeDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ...gitDirTreeDenyPaths(gitDirTreeDenies(dotGit, allowGitConfig)).map( + toLiteralPathEntry, + ), ) } else { // Absent, or a pointer file: the repository's own hooks and config are diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 5fa5b5562..1606aeadc 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -10,9 +10,9 @@ import { /** * The path is absent, as opposed to unreadable or otherwise unverifiable. * Narrower than the shared {@link isAbsenceErrno} by two codes: ELOOP must - * read as unreadable here, so a symlink loop is denied whole rather than - * passed over as nothing, and ENAMETOOLONG has its own answer in - * {@link isUnusablePathError}. + * read as unreadable here, so a `gitdir:` target that is a symlink loop is + * denied whole rather than passed over as nothing, and ENAMETOOLONG has its + * own answer in {@link isUnusablePathError}. */ function isAbsenceError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code @@ -37,14 +37,15 @@ function isUnusablePathError(err: unknown): boolean { */ const MAX_GIT_METADATA_BYTES = 1024 * 1024 +/** Time the `.git/modules` walk gets when the caller sets no deadline. */ +const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS + /** - * Time the `.git/modules` walk gets when the caller sets no deadline of its - * own — the macOS backend and the violation monitor, neither of which runs - * the ripgrep scan whose deadline the Linux wrap shares with it. The same - * figure the scan gets, for the same reason: a tree that takes longer than - * this to walk is one the command about to run could have made. + * How often the walk looks at the clock: once per this many entries, rather + * than once per directory, because one directory can hold as many entries as + * the whole rest of the walk. */ -const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS +const WALK_DEADLINE_CHECK_INTERVAL = 128 /** * Entries whose presence makes a directory a git directory. git needs HEAD @@ -88,14 +89,11 @@ export class GitMetadataError extends Error { } /** - * The `.git/modules` walk ran out of the time it was given. The tree it was - * walking is one the command about to run could have made — a bind mount - * pointed back at its own ancestor gives every level a real path of its own, - * which the visited set cannot fold together — so a half-finished listing is - * not something to sandbox on: the submodule git directories it never reached - * would be left with writable hooks. The Linux wrap turns this into a - * `LinuxSandboxProfileError` carrying `deny_scan_failed`, the same answer the - * ripgrep scan's own overrun gets; on macOS it reaches the caller as itself. + * The `.git/modules` walk ran out of the time it was given, so the submodule + * git directories it never reached would have been left with writable hooks. + * The Linux wrap turns this into a `LinuxSandboxProfileError` carrying + * `deny_scan_failed`; on macOS it reaches the caller as itself. Branch on + * `.code`, not on the message. */ export class SubmoduleWalkBudgetError extends Error { readonly code = 'submodule_walk_budget_exhausted' as const @@ -113,9 +111,10 @@ export interface SubmoduleScan { * Directories the walk could not see through: what lies under them is * unknown, so they are denied whole rather than left writable with a git * directory possibly inside. Two things produce one — a directory the walk - * could not list and an entry it could not stat — and the recorded path is - * the deepest ancestor this process can reach, which can be the - * `.git/modules` root itself. Sorted, like `gitDirs`. + * could not list, and an entry whose target exists and could not be + * inspected — and the recorded path is the deepest ancestor this process can + * reach towards it, which can be the `.git/modules` root itself. Sorted and + * without duplicates, like `gitDirs`. * * A whole-directory deny is read-only for everything beneath it, a * submodule's `objects`, `refs` and `index` included, so git writes inside a @@ -125,88 +124,20 @@ export interface SubmoduleScan { } /** - * What a wrap has left for its submodule denies, and what it has collapsed to - * stay inside that. - * - * bubblewrap parses a bounded number of words, the command line and an - * `--args` file together, and takes three of them for each mount: the option - * and its two paths. A repository with thousands of submodules asks for more - * mounts than that on its own — four denies and an ancestor pin per submodule - * git directory — and the ceiling is not this library's to lift. Refusing - * every command in such a repository is the wrong answer to it, so the denies - * are degraded instead, fail-closed and only as far as they have to be: - * - * 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while - * they fit; - * 2. the submodule git directories that do not fit are denied WHOLE, one - * read-only bind each. git writes inside those submodules fail read-only; - * nothing else does. Which ones is settled by sorted order, the last - * first, so two wraps of the same tree agree; - * 3. where even that does not fit, the enclosing `modules` directory is - * denied whole and everything under it goes with it. - * - * A budget is spent across the whole wrap, working directory first, which is - * what lets the violation monitor - which sees only the working directory's - * half and makes its own budget - collapse exactly where the wrap did. - * macOS passes none: Seatbelt's profile has no such cap, so nothing there is - * collapsed. + * What a git directory's tree is denied by, in the pieces a backend with a + * ceiling on how many mounts it can carry degrades it in (Linux: + * src/sandbox/linux-deny-collapse.ts). Every piece is a precise deny: nothing + * here is collapsed, on either backend. */ -export class SubmoduleDenyBudget { - private mountsLeft: number - /** Submodule git directories left with their precise denies. */ - preciseGitDirs = 0 - /** Submodule git directories denied whole instead. */ - wholeGitDirs = 0 - /** `modules` directories denied whole, taking every submodule under each. */ - readonly wholeModulesDirs: string[] = [] - - constructor(mounts: number) { - this.mountsLeft = mounts - } - - /** Whether `mounts` are still there, without taking them. */ - fits(mounts: number): boolean { - return mounts <= this.mountsLeft - } - - /** - * Takes `mounts` whether they are there or not. For what a wrap must have - * however little is left: one bind over a whole `modules` directory is the - * least the denies under it can cost, and giving that up would leave a - * tree of writable hooks. Overspending here is what `too_many_arguments` - * is still there to catch. - */ - spend(mounts: number): void { - this.mountsLeft -= mounts - } - - /** Takes `mounts` where they are there, and says whether they were. */ - take(mounts: number): boolean { - if (!this.fits(mounts)) return false - this.mountsLeft -= mounts - return true - } - - /** Whether anything was collapsed, and so whether the wrap says so. */ - get collapsed(): boolean { - return this.wholeGitDirs > 0 || this.wholeModulesDirs.length > 0 - } - - /** One line naming what was collapsed and at which level. */ - describeCollapse(): string { - const parts: string[] = [] - if (this.wholeGitDirs > 0) { - parts.push( - `${this.wholeGitDirs} of ${this.wholeGitDirs + this.preciseGitDirs} submodule git directories are denied whole rather than by path (git writes inside those submodules fail read-only; the other ${this.preciseGitDirs} keep their precise denies)`, - ) - } - if (this.wholeModulesDirs.length > 0) { - parts.push( - `${this.wholeModulesDirs.join(', ')} ${this.wholeModulesDirs.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${this.wholeModulesDirs.length === 1 ? 'it' : 'them'}`, - ) - } - return parts.join('; ') - } +export interface GitDirTreeDenies { + /** The git directory's own hooks, config and redirect files. */ + ownDenyPaths: string[] + /** `/modules`, whether or not the walk found anything under it. */ + modulesDir: string + /** One per submodule git directory under `modulesDir`, sorted by path. */ + submodules: Array<{ gitDir: string; denyPaths: string[] }> + /** What the walk could not see through; see {@link SubmoduleScan}. */ + unreadableDirs: string[] } /** @@ -397,85 +328,40 @@ export function gitFileDenyPaths( * Every git directory a deny must cover once `gitDir` is one: its own hooks/ * and config, and the same for each submodule git directory under its * `modules` (what a commit inside that submodule runs), plus whatever the - * walk could not see through. Both backends produce their entries from this - * one list. + * walk could not see through. `deadline` bounds the walk (see + * {@link submoduleGitDirs}). * - * `deadline` bounds the walk (see {@link submoduleGitDirs}). `budget` bounds - * how many mounts the submodule denies may take, and is what degrades them - * where a repository has more submodules than the backend can carry mounts - * for (see {@link SubmoduleDenyBudget}); it is spent as it goes, so a caller - * that passes one to several git directories gets the first served first. + * Kept apart rather than flattened so that a backend which cannot carry every + * mount can degrade the submodule denies and leave the rest alone; + * {@link gitDirTreeDenyPaths} is the flat form both backends emit when + * nothing has to be degraded. */ -export function gitDirTreeDenyPaths( +export function gitDirTreeDenies( gitDir: string, allowGitConfig: boolean, - options: { budget?: SubmoduleDenyBudget; deadline?: number } = {}, -): string[] { + options: { deadline?: number } = {}, +): GitDirTreeDenies { const modulesDir = path.join(gitDir, 'modules') const modules = submoduleGitDirs(modulesDir, options.deadline) - return [ - ...gitDirDenyPaths(gitDir, allowGitConfig), - ...submoduleDenyPaths(modulesDir, modules, allowGitConfig, options.budget), - ] -} - -/** - * What the submodule git directories under `modulesDir` cost and what they - * are denied with, inside what `budget` has left. No budget means no ceiling - * to stay under and every one of them keeps its precise denies, which is the - * macOS answer and what the Linux wrap does until a repository is large - * enough to need otherwise. See {@link SubmoduleDenyBudget} for the order. - */ -function submoduleDenyPaths( - modulesDir: string, - modules: SubmoduleScan, - allowGitConfig: boolean, - budget: SubmoduleDenyBudget | undefined, -): string[] { - const precise = (gitDir: string): string[] => - gitDirDenyPaths(gitDir, allowGitConfig) - if (budget === undefined) { - return [...modules.unreadableDirs, ...modules.gitDirs.flatMap(precise)] - } - - // A directory the walk could not see through is already a whole-directory - // deny and cannot be degraded further, so it is counted with the git - // directories at their cheapest form. One more mount for the `modules` - // directory itself, whose ancestor pin every deny under it shares. - const wholeMounts = modules.unreadableDirs.length + modules.gitDirs.length + 1 - if (!budget.fits(wholeMounts)) { - // Even one bind each is past what is left: the whole tree goes behind one, - // the directories the walk could not see through included — except one - // recorded ABOVE `modules`, which is what the walk falls back to when - // `modules` itself stopped being reachable, and which this would not - // cover. - budget.spend(1) - budget.wholeModulesDirs.push(modulesDir) - const above = modules.unreadableDirs.filter( - dir => dir !== modulesDir && !dir.startsWith(`${modulesDir}${path.sep}`), - ) - budget.spend(above.length) - return [modulesDir, ...above] + return { + ownDenyPaths: gitDirDenyPaths(gitDir, allowGitConfig), + modulesDir, + submodules: modules.gitDirs.map(submodule => ({ + gitDir: submodule, + denyPaths: gitDirDenyPaths(submodule, allowGitConfig), + })), + unreadableDirs: modules.unreadableDirs, } +} - // Every one of them fits whole. Spend what is left upgrading them to their - // precise denies, in sorted order, so that the ones collapsed are the last - // — the deepest nested submodules among them — and two wraps agree. An - // upgrade costs one mount per deny path, the whole-directory bind it - // replaces paying for the git directory's own ancestor pin. - budget.spend(wholeMounts) - const denyPaths: string[] = [...modules.unreadableDirs] - for (const gitDir of modules.gitDirs) { - const denies = precise(gitDir) - if (budget.take(denies.length)) { - budget.preciseGitDirs += 1 - denyPaths.push(...denies) - continue - } - budget.wholeGitDirs += 1 - denyPaths.push(gitDir) - } - return denyPaths +/** {@link gitDirTreeDenies} as one list, which is what a backend with no + * ceiling to stay under emits. */ +export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { + return [ + ...denies.ownDenyPaths, + ...denies.unreadableDirs, + ...denies.submodules.flatMap(submodule => submodule.denyPaths), + ] } /** @@ -499,49 +385,70 @@ export function submoduleGitDirs( modulesDir: string, deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, ): SubmoduleScan { - const scan: SubmoduleScan = { gitDirs: [], unreadableDirs: [] } - // Where the walk still has to look, and where it has already been. The root - // counts as visited: an entry linked straight back to it is then the same - // dead end as one linked to any other directory already walked. - const pending = [modulesDir] + const walk: SubmoduleWalk = { gitDirs: [], unreadableDirs: new Set() } + // Where the walk still has to look, and where it has already been. A + // directory listed to see whether it is a git directory carries its entries + // with it, so that no directory is listed twice. The root counts as visited: + // an entry linked straight back to it is then the same dead end as one + // linked to any other directory already walked. + const pending: Array<{ dir: string; entries?: fs.Dirent[] }> = [ + { dir: modulesDir }, + ] const visited = new Set([realPathOrSelf(modulesDir)]) - while (pending.length > 0) { - const dir = pending.pop() as string - if (Date.now() > deadline) { - throw new SubmoduleWalkBudgetError( - `[Sandbox] The walk of ${modulesDir} ran out of the time it was given with ${pending.length + 1} directories left to look at (at ${dir}); refusing to sandbox on the submodule git directories it did reach`, - ) - } - const entries = listDirectory(dir, scan) + let entriesSeen = 0 + for (let item = pending.pop(); item !== undefined; item = pending.pop()) { + const entries = item.entries ?? listDirectory(item.dir, walk) if (entries === undefined) continue for (const entry of entries) { - const child = path.join(dir, entry.name) + if ( + entriesSeen++ % WALK_DEADLINE_CHECK_INTERVAL === 0 && + Date.now() > deadline + ) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${modulesDir} ran out of the time it was given while listing ${item.dir}, with ${pending.length} directories still to look at; refusing to sandbox on the submodule git directories it did reach`, + ) + } + const child = path.join(item.dir, entry.name) // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. - if (!isDirectory(entry, child, scan)) continue + if (!isDirectory(entry, child, walk)) continue const visitKey = realPathOrSelf(child) if (visited.has(visitKey)) continue visited.add(visitKey) - const childEntries = listDirectory(child, scan) + const childEntries = listDirectory(child, walk) if (childEntries === undefined) continue const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) - if (isGitDir) scan.gitDirs.push(child) + if (isGitDir) walk.gitDirs.push(child) // A git directory keeps its own submodules under `modules`; anything // else is a segment of a submodule name (`vendor` of `vendor/lib`). - pending.push(isGitDir ? path.join(child, 'modules') : child) + pending.push( + isGitDir + ? { dir: path.join(child, 'modules') } + : { dir: child, entries: childEntries }, + ) } } - scan.gitDirs.sort() - scan.unreadableDirs.sort() - return scan + return { + gitDirs: walk.gitDirs.sort(), + unreadableDirs: [...walk.unreadableDirs].sort(), + } +} + +/** What the walk has found so far. The deny paths a directory it could not + * see through produces are a set: one unreadable entry and the next fold to + * the same ancestor, and a repeated path is a mount the profile never pays + * for but would otherwise be counted as one. */ +interface SubmoduleWalk { + gitDirs: string[] + unreadableDirs: Set } /** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ function listDirectory( dir: string, - scan: SubmoduleScan, + walk: SubmoduleWalk, ): fs.Dirent[] | undefined { try { return fs.readdirSync(dir, { withFileTypes: true }) @@ -549,7 +456,7 @@ function listDirectory( // Absent is the common case: no submodules, or none nested in this one. if (!isAbsenceError(err)) { const denied = deepestReachableAncestor(dir) ?? dir - scan.unreadableDirs.push(denied) + walk.unreadableDirs.add(denied) logForDebugging( `[Sandbox] Could not list ${dir}, denying ${denied} whole: ${err}`, { level: 'warn' }, @@ -563,20 +470,54 @@ function listDirectory( function isDirectory( entry: fs.Dirent, entryPath: string, - scan: SubmoduleScan, + walk: SubmoduleWalk, ): boolean { if (entry.isDirectory()) return true if (!entry.isSymbolicLink()) return false try { return fs.statSync(entryPath).isDirectory() } catch (err) { - if (!isAbsenceError(err)) { - scan.unreadableDirs.push(deepestReachableAncestor(entryPath) ?? entryPath) + if (isAbsenceError(err) || isUnusablePathError(err)) return false + if ((err as NodeJS.ErrnoException).code === 'ELOOP') { + // A link that resolves back to itself reaches no directory at all, so + // there is nothing behind it to deny and nothing a command can put + // there without replacing the link, which the next wrap's walk sees. + // The link's own path cannot carry a read-only bind either: bubblewrap + // resolves a mount destination, and resolving this one is what just + // failed. Denying the directory that HOLDS it is the one answer that + // would be worse than none - a single link a command can write would + // take every submodule beside it read-only. + logForDebugging( + `[Sandbox] ${entryPath} is a symlink loop, which leads to nothing to deny: ${err}`, + { level: 'warn' }, + ) + return false } + // Something is there and could not be inspected. Fail closed on the + // deepest directory that can be reached TOWARDS IT rather than on the + // directory holding the link, which would again be every submodule + // beside it. + const denied = + deepestReachableAncestor(linkTarget(entryPath) ?? entryPath) ?? entryPath + walk.unreadableDirs.add(denied) + logForDebugging( + `[Sandbox] Could not follow ${entryPath}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) return false } } +/** Where `link` points, resolved against the directory holding it, or + * undefined when it cannot be read. */ +function linkTarget(link: string): string | undefined { + try { + return path.resolve(path.dirname(link), fs.readlinkSync(link)) + } catch { + return undefined + } +} + /** * Deny paths for a directory a `gitdir:` or `commondir` names. An existing * directory that is not a git directory is left alone: file content must not @@ -808,13 +749,20 @@ function physicalPath(base: string, target: string): string { /** * The deepest ancestor of `target` (itself included) this process can still - * stat. Denying that directory fails closed when the path below it cannot be - * inspected: nothing under it is writable in the sandbox. + * stat as a directory. Denying that directory fails closed when the path + * below it cannot be inspected: nothing under it is writable in the sandbox. + * + * Symlinks are followed, so a link to a directory that cannot be listed is + * answered with the link's own path rather than with the directory holding + * it — the Linux deny loop puts the bind on the real target (see + * `resolveSymlinkedDenyPath`), and answering with the parent would let one + * unreadable directory anyone can plant a link to take every entry beside it + * read-only. */ function deepestReachableAncestor(target: string): string | undefined { for (let dir = target; ; dir = path.dirname(dir)) { try { - if (fs.lstatSync(dir).isDirectory()) return dir + if (fs.statSync(dir).isDirectory()) return dir } catch { // Unreachable at this level; try the parent. } From 73deb062d2e3b52cc1c09ce01d8e21afed544574 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 10:10:48 +0000 Subject: [PATCH 030/141] Tests: the degraded levels, what the walk answers, and the two deadlines A `modules` denied whole keeping the deny of an entry whose real path is outside it, and its hooks unwritable under bubblewrap; a repository of checked-out submodules degrading and then refusing where even that is not enough; multi-segment and nested names under the same pressure; a nested repository taking its first submodule under a wrap that is degrading; two thousand eight hundred symlink loops costing nothing; a symlinked entry denied where it leads, and once; a directory of fifty thousand entries stopped in the middle rather than at the end of it; and a walk made after the scan getting a budget of its own. The live arms are `it.if(...)` rather than an early return that reports a pass having checked nothing, and the deep-chain case is gated off Windows like its neighbours, whose MAX_PATH it was over. The deeply nested git directory now holds a submodule of its own, so that what a git directory's own `modules` is denied by is pinned on both backends. The bracketed `.git/modules` fixture locks a directory instead of planting a symlink loop, which is no longer a whole-directory deny, and gives the mode back before the write it refuses is attempted. --- .../macos-literal-deny-brackets.test.ts | 67 +- test/sandbox/mandatory-deny-paths.test.ts | 709 ++++++++++++++---- 2 files changed, 604 insertions(+), 172 deletions(-) diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index 8e522cec5..16a6d6d65 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, afterAll, beforeAll } from 'bun:test' import { spawnSync } from 'node:child_process' import { + chmodSync, mkdirSync, realpathSync, rmSync, - symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -324,6 +324,7 @@ describe.if(isMacOS)( afterAll(() => { process.chdir(originalCwd) + openModulesTree(tree) rmSync(tree.root, { recursive: true, force: true }) }) @@ -433,12 +434,24 @@ function modulesTree(prefix: string): ModulesTree { writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main\n') writeFileSync(join(work, '.git', 'HEAD'), 'ref: refs/heads/main\n') mkdirSync(unreadable, { recursive: true }) - // A loop the walk cannot stat, so it stops and denies the directory - // holding it whole — the same record an unlistable directory produces. - symlinkSync('loop', join(unreadable, 'loop')) + // A directory the walk cannot list, which is denied whole rather than left + // writable with a git directory possibly inside. Restored by + // `openModulesTree` before the tree is removed - and before a sandboxed + // write into it is what has to be refused. + chmodSync(unreadable, 0o000) return { root, work, submodule, unreadable } } +/** A walk cannot be kept out of a directory it owns as root, so the record + * the two cases below are about is not there for one. */ +const CAN_LOCK_A_DIRECTORY = process.getuid?.() !== 0 + +/** Gives `tree.unreadable` its permissions back, so that what refuses a write + * into it is the sandbox and not the mode, and so that it can be removed. */ +function openModulesTree(tree: ModulesTree): void { + chmodSync(tree.unreadable, 0o755) +} + /** * `/.git` as a pointer file, naming a git directory whose `commondir` * names another. Every directory here is one the file's own contents chose. @@ -495,6 +508,7 @@ describe.if(!isWindows)( afterAll(() => { process.chdir(originalCwd) + openModulesTree(tree) rmSync(tree.root, { recursive: true, force: true }) }) @@ -516,11 +530,14 @@ describe.if(!isWindows)( } }) - it('denies the directory the walk stopped at by subpath', () => { - const text = profile() - expect(text).toContain(`(subpath ${JSON.stringify(tree.unreadable)})`) - expect(text).not.toContain(sniffedFilter(tree.unreadable, '(/.*)?$')) - }) + it.if(CAN_LOCK_A_DIRECTORY)( + 'denies the directory the walk could not list by subpath', + () => { + const text = profile() + expect(text).toContain(`(subpath ${JSON.stringify(tree.unreadable)})`) + expect(text).not.toContain(sniffedFilter(tree.unreadable, '(/.*)?$')) + }, + ) it('leaves no regex carrying a bracket read off the filesystem', () => { // A spelling sniffed as a pattern keeps its brackets verbatim, since @@ -643,12 +660,32 @@ describe.if(isMacOS)( expect(result.stderr.toLowerCase()).toContain('operation not permitted') }) - it('refuses a write in the directory the walk stopped at', () => { - const target = join(tree.unreadable, 'planted') - const result = run(`echo planted > ${JSON.stringify(target)}`) - expect(result.status).not.toBe(0) - expect(result.stderr.toLowerCase()).toContain('operation not permitted') - }) + it.if(CAN_LOCK_A_DIRECTORY)( + 'refuses a write in the directory the walk could not list', + () => { + const target = join(tree.unreadable, 'planted') + // The profile is built while the directory is locked, which is what + // puts the deny in it; the mode is then given back, so that what + // refuses the write is the sandbox. + const command = wrapCommandWithSandboxMacOS({ + command: `echo planted > ${JSON.stringify(target)}`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + openModulesTree(tree) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, LC_ALL: 'C' }, + }) + expect(result.status).not.toBe(0) + expect((result.stderr || '').toLowerCase()).toContain( + 'operation not permitted', + ) + }, + ) }, ) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index c8d7f964c..57e65611b 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -23,7 +23,7 @@ import { realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' import { indexOfMount, @@ -44,14 +44,22 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { GitMetadataError, - SubmoduleDenyBudget, SubmoduleWalkBudgetError, gitDirDenyPaths, + gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenyPaths, gitRedirectPlaceholder, submoduleGitDirs, } from '../../src/sandbox/mandatory-deny-paths.js' +import { + NO_COLLAPSE, + collapseFurther, + collapsedDenyPaths, + describeCollapse, + repositorySubmodules, +} from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' import { quote } from '../../src/utils/shell-quote.js' import type { RipgrepConfig } from '../../src/utils/ripgrep.js' @@ -2878,40 +2886,42 @@ describe('Git metadata deny paths - Unit Tests', () => { /** * A submodule name is a path and submodules nest, so how deep a real tree - * can go is a question about the repository and not about this walk. There - * is no depth bound: what bounds the walk is the time it is given, and what - * stops it looping is where each directory really is. + * can go is a question about the repository and not about this walk: what + * bounds the walk is the time it is given, and what stops it looping is + * where each directory really is. */ - it('walks a chain hundreds of directories deep, and denies every level', () => { - // Submodules each nested inside the one above, two directories of - // `/modules` per level — far past any bound the walk used to have, - // and enough of its own stack to overflow a recursive one on some - // runtimes. How many levels fit is what the platform's longest path - // leaves room for once the temporary directory has had its share: macOS - // stops at 1024 bytes, and a path past it cannot be opened at all. - const budget = getPlatform() === 'macos' ? 950 : 4000 - const levels = Math.floor((budget - dir.length) / '/x/modules'.length) - expect(levels).toBeGreaterThan(50) - - const gitDirs: string[] = [] - let at = join(dir, 'modules') - for (let level = 0; level < levels; level++) { - at = join(at, 'x') - makeGitDir(at) - gitDirs.push(at) - at = join(at, 'modules') - } + it.if(!isWindows)( + 'walks a chain hundreds of directories deep, and denies every level', + () => { + // Submodules each nested inside the one above, two directories of + // `/modules` per level: deep enough to overflow a recursive walk's + // own stack on some runtimes. How many levels fit is what the platform's + // longest path leaves room for once the temporary directory has had its + // share - macOS stops at 1024 bytes, and a path past it cannot be opened + // at all, while Windows stops at 260 and is left out here. + const budget = getPlatform() === 'macos' ? 950 : 4000 + const levels = Math.floor((budget - dir.length) / '/x/modules'.length) + expect(levels).toBeGreaterThan(50) + + const gitDirs: string[] = [] + let at = join(dir, 'modules') + for (let level = 0; level < levels; level++) { + at = join(at, 'x') + makeGitDir(at) + gitDirs.push(at) + at = join(at, 'modules') + } - const scan = submoduleGitDirs(join(dir, 'modules')) - expect(scan.unreadableDirs).toEqual([]) - expect(scan.gitDirs).toEqual([...gitDirs].sort()) - // Every level's hooks and config, not just the ones above a bound. - const denies = gitDirTreeDenyPaths(dir, false) - for (const gitDir of gitDirs) { - expect(denies).toContain(join(gitDir, 'hooks')) - expect(denies).toContain(join(gitDir, 'config')) - } - }) + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.unreadableDirs).toEqual([]) + expect(scan.gitDirs).toEqual([...gitDirs].sort()) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const gitDir of gitDirs) { + expect(denies).toContain(join(gitDir, 'hooks')) + expect(denies).toContain(join(gitDir, 'config')) + } + }, + ) it.if(!isWindows)('ends at a symlink pointing back into the tree', () => { // modules/a/back -> modules, which the walk would follow for ever if it @@ -2926,19 +2936,66 @@ describe('Git metadata deny paths - Unit Tests', () => { }) }) - it.if(!isWindows)('ends at a symlink loop, denying it whole', () => { - // A loop cannot be stat'ed at all (ELOOP), which reads as unreadable - // rather than as absent: the deepest directory the walk can reach for it - // is denied whole. - mkdirSync(join(dir, 'modules'), { recursive: true }) - symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) - symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) + it.if(!isWindows)( + 'denies nothing for a symlink loop, which leads nowhere', + () => { + // A loop cannot be stat'ed at all (ELOOP) and reaches no directory, so + // there is nothing behind it to deny - and denying what HOLDS it would + // hand every command a way to take every submodule beside it read-only. + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) + symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + }) + }, + ) - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ - gitDirs: [], - unreadableDirs: [join(dir, 'modules'), join(dir, 'modules')], - }) - }) + it.if(!isWindows)( + 'costs one command nothing to plant thousands of loops', + () => { + mkdirSync(join(dir, 'modules'), { recursive: true }) + for (let i = 0; i < 2800; i++) { + symlinkSync(`s${i}`, join(dir, 'modules', `s${i}`)) + } + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + }) + }, + 30000, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'denies where a symlinked entry leads, not the directory holding it, and once', + () => { + // Two links into a directory this process cannot search. What is behind + // them is unknown, so the deny is the deepest directory that can be + // reached towards them - the locked directory itself - and not the + // `modules` that holds the links, which would take every submodule + // beside them. One entry, not three: the same path is reached by the + // listing of `locked` and by each of the two links. + const locked = join(dir, 'modules', 'locked') + const sibling = makeGitDir(join(dir, 'modules', 'lib')) + mkdirSync(locked, { recursive: true }) + symlinkSync(join(locked, 'one'), join(dir, 'modules', 'a')) + symlinkSync(join(locked, 'two'), join(dir, 'modules', 'b')) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [sibling], + unreadableDirs: [locked], + }) + } finally { + chmodSync(locked, 0o755) + } + }, + ) it('refuses rather than hand back a walk that ran out of its time', () => { makeGitDir(join(dir, 'modules', 'a', 'lib')) @@ -2957,11 +3014,30 @@ describe('Git metadata deny paths - Unit Tests', () => { return err as SubmoduleWalkBudgetError } })() - expect(error?.name).toBe('SubmoduleWalkBudgetError') expect(error?.code).toBe('submodule_walk_budget_exhausted') expect(error?.message).toContain(join(dir, 'modules')) }) + it('stops inside one very wide directory rather than at the end of it', () => { + // The deadline is looked at as the entries go by, not once per + // directory: a `.git/modules` a command filled with fifty thousand + // entries is one directory, and none of these is one to walk into, so a + // walk that only checked the clock when it took a directory off its + // stack would work through every one of them before looking again. + const modules = join(dir, 'modules') + mkdirSync(modules, { recursive: true }) + writeFileSync(join(dir, 'target'), 'x') + for (let i = 0; i < 50000; i++) { + symlinkSync(join(dir, 'target'), join(modules, `s${i}`)) + } + + const started = Date.now() + expect(() => submoduleGitDirs(modules, Date.now() + 50)).toThrow( + SubmoduleWalkBudgetError, + ) + expect(Date.now() - started).toBeLessThan(2000) + }, 120000) + it.if(isLinux)( 'refuses the wrap where the modules walk runs out of its time', async () => { @@ -3000,6 +3076,46 @@ describe('Git metadata deny paths - Unit Tests', () => { 30000, ) + it.if(isLinux)( + 'gives a walk made after the scan a budget of its own', + async () => { + // The scan has a timeout of its own and may use all of it. The walk of + // a nested repository the scan found runs after that and takes its own + // budget from the clock then: what a slow scan left would refuse a + // repository whose submodules a walk given the time gets through in + // microseconds. One millisecond, which no scan can even be started + // inside, is enough to tell the two apart. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const nestedGitDir = makeGitDir(join(checkout, 'nested', '.git')) + const nestedSub = makeGitDir(join(nestedGitDir, 'modules', 'lib')) + // A stand-in for ripgrep that reports the nested repository and exits. + const scan = join(dir, 'scan.sh') + writeFileSync( + scan, + `#!/bin/sh\nprintf '%s\\0' ${JSON.stringify(join(nestedGitDir, 'config'))}\n`, + { mode: 0o755 }, + ) + + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: scan, timeoutMs: 1 }, + }) + expect(command).toContain(join(nestedSub, 'hooks')) + } finally { + cleanupBwrapMountPoints({ force: true }) + process.chdir(originalCwd) + } + }, + 60000, + ) + it.if(isLinux)( 'keeps a deeply nested git directory writable except for its hooks and config', async () => { @@ -3012,6 +3128,9 @@ describe('Git metadata deny paths - Unit Tests', () => { ) makeGitDir(deep) mkdirSync(join(deep, 'objects'), { recursive: true }) + // And one more level under it, so that what the deny of a git + // directory's own submodules covers is pinned as well. + const deeper = makeGitDir(join(deep, 'modules', 'lib')) const originalCwd = process.cwd() process.chdir(checkout) @@ -3041,6 +3160,19 @@ describe('Git metadata deny paths - Unit Tests', () => { writeRootBind, ) expect(command).toContain(`--ro-bind ${join(deep, 'hooks')} `) + // Its own submodule is denied by path too, and neither it nor the + // `modules` directory holding it is denied whole: a bind there would + // take that submodule's objects, refs and index with it. Only the + // ancestor pins spell those, and they sit before the write root's + // own bind. + expect(command).toContain(`--ro-bind ${join(deeper, 'hooks')} `) + expect( + lastIndexOfMount(command, '--ro-bind', deeper, deeper), + ).toBeLessThan(writeRootBind) + const modules = join(deep, 'modules') + expect( + lastIndexOfMount(command, '--ro-bind', modules, modules), + ).toBeLessThan(writeRootBind) // Where bwrap can run, prove it: what git needs writable in that // submodule still is, and what makes a write into code is not. @@ -3050,6 +3182,7 @@ describe('Git metadata deny paths - Unit Tests', () => { `sh -c 'echo o > ${join(deep, 'objects', 'x')} && ` + `! echo h > ${join(deep, 'hooks', 'x')} && ` + `! echo c > ${join(deep, 'config')} && ` + + `! echo h > ${join(deeper, 'hooks', 'x')} && ` + `echo SRT_DEEP_OK'`, ), { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, @@ -3073,6 +3206,7 @@ describe('Git metadata deny paths - Unit Tests', () => { ...Array.from({ length: 12 }, () => 'x'), ) makeGitDir(deep) + const deeper = makeGitDir(join(deep, 'modules', 'lib')) const originalCwd = process.cwd() process.chdir(checkout) @@ -3085,10 +3219,12 @@ describe('Git metadata deny paths - Unit Tests', () => { writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) - // No deny covering the git directory whole, which would take its + expect(profile).toContain(`(subpath "${join(deeper, 'hooks')}")`) + // No deny covering either git directory whole, which would take its // objects, refs and index with it. Seatbelt has no argument cap, so // nothing is ever collapsed there. expect(profile).not.toContain(`(subpath "${deep}")`) + expect(profile).not.toContain(`(subpath "${deeper}")`) } finally { process.chdir(originalCwd) } @@ -3097,16 +3233,18 @@ describe('Git metadata deny paths - Unit Tests', () => { /** * bubblewrap parses at most 9000 words and takes three for each mount, so a - * repository with thousands of submodules asks for more than it can be given. + * repository with enough submodules asks for more than the profile can carry. * The ceiling is not this library's to lift; refusing every command in such a * repository is the wrong answer to it, so the denies are degraded instead - - * precise while they fit, then a whole git directory at a time, then the - * `modules` directory whole. Seatbelt has no such cap, so none of this - * applies on macOS. + * precise while the profile fits, then a whole git directory at a time, then + * the `modules` directory whole. What decides is the profile the wrap built, + * so the wraps below are Linux's; Seatbelt has no cap of the kind. */ describe.if(isSupportedPlatform)( 'Submodule denies past the argument cap', () => { + /** Everything the live arms need: a sandbox that starts, and a git. */ + const LIVE = isLinux && bwrapCanNamespace() && Bun.which('git') !== null let dir: string const savedCwd = process.cwd() @@ -3127,116 +3265,163 @@ describe.if(isSupportedPlatform)( return gitDir } - /** A repository whose .git/modules holds `count` submodule git directories, - * named so that sorted order is the order they were made in. */ - function makeSuperproject(count: number): { - checkout: string - subs: string[] - } { + /** A repository whose .git/modules holds `count` submodule git + * directories, named so that sorted order is the order they were made + * in. `checkouts` gives each one the `.git` pointer file a checked-out + * submodule has, which costs a bind and a pin no collapse can save. */ + function makeSuperproject( + count: number, + options: { checkouts?: boolean } = {}, + ): { checkout: string; subs: string[] } { const checkout = join(dir, 'repo') const gitDir = makeGitDir(join(checkout, '.git')) const subs: string[] = [] for (let i = 0; i < count; i++) { - subs.push( - makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(5, '0')}`)), + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', name))) + if (options.checkouts !== true) continue + mkdirSync(join(checkout, name), { recursive: true }) + writeFileSync( + join(checkout, name, '.git'), + `gitdir: ../.git/modules/${name}\n`, ) } return { checkout, subs } } - it('keeps the precise denies that fit and denies the rest of the submodules whole', () => { - const { checkout, subs } = makeSuperproject(5) - const gitDir = join(checkout, '.git') - // One mount for every submodule at its cheapest, one for the `modules` - // directory's own ancestor pin, and enough left over to upgrade two of - // them to their four precise denies. - const budget = new SubmoduleDenyBudget(5 + 1 + 2 * 4) - - const denies = gitDirTreeDenyPaths(gitDir, false, { budget }) - - expect(budget.preciseGitDirs).toBe(2) - expect(budget.wholeGitDirs).toBe(3) - expect(budget.wholeModulesDirs).toEqual([]) - expect(budget.collapsed).toBe(true) - for (const sub of subs.slice(0, 2)) { - expect(denies).toContain(join(sub, 'hooks')) - expect(denies).toContain(join(sub, 'config')) - expect(denies).toContain(join(sub, 'commondir')) - expect(denies).toContain(join(sub, 'config.worktree')) - expect(denies).not.toContain(sub) - } - // The last in sorted order are the ones collapsed, so two wraps of one - // tree collapse the same submodules. - for (const sub of subs.slice(2)) { - expect(denies).toContain(sub) - expect(denies).not.toContain(join(sub, 'hooks')) + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } + + describe('what a degraded level denies', () => { + /** A plan as the wrap builds one: the deny paths in the order they were + * found, and the submodule git directories they can be folded into. */ + function planFor( + modulesDir: string, + gitDirNames: string[], + extra: string[] = [], + ): SubmoduleDenyPlan { + const gitDirs = gitDirNames.map(name => { + const gitDir = join(modulesDir, name) + return { + gitDir, + denyPaths: [ + join(gitDir, 'hooks'), + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ], + } + }) + return { + denyPaths: [...extra, ...gitDirs.flatMap(sub => sub.denyPaths)], + repositories: [{ modulesDir, gitDirs, unreadableDirs: [] }], + } } - // The repository's own denies are never collapsed: it is one git - // directory however many submodules it has. - expect(denies).toContain(join(gitDir, 'hooks')) - }) - it('denies the modules directory whole where not even one bind each fits', () => { - const { checkout, subs } = makeSuperproject(5) - const gitDir = join(checkout, '.git') - const modules = join(gitDir, 'modules') - const budget = new SubmoduleDenyBudget(5) + it('leaves the list exactly as it was where nothing is degraded', () => { + const plan = planFor(join(dir, '.git', 'modules'), ['a', 'b']) + expect(collapsedDenyPaths(plan, NO_COLLAPSE)).toEqual(plan.denyPaths) + }) - const denies = gitDirTreeDenyPaths(gitDir, false, { budget }) + it('folds a degraded git directory in wherever its deny paths came from', () => { + // A checked-out submodule's `.git` pointer names the same git + // directory the walk of `.git/modules` reached, so the pointer's own + // deny sits in the list beside denies that are the git directory's: + // collapsing it has to take those and leave the pointer, which names + // a file and has nothing to collapse into. + const modules = join(dir, '.git', 'modules') + const pointer = join(dir, 'b', '.git') + const plan = planFor(modules, ['a', 'b'], [pointer]) + + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) - expect(budget.wholeModulesDirs).toEqual([modules]) - expect(budget.preciseGitDirs).toBe(0) - expect(budget.wholeGitDirs).toBe(0) - expect(denies).toContain(modules) - for (const sub of subs) expect(denies).not.toContain(sub) - expect(denies).toContain(join(gitDir, 'hooks')) - }) + expect(denies).toContain(pointer) + expect(denies).toContain(join(modules, 'a', 'hooks')) + // The last in sorted order is the one degraded, so two wraps of one + // tree degrade the same submodules. + expect(denies).toContain(join(modules, 'b')) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + expect(denies).not.toContain(join(modules, 'b', 'config')) + }) - it('serves the repositories that ask first, which is what a nested one gets', () => { - // One budget is spent across the whole wrap: the working directory's own - // repository first and the nested repositories the scan found after it, - // so it is the last of them that collapses. - const first = makeGitDir(join(dir, 'first', '.git')) - const second = makeGitDir(join(dir, 'second', '.git')) - for (const gitDir of [first, second]) { - for (let i = 0; i < 3; i++) makeGitDir(join(gitDir, 'modules', `s${i}`)) - } - // Room for the first repository's three submodules precisely, and for - // one bind each of the second's. - const budget = new SubmoduleDenyBudget(3 + 1 + 3 * 4 + 3 + 1) - - gitDirTreeDenyPaths(first, false, { budget }) - expect(budget.preciseGitDirs).toBe(3) - expect(budget.wholeGitDirs).toBe(0) - - const denies = gitDirTreeDenyPaths(second, false, { budget }) - expect(budget.preciseGitDirs).toBe(3) - expect(budget.wholeGitDirs).toBe(3) - expect(denies).toContain(join(second, 'modules', 's2')) - }) + it('runs out, which is what too_many_arguments is left for', () => { + const plan = planFor(join(dir, '.git', 'modules'), ['a', 'b']) + const everything = { wholeGitDirs: 2, wholeModulesDirs: 1 } + expect(collapseFurther(plan, NO_COLLAPSE, 1)).toEqual({ + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + expect(collapseFurther(plan, everything, 1)).toBeUndefined() + }) - it('leaves every submodule its precise denies where no budget is given', () => { - // Which is the macOS answer: its profile has no cap to stay under. - const { checkout, subs } = makeSuperproject(5) + it('names the repositories it degraded, and counts every level', () => { + const modules = join(dir, '.git', 'modules') + const plan = planFor(modules, ['a', 'b']) - const denies = gitDirTreeDenyPaths(join(checkout, '.git'), false) + expect(describeCollapse(plan, NO_COLLAPSE)).toBe('') + const told = describeCollapse(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) + expect(told).toContain('2 of the 2 submodule git directories') + expect(told).toContain(modules) + }) - for (const sub of subs) { - expect(denies).toContain(join(sub, 'hooks')) - expect(denies).not.toContain(sub) - } - }) + it('has nothing to degrade, and nothing to deny, for a repository with no submodules', () => { + // An absent `.git/modules` is not a directory to deny: a /dev/null + // mount point planted at one stops `git submodule add` working in + // that repository, and is left behind by a killed wrap. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const denies = gitDirTreeDenies(gitDir, false) + + expect(repositorySubmodules(denies)).toBeUndefined() + expect(gitDirTreeDenyPaths(denies)).not.toContain( + join(gitDir, 'modules'), + ) + }) - it('says what it collapsed, once, naming the level', () => { - const budget = new SubmoduleDenyBudget(0) - expect(budget.collapsed).toBe(false) - budget.preciseGitDirs = 300 - budget.wholeGitDirs = 1700 - budget.wholeModulesDirs.push('/repo/.git/modules') + it.if(!isWindows)( + 'keeps the deny of an entry whose real path is outside a modules directory denied whole', + () => { + // A read-only bind of `modules` covers what is really under it. An + // entry the walk reached through a symlink can be anywhere, and + // leaving it to that bind would leave its hooks writable. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const inside = makeGitDir(join(gitDir, 'modules', 'a')) + const outside = makeGitDir(join(dir, 'elsewhere')) + const link = join(gitDir, 'modules', 'b') + symlinkSync(outside, link) + + const repository = repositorySubmodules( + gitDirTreeDenies(gitDir, false), + ) + expect(repository).toBeDefined() + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirTreeDenyPaths(gitDirTreeDenies(gitDir, false)), + repositories: repository === undefined ? [] : [repository], + } + + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) - expect(budget.collapsed).toBe(true) - expect(budget.describeCollapse()).toContain('1700 of 2000') - expect(budget.describeCollapse()).toContain('/repo/.git/modules') + expect(denies).toContain(join(gitDir, 'modules')) + expect(denies).toContain(link) + expect(denies).not.toContain(inside) + expect(denies).not.toContain(join(inside, 'hooks')) + }, + ) }) it.if(isLinux)( @@ -3252,8 +3437,6 @@ describe.if(isSupportedPlatform)( writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - // Before this it was refused up front with too_many_arguments, and - // every command in the repository with it. const command = await wrap() expect(command).toContain('bwrap') @@ -3261,10 +3444,7 @@ describe.if(isSupportedPlatform)( // they are in the argument file bwrap reads - which is where its own // cap on parsed words is spent. Under it, with room to spare for the // handful still on the line. - const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] - expect(profile).toBeDefined() - const words = readFileSync(profile as string, 'utf8').split('\0') - words.pop() + const words = mountWords(command) expect(words.length).toBeLessThan(9000) // The first submodules in sorted order keep their precise denies; the @@ -3288,15 +3468,20 @@ describe.if(isSupportedPlatform)( const again = await wrap() const profileAgain = /\/proc\/\d+\/fd\/\d+/.exec(again)?.[0] expect(readFileSync(profileAgain as string, 'utf8')).toBe( - readFileSync(profile as string, 'utf8'), + readFileSync( + /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] as string, + 'utf8', + ), ) - // The violation monitor judges a refused write against the working - // directory's own denies, which it works out for itself: it has to - // collapse where the wrap collapsed or it would report the wrong path. + // The violation monitor works the working directory's own denies out + // for itself, once for the session, and degrades none of them: it + // reports the path inside a submodule git directory that a wrap has + // by then denied whole, which is a write refused either way. const monitorDenies = new Set(linuxGetMonitorCwdDenyPaths(false)) expect(monitorDenies.has(join(subs[0] as string, 'hooks'))).toBe(true) - expect(monitorDenies.has(last)).toBe(true) + expect(monitorDenies.has(join(last, 'hooks'))).toBe(true) + expect(monitorDenies.has(last)).toBe(false) // Seatbelt has no such cap: every submodule keeps its precise denies // there, whatever this repository costs bubblewrap. Read off the @@ -3314,11 +3499,90 @@ describe.if(isSupportedPlatform)( ) it.if(isLinux)( - 'holds a collapsed submodule read-only while the rest of the tree works', + 'degrades a repository of checked-out submodules, and refuses where even that is not enough', + async () => { + // A checked-out submodule costs what a bare one does and two mounts + // more that no collapse saves: the `.git` pointer file's own bind and + // the pin of the directory holding it. So this shape, and not a bare + // `.git/modules`, is the one that can still reach the cap. + const wrap = (checkout: string): Promise => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + for (const count of [300, 700]) { + const { checkout, subs } = makeSuperproject(count, { + checkouts: true, + }) + process.chdir(checkout) + const words = mountWords(await wrap(checkout)) + expect(words.length).toBeLessThan(9000) + // Every pointer file is denied whatever was degraded under it. + const denied = new Set(words) + for (const sub of subs) { + expect(denied.has(join(checkout, basename(sub), '.git'))).toBe(true) + } + process.chdir(savedCwd) + rmSync(checkout, { recursive: true, force: true }) + cleanupBwrapMountPoints({ force: true }) + } + + const { checkout } = makeSuperproject(1600, { checkouts: true }) + process.chdir(checkout) + const error = await wrap(checkout).catch((e: unknown) => e) + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'too_many_arguments', + ) + }, + 600000, + ) + + it.if(isLinux)( + 'degrades multi-segment and nested submodule names like any other', async () => { - const git = Bun.which('git') - if (!bwrapCanNamespace() || git === null) return + // A submodule's name is its path, so one can sit several segments + // down and hold submodules of its own. Each extra segment is another + // directory to pin, which the profile pays for and the model of what + // a submodule costs never saw. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < 700; i++) { + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', `vendor${i}`, name))) + } + let nested = subs[0] as string + for (let level = 0; level < 20; level++) { + nested = makeGitDir(join(nested, 'modules', 'inner')) + subs.push(nested) + } + process.chdir(checkout) + + const words = mountWords( + await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + expect(words.length).toBeLessThan(9000) + const denied = new Set(words) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + expect(precise.length).toBeGreaterThan(0) + expect(precise.length).toBeLessThan(subs.length) + }, + 300000, + ) + it.if(LIVE)( + 'holds a collapsed submodule read-only while the rest of the tree works', + async () => { + const git = Bun.which('git') as string // Seven hundred real submodule git directories, made here rather than // cloned: past what precise denies fit in, so the tail is collapsed. const checkout = join(dir, 'repo') @@ -3406,6 +3670,137 @@ describe.if(isSupportedPlatform)( }, 300000, ) + + it.if(LIVE)( + 'keeps the hooks of a symlinked submodule entry where `modules` is denied whole', + async () => { + // Three thousand submodules is past what one read-only bind each + // fits in, which is the level that puts the whole tree behind a + // single bind over `.git/modules`. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + for (let i = 0; i < 3000; i++) { + makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(5, '0')}`)) + } + const outside = makeGitDir(join(dir, 'elsewhere')) + symlinkSync(outside, join(gitDir, 'modules', 'zz-linked')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(outside, 'hooks', 'pre-commit')} || echo LINKED_HOOK_WRITABLE; ` + + `! echo x > ${join(gitDir, 'modules', 's00000', 'hooks', 'x')} || echo MODULES_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout, outside], denyWithinAllow: [] }, + }) + // The whole tree behind one bind, and the linked entry - which that + // bind does not cover, since the deny lands where the link really + // points - behind one of its own. + const words = mountWords(command) + const mounted = (source: string, dest: string): boolean => + words.some( + (word, at) => + word === '--ro-bind' && + words[at + 1] === source && + words[at + 2] === dest, + ) + expect(mounted(join(gitDir, 'modules'), join(gitDir, 'modules'))).toBe( + true, + ) + expect(mounted(outside, outside)).toBe(true) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('LINKED_HOOK_WRITABLE') + expect(result.stdout).not.toContain('MODULES_WRITABLE') + }, + 300000, + ) + + it.if(LIVE)( + 'lets a nested repository take its first submodule while the wrap is degrading', + async () => { + // Nothing is denied for a repository that has no `.git/modules`, so + // there is no mount point planted where one would go and git can + // still make it - under a wrap degrading the working directory's own + // seven hundred submodules at the same time. + const git = Bun.which('git') as string + const { checkout } = makeSuperproject(703) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + const source = join(dir, 'source') + const nested = join(checkout, 'nested') + for (const repo of [source, nested]) { + mkdirSync(repo, { recursive: true }) + run('-c', 'init.defaultBranch=main', 'init', '-q', repo) + writeFileSync(join(repo, 'f.txt'), 'x\n') + run('-C', repo, 'add', 'f.txt') + run( + '-C', + repo, + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '-q', + '-m', + 'one', + ) + } + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `${git} -c protocol.file.allow=always -c user.name=t -c user.email=t@t ` + + `-C ${nested} submodule add -q ${source} sub || echo SUBMODULE_ADD_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + // The submodule is recorded in the nested repository's config, + // which is denied unless the caller allows git config writes. + allowGitConfig: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout + result.stderr).not.toContain( + 'SUBMODULE_ADD_FAILED', + ) + expect(existsSync(join(nested, '.git', 'modules', 'sub'))).toBe(true) + // And nothing was left on the host where the deny would have been. + cleanupBwrapMountPoints({ force: true }) + expect(statSync(join(nested, '.git', 'modules')).isDirectory()).toBe( + true, + ) + }, + 300000, + ) }, ) From 88ec43a263cf064beba2c8f046227dbc597f7fa4 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 10:10:48 +0000 Subject: [PATCH 031/141] README: what a degrade gives up, and what it takes to reach the cap The submodule paragraph described a collapse driven by a model of what the denies cost. They are degraded against what the profile costs, and the figures are re-measured: about 590 submodule git directories, or about 425 where each is checked out, before anything is given up, and about 1475 checked-out submodules before `too_many_arguments`, which bare ones do not reach at any count. Says what a wrapped command can plant - a wide `.git/modules`, and the refusal a slow one earns - names the macOS form of that refusal beside `GitMetadataError`'s, and states the one limit on what the violation monitor reports. --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2caa2a346..edbf7b3b6 100644 --- a/README.md +++ b/README.md @@ -698,7 +698,7 @@ Filesystem restrictions are enforced at the OS level: - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. -- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. What a repository's submodules cost no longer reaches that: past what the profile has room for they are denied whole instead — see **Which git directories are covered**. +- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. A repository's submodule denies are degraded until the profile fits rather than refused at that point — see **Which git directories are covered**. - Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. ### Mandatory Deny Paths (Auto-Protected Files) @@ -732,13 +732,16 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. -- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list and an entry it cannot stat, and for both of them what is denied is the deepest ancestor the walk can reach, which may be `.git/modules` itself. -- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — the same budget the `ripgrep` scan gets on Linux, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed`) rather than sandboxing on the submodules the walk did reach. -- **Past what bubblewrap takes arguments for, the submodule denies are collapsed (Linux).** A profile takes three of bubblewrap's 9000 words per mount, and a submodule git directory costs five of them: four denies and an ancestor pin. A repository with thousands of submodules therefore asks for more than the profile has room for, and the ceiling is not this library's to lift — so the denies are degraded instead, fail-closed and only as far as they have to be. The working directory's own repository is served first, then the nested repositories the scan found, in sorted order: - 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while they fit; - 2. the submodule git directories that do not fit are denied WHOLE, one read-only bind each — git writes inside those submodules fail read-only, their `objects`, `refs` and `index` with them, and nothing outside them changes. Which ones is settled by sorted order, the last first, so two wraps of one tree collapse the same submodules and a command is sandboxed the same way each time; - 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule under it goes with it. - One warning per wrap says how many were collapsed and at which level. `too_many_arguments` is still there for a profile that does not fit after all of this, which now takes something other than submodules to cause. +- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. +- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. +- **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: + + 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while the profile fits; + 2. a submodule git directory that does not fit is denied WHOLE, one read-only bind — git writes inside that submodule fail read-only, its `objects`, `refs` and `index` with them, and nothing outside it changes. A checked-out submodule's `.git` pointer names the same git directory, so its copy of those denies goes with them; the pointer file keeps its own deny, which names a file and has nothing to collapse into; + 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule really under it goes with it. An entry the walk reached through a symlink can be anywhere, and one whose real path is outside keeps the deny it had: a bind of `modules` does not cover it. + + With one write root and no network restrictions, that starts at about 590 submodule git directories, or about 425 where each is checked out. A `SRT_DEBUG` warning says what was degraded, at which level, and for which repositories; there is no other wrap-time channel to report it on. The Linux violation monitor works the same denies out for itself, once for the session and with nothing degraded, so a command that forces a degrade later leaves it naming the path inside a submodule git directory the wrap has by then denied whole — the write is refused either way, and it is the path in the report that is stale, never what bubblewrap enforces. `too_many_arguments` is what is left for a profile that does not fit with every submodule deny degraded: measured at about 1475 CHECKED-OUT submodules, whose pointer files and pins no degrading saves, and at a few hundred separate nested repositories, whose own four denies are not submodule denies. Bare submodule git directories do not reach it at any count, multi-segment (`vendor/lib`) and nested names included: the one bind over `.git/modules` is where they end. + - On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. - On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there: a Seatbelt profile is a text file with no cap on how many entries it may carry, so every submodule keeps its precise denies however many there are. @@ -749,12 +752,15 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - Where the git directory takes no mount point at all — one this process cannot write, such as a root-owned vendored checkout or a read-only mount — the git directory is bound read-only whole instead. That needs nothing written to the host, costs what was already unwritable, and is also what stops the command from making the directory writable and creating the file itself. - Cleanup is deferred per process, not across processes: two processes wrapping commands in one repository can each claim the same mount point, and the one that cleans up first removes it while the other's sandbox is still running, which detaches that bind and stops that one deny applying inside it. The bind's source is the placeholder in the temporary directory rather than the mount point, so the sandbox still starts. -**Refusals a wrapped command can plant (Linux).** Two of the wrap-time refusals below follow from a file a sandboxed command is allowed to write, and the command that would put it right is refused as well, because every wrap in that working directory hits the same file. Both name the offending path, or the line the scan printed, in the message, and **both are lifted only by a command run outside the sandbox**: +**Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: - `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. - `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. +- `deny_scan_failed` where a `.git/modules` tree takes longer to walk than the scan's own budget: a tree wide or slow enough is one a command can make, and the walk refuses rather than hand back a listing that stops somewhere unknown. Removing what makes it slow lifts it. On macOS the same condition is a `SubmoduleWalkBudgetError`, exported from the package root. + +- `too_many_arguments` where a command plants enough CHECKED-OUT submodules — a `.git` pointer file and the git directory it names — that the profile does not fit with every submodule deny degraded, measured at about 1475 of them. -A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. +A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: From cb1c2c70d61337033904bb757fabd0f36a68d2f6 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 10:21:57 +0000 Subject: [PATCH 032/141] Tests: give the two macOS arms back what this round took from them The locked `.git/modules` directory the bracket fixture now makes is given its mode back by the describes that make one, not by a describe whose tree has no such directory - which threw before the profile it was checking was ever built. The directory of fifty thousand entries is taken away by the case that made it rather than by the hook after it: removing that many is slower than a hook is given on some filesystems, and the whole file was abandoned for it. --- test/sandbox/macos-literal-deny-brackets.test.ts | 2 +- test/sandbox/mandatory-deny-paths.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index 16a6d6d65..0d8cafc7a 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -324,7 +324,6 @@ describe.if(isMacOS)( afterAll(() => { process.chdir(originalCwd) - openModulesTree(tree) rmSync(tree.root, { recursive: true, force: true }) }) @@ -625,6 +624,7 @@ describe.if(isMacOS)( afterAll(() => { process.chdir(originalCwd) + openModulesTree(tree) rmSync(tree.root, { recursive: true, force: true }) }) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 57e65611b..4e3f95d5a 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -3036,6 +3036,9 @@ describe('Git metadata deny paths - Unit Tests', () => { SubmoduleWalkBudgetError, ) expect(Date.now() - started).toBeLessThan(2000) + // Removed here rather than by the hook after it: fifty thousand entries + // take longer to take away on some filesystems than a hook is given. + rmSync(modules, { recursive: true, force: true }) }, 120000) it.if(isLinux)( From 307fa80e14185f16f89a3e83a89d93a43356cda1 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 22:04:43 +0000 Subject: [PATCH 033/141] Deny both halves of a git directory entry that is a symlink `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a symlink, and then there are two things to protect: what a write through the link reaches, and the link itself, which a command can unlink and leave its own `hooks/` in place of for the host's git to run. One deny covers one of them, and which one is the backend's: a Linux bind lands on what the path resolves to, a Seatbelt filter matches the path as an unlink or a rename names it. So the producer names both. Every entry is lstat'ed; a link adds its target to the deny paths, which is what macOS was missing, and the directory holding it to `linkedEntryDirs`, which only the backend whose denies resolve carries - the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so a repository that has such a link keeps `git status`, `log` and `diff` and loses `git add` and `git commit` until the link is gone; macOS gets no whole deny and goes on working. Fail closed either way round. A link whose target is not there yet is denied where it lands, so a command cannot create it: the hole was a dangling `hooks` inside a git directory a degrade had already made read-only. An entry whose `lstat` fails for anything but absence is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it; an entry of a `.git/modules` that is a loop still denies nothing at all, since a command can plant thousands of those and a pointer naming one is already denied at `.git/modules`. The degrade keeps what leads out. A submodule git directory denied whole is one read-only bind, and a deny that resolves outside it - a symlinked entry's target - is not covered by that bind: it is kept rather than dropped, and counted as a mount the step does not give back, so a budget that cannot take it collapses one level further instead. Without that, a write through the link landed in a collapsed submodule. --- src/sandbox/linux-deny-collapse.ts | 71 +++++--- src/sandbox/linux-sandbox-utils.ts | 23 ++- src/sandbox/mandatory-deny-paths.ts | 272 +++++++++++++++++++++++++--- 3 files changed, 310 insertions(+), 56 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 0ca3c8b9a..b90f16ed1 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -1,5 +1,5 @@ import * as fs from 'fs' -import type { GitDirTreeDenies } from './mandatory-deny-paths.js' +import type { GitDirDenies, GitDirTreeDenies } from './mandatory-deny-paths.js' import { isAtOrUnder } from './sandbox-utils.js' /** @@ -13,9 +13,13 @@ import { isAtOrUnder } from './sandbox-utils.js' export interface RepositorySubmodules { modulesDir: string /** Sorted by path, which is the order a collapse works back through. */ - gitDirs: Array<{ gitDir: string; denyPaths: string[] }> - /** Directories the walk could not see through: whole-directory denies. */ - unreadableDirs: string[] + gitDirs: Array<{ gitDir: string } & GitDirDenies> + /** + * The whole-directory denies of this repository: what the walk could not + * see through, and the directories holding an entry it followed through a + * symlink. A bind of `modules` covers the ones really under it. + */ + wholeDirDenies: string[] } /** What one wrap denies, and what of that can be degraded. */ @@ -54,13 +58,14 @@ export const NO_COLLAPSE: CollapseLevel = { export function repositorySubmodules( denies: GitDirTreeDenies, ): RepositorySubmodules | undefined { - if (denies.submodules.length === 0 && denies.unreadableDirs.length === 0) { + const wholeDirDenies = [...denies.unreadableDirs, ...denies.linkedEntryDirs] + if (denies.submodules.length === 0 && wholeDirDenies.length === 0) { return undefined } return { modulesDir: denies.modulesDir, gitDirs: denies.submodules, - unreadableDirs: denies.unreadableDirs, + wholeDirDenies, } } @@ -72,8 +77,11 @@ export function repositorySubmodules( * A degraded submodule git directory's own deny paths are dropped from the * list wherever they sit and one read-only bind of the git directory is added * in their place; a `.git/modules` denied whole takes everything under it the - * same way. A `.git` pointer file's own deny is never one of them: it names a - * file, and a file has nothing to collapse into. + * same way. Two kinds of deny paths are NOT dropped: a `.git` pointer file's + * own, which names a file and has nothing to collapse into, and the ones the + * producer marked as leading out of the git directory — what an entry that is + * a symlink points at, which no bind of the directory holding the link + * covers. */ export function collapsedDenyPaths( plan: SubmoduleDenyPlan, @@ -109,22 +117,24 @@ export function collapsedDenyPaths( : () => false if (wholeModules) wholeBinds.push(repository.modulesDir) for (const submodule of repository.gitDirs) { - if (coveredByModules(submodule.gitDir)) { - for (const denyPath of submodule.denyPaths) covered.add(denyPath) - continue + const byModules = coveredByModules(submodule.gitDir) + if (!byModules && !wholeGitDirs.has(submodule.gitDir)) continue + const escaping = new Set(submodule.escapingDenyPaths) + for (const denyPath of submodule.denyPaths) { + if (!escaping.has(denyPath)) covered.add(denyPath) } - if (!wholeGitDirs.has(submodule.gitDir)) continue - for (const denyPath of submodule.denyPaths) covered.add(denyPath) - wholeBinds.push(submodule.gitDir) + if (!byModules) wholeBinds.push(submodule.gitDir) } - for (const unreadableDir of repository.unreadableDirs) { - if (coveredByModules(unreadableDir)) covered.add(unreadableDir) + for (const wholeDirDeny of repository.wholeDirDenies) { + if (coveredByModules(wholeDirDeny)) covered.add(wholeDirDeny) } } - return [ - ...plan.denyPaths.filter(denyPath => !covered.has(denyPath)), - ...wholeBinds, - ] + // A git directory that is a deny path in its own right — one holding a + // symlinked entry — is both dropped as covered and added back as the bind + // that covers it, so the binds are added only where the list has lost them. + const kept = plan.denyPaths.filter(denyPath => !covered.has(denyPath)) + const keptPaths = new Set(kept) + return [...kept, ...wholeBinds.filter(bind => !keptPaths.has(bind))] } /** @@ -135,9 +145,10 @@ export function collapsedDenyPaths( * How much each step gives back is what the caller measures; the arithmetic * here only decides how many entries one step takes, so that a repository * with thousands of submodules is not rebuilt once per submodule. A - * submodule git directory gives back one mount per deny path it loses, plus - * the ancestor pin of the git directory itself, which the bind that replaces - * them is. + * submodule git directory gives back one mount per deny path the bind that + * replaces them covers — a deny leading out of it is kept and gives back + * nothing — plus the ancestor pin of the git directory itself, which that + * bind is. */ export function collapseFurther( plan: SubmoduleDenyPlan, @@ -149,7 +160,11 @@ export function collapseFurther( const taken = stepBack( order.length - level.wholeGitDirs, mountsOver, - at => order[at]?.denyPaths.length ?? 0, + at => { + const submodule = order[at] + if (submodule === undefined) return 0 + return submodule.denyPaths.length - submodule.escapingDenyPaths.length + }, ) return { ...level, wholeGitDirs: level.wholeGitDirs + taken } } @@ -212,11 +227,9 @@ export function describeCollapse( /** Every submodule git directory the plan may degrade, in the order a * collapse works back through: by repository as served, each repository's * own sorted. */ -function collapsibleGitDirs(plan: SubmoduleDenyPlan): Array<{ - modulesDir: string - gitDir: string - denyPaths: string[] -}> { +function collapsibleGitDirs( + plan: SubmoduleDenyPlan, +): Array<{ modulesDir: string; gitDir: string } & GitDirDenies> { return plan.repositories.flatMap(repository => repository.gitDirs.map(submodule => ({ modulesDir: repository.modulesDir, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 753092963..bb60d5077 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -32,12 +32,13 @@ import { import { GitMetadataError, SubmoduleWalkBudgetError, - gitDirDenyPaths, + gitDirDenies, gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenyPaths, gitRedirectPlaceholder, } from './mandatory-deny-paths.js' +import type { GitDirTreeDenies } from './mandatory-deny-paths.js' import type { CollapseLevel, RepositorySubmodules, @@ -684,7 +685,7 @@ function cwdMandatoryDenyPlan( } if (dotGitStat?.isDirectory()) { const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) - denyPaths.push(...gitDirTreeDenyPaths(denies)) + denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) const repository = repositorySubmodules(denies) if (repository !== undefined) repositories.push(repository) } else if (dotGitStat?.isFile()) { @@ -696,6 +697,19 @@ function cwdMandatoryDenyPlan( return { denyPaths, repositories } } +/** + * {@link gitDirTreeDenyPaths} plus the directories that have to be denied + * WHOLE because they hold an entry that is a symlink. Only this backend + * carries those: a bind lands on what a deny path resolves to, so the link's + * own path keeps nothing and the directory around it is the handle on it, + * while a Seatbelt filter matches the path as a rename or an unlink names it + * and needs no such thing (see `gitDirDenies` in + * src/sandbox/mandatory-deny-paths.ts). + */ +function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { + return [...gitDirTreeDenyPaths(denies), ...denies.linkedEntryDirs] +} + /** * The deny paths `cwd` has whatever its `.git` turns out to be, and whatever * can be read of it. Settings files are added at the callsite in @@ -773,7 +787,8 @@ function monitorDotGitDenyPaths( // nothing followed to name. if (dotGitStat.isFile()) return [dotGitPath] if (dotGitStat.isDirectory()) { - return gitDirDenyPaths(dotGitPath, allowGitConfig) + const denies = gitDirDenies(dotGitPath, allowGitConfig) + return [...denies.denyPaths, ...denies.linkedEntryDirs] } return [] } @@ -988,7 +1003,7 @@ async function linuxGetMandatoryDenyPaths( const denies = asProfileRefusal(() => gitDirTreeDenies(gitDir, allowGitConfig, { deadline: walkDeadline() }), ) - denyPaths.push(...gitDirTreeDenyPaths(denies)) + denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) const repository = repositorySubmodules(denies) if (repository !== undefined) plan.repositories.push(repository) } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 1606aeadc..f8c145e08 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -4,6 +4,7 @@ import { logForDebugging } from '../utils/debug.js' import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' import { isAbsenceErrno, + isAtOrUnder, MAX_SYMLINK_RESOLUTION_DEPTH, } from './sandbox-utils.js' @@ -105,7 +106,12 @@ export class SubmoduleWalkBudgetError extends Error { /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { - /** The submodule git directories, sorted. */ + /** + * The submodule git directories, sorted. An entry the walk followed through + * a symlink is here as the path it was reached by; one whose target is not + * there yet is here as the path that target folds to, which is what a deny + * has to block a command from creating. + */ gitDirs: string[] /** * Directories the walk could not see through: what lies under them is @@ -121,6 +127,18 @@ export interface SubmoduleScan { * tree that trips one stop working. */ unreadableDirs: string[] + /** + * Directories holding an entry the walk followed through a SYMLINK, denied + * whole for the link's sake: the deny the target gets covers what a write + * through the link reaches, and the link's own path takes no mount of its + * own (putting one there means resolving it). A `.git/modules` is writable + * inside the sandbox, so a command that leaves the link alone but puts its + * own git directory in its place aims every pointer that named it at hooks + * the deny never saw. Denying the directory that holds it is the only + * handle on that, and it is a whole-directory deny like the above. Sorted + * and without duplicates. + */ + linkedEntryDirs: string[] } /** @@ -135,9 +153,11 @@ export interface GitDirTreeDenies { /** `/modules`, whether or not the walk found anything under it. */ modulesDir: string /** One per submodule git directory under `modulesDir`, sorted by path. */ - submodules: Array<{ gitDir: string; denyPaths: string[] }> + submodules: Array<{ gitDir: string } & GitDirDenies> /** What the walk could not see through; see {@link SubmoduleScan}. */ unreadableDirs: string[] + /** Directories holding a symlinked entry; see {@link SubmoduleScan}. */ + linkedEntryDirs: string[] } /** @@ -184,6 +204,10 @@ const GIT_REDIRECT_FILES: ReadonlyArray<{ * core.hooksPath and the like) unless the caller allows config writes, and * the redirect files of {@link GIT_REDIRECT_FILES}, each under the same * condition as the file it redirects. + * + * Path arithmetic alone, so that macOS can hand it a glob pattern in place of + * a directory. {@link gitDirDenies} is what a caller holding a real directory + * wants: the same paths, plus what an entry that is a symlink needs. */ export function gitDirDenyPaths( gitDir: string, @@ -201,6 +225,151 @@ export function gitDirDenyPaths( return denyPaths } +/** + * What a git directory is denied by once the entries it holds have been + * looked at, and what of that a read-only bind of the git directory would not + * cover. + */ +export interface GitDirDenies { + /** The deny paths, in the order the backends emit them. */ + denyPaths: string[] + /** + * The ones that lead OUT of the git directory: what a symlinked entry + * points at, and an entry this could not classify. A backend that degrades + * a git directory to one read-only bind of it keeps these and drops the + * rest, which that bind covers. + */ + escapingDenyPaths: string[] + /** + * The git directory itself, where an entry of it is a symlink — empty for + * an ordinary one. Only a backend whose denies RESOLVE has to carry it: + * see {@link SubmoduleScan.linkedEntryDirs}, which is the same answer for + * an entry of a `.git/modules`. + */ + linkedEntryDirs: string[] +} + +/** + * {@link gitDirDenyPaths} for a git directory that is really there, with what + * an entry which is itself a SYMLINK adds to it. + * + * A symlinked entry is two things, and one deny covers one of them: the + * target, where a write through the link lands, and the link itself, which + * sits inside a writable git directory where a command can unlink it and + * leave its own `hooks/` in its place for the host's git to run. Which of the + * two a deny path covers is the backend's: a Linux bind lands on what the + * path resolves to (so the target is covered and the link's own path keeps + * nothing), while a Seatbelt filter matches the path as an unlink or a rename + * names it (so the link is covered and the target is not). Both are therefore + * named — the entry and its target as deny paths, and the git directory in + * `linkedEntryDirs` for the backend that needs the link held by the directory + * around it. Denying a git directory whole leaves `git status`, `log` and + * `diff` working in that repository, while `git add`, `git commit` and + * anything else that writes the index or an object fail read-only until the + * link is gone. + * + * An ordinary git directory costs one lstat per entry and is denied by exactly + * what it always was. + */ +export function gitDirDenies( + gitDir: string, + allowGitConfig: boolean, +): GitDirDenies { + const denyPaths: string[] = [] + const escapingDenyPaths: string[] = [] + let denyWhole = false + for (const entryPath of gitDirDenyPaths(gitDir, allowGitConfig)) { + const entry = gitDirEntry(entryPath) + switch (entry.kind) { + case 'plain': + denyPaths.push(entryPath) + break + case 'link': + denyWhole = true + // Both spellings: the Linux bind resolves the entry's own path to the + // same place, while on macOS a filter matches where the write lands + // and the target is the only one of the two that names it. + denyPaths.push(entryPath, entry.target) + if (!isAtOrUnder(entry.target, realPathOrSelf(gitDir))) { + escapingDenyPaths.push(entry.target) + } + break + case 'unreachable': + // A loop, or a name no file can occupy: there is nothing behind it to + // deny and nothing a command can create through it. The link itself + // is the whole hazard, and the whole-directory deny holds it. + denyWhole = true + break + case 'unknown': + // There and not classifiable. Fail closed both ways: the deny the + // entry has anyway, kept through a degrade in case it is a link, and + // the whole-directory deny one would need. + denyWhole = true + denyPaths.push(entryPath) + escapingDenyPaths.push(entryPath) + break + } + } + return { + denyPaths, + escapingDenyPaths, + linkedEntryDirs: denyWhole ? [gitDir] : [], + } +} + +/** What an entry of a git directory turns out to be. */ +type GitDirEntry = + /** Not a symlink, or nothing at all: the deny it has always had. */ + | { kind: 'plain' } + /** A symlink, resolved or dangling, that leads to `target`. */ + | { kind: 'link'; target: string } + /** A symlink that reaches nothing: a loop, or a name no file can occupy. */ + | { kind: 'unreachable' } + /** There, and what it is could not be worked out. */ + | { kind: 'unknown' } + +function gitDirEntry(entryPath: string): GitDirEntry { + let stats: fs.Stats + try { + stats = fs.lstatSync(entryPath) + } catch (err) { + // The shared classification rather than this file's narrower one: a + // component above the entry that is a loop, or a name too long for the + // filesystem, reaches no file here and leaves nothing to protect, while + // the narrower one exists for a `gitdir:` target and answers a different + // question. Anything else is a fact about the entry this cannot read. + if (isAbsenceErrno(err)) return { kind: 'plain' } + logForDebugging( + `[Sandbox] Could not tell whether ${entryPath} is a symlink (${err}); denying the git directory holding it whole, and the entry as it stands`, + { level: 'warn' }, + ) + return { kind: 'unknown' } + } + if (!stats.isSymbolicLink()) return { kind: 'plain' } + try { + fs.statSync(entryPath) + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'ELOOP' || isUnusablePathError(err)) { + return { kind: 'unreachable' } + } + if (!isAbsenceErrno(err)) { + logForDebugging( + `[Sandbox] Could not follow ${entryPath} (${err}); denying the git directory holding it whole, and the entry as it stands`, + { level: 'warn' }, + ) + return { kind: 'unknown' } + } + // Dangling, which is no reason to pass over it: nothing is there yet and + // a command can put a hooks directory there for the host's git to find, + // so the deny goes where the link lands and blocks creating it. + } + return { + kind: 'link', + target: physicalPath(path.parse(entryPath).root, entryPath), + } +} + /** * What must stand in for `denyPath` where it does not exist, or undefined for * a path git does not read this way. Decided by the basename, so every @@ -343,14 +512,21 @@ export function gitDirTreeDenies( ): GitDirTreeDenies { const modulesDir = path.join(gitDir, 'modules') const modules = submoduleGitDirs(modulesDir, options.deadline) + const own = gitDirDenies(gitDir, allowGitConfig) + const submodules = modules.gitDirs.map(submodule => ({ + gitDir: submodule, + ...gitDirDenies(submodule, allowGitConfig), + })) return { - ownDenyPaths: gitDirDenyPaths(gitDir, allowGitConfig), + ownDenyPaths: own.denyPaths, modulesDir, - submodules: modules.gitDirs.map(submodule => ({ - gitDir: submodule, - denyPaths: gitDirDenyPaths(submodule, allowGitConfig), - })), + submodules, unreadableDirs: modules.unreadableDirs, + linkedEntryDirs: [ + ...own.linkedEntryDirs, + ...modules.linkedEntryDirs, + ...submodules.flatMap(submodule => submodule.linkedEntryDirs), + ], } } @@ -385,7 +561,11 @@ export function submoduleGitDirs( modulesDir: string, deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, ): SubmoduleScan { - const walk: SubmoduleWalk = { gitDirs: [], unreadableDirs: new Set() } + const walk: SubmoduleWalk = { + gitDirs: [], + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + } // Where the walk still has to look, and where it has already been. A // directory listed to see whether it is a git directory carries its entries // with it, so that no directory is listed twice. The root counts as visited: @@ -412,7 +592,18 @@ export function submoduleGitDirs( // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. - if (!isDirectory(entry, child, walk)) continue + const found = walkEntry(entry, child, walk) + if (found.kind === 'skip') continue + if (found.kind === 'dangling') { + // Where a link leads to nothing is denied as a git directory that is + // not there yet, so a command cannot fill one in for the host's git + // to find. Nothing is walked: there is nothing there to walk. + if (!visited.has(found.landing)) { + visited.add(found.landing) + walk.gitDirs.push(found.landing) + } + continue + } const visitKey = realPathOrSelf(child) if (visited.has(visitKey)) continue visited.add(visitKey) @@ -433,6 +624,7 @@ export function submoduleGitDirs( return { gitDirs: walk.gitDirs.sort(), unreadableDirs: [...walk.unreadableDirs].sort(), + linkedEntryDirs: [...walk.linkedEntryDirs].sort(), } } @@ -443,8 +635,19 @@ export function submoduleGitDirs( interface SubmoduleWalk { gitDirs: string[] unreadableDirs: Set + linkedEntryDirs: Set } +/** What one entry of a walked directory is worth looking into. */ +type WalkEntry = + /** A directory to walk, reached directly or through a symlink. */ + | { kind: 'directory' } + /** A symlink whose target is not there: nothing to walk, and `landing` is + * where a command putting a git directory there would put it. */ + | { kind: 'dangling'; landing: string } + /** Nothing to walk and nothing to deny. */ + | { kind: 'skip' } + /** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ function listDirectory( dir: string, @@ -466,18 +669,39 @@ function listDirectory( } } -/** Whether `entry` is a directory, following a symlink to one. */ -function isDirectory( +/** + * What `entry` is worth doing with, following a symlink to a directory. + * + * A link that leads anywhere at all also puts the directory HOLDING it on the + * whole-directory denies: what a deny of the target covers is what a write + * through the link reaches, while the link itself takes no mount of its own, + * and a `.git/modules` is writable — see {@link SubmoduleScan.linkedEntryDirs}. + * A command can plant such a link and leave every submodule beside it + * read-only for the commands after it, which is the answer this makes to a + * command that plants git directories too. + */ +function walkEntry( entry: fs.Dirent, entryPath: string, walk: SubmoduleWalk, -): boolean { - if (entry.isDirectory()) return true - if (!entry.isSymbolicLink()) return false +): WalkEntry { + if (entry.isDirectory()) return { kind: 'directory' } + if (!entry.isSymbolicLink()) return { kind: 'skip' } try { - return fs.statSync(entryPath).isDirectory() + if (!fs.statSync(entryPath).isDirectory()) return { kind: 'skip' } + walk.linkedEntryDirs.add(path.dirname(entryPath)) + return { kind: 'directory' } } catch (err) { - if (isAbsenceError(err) || isUnusablePathError(err)) return false + if (isAbsenceError(err)) { + // Dangling. Nothing is there to walk, and a command can create it: what + // the link lands on is denied as a git directory that is not there yet. + walk.linkedEntryDirs.add(path.dirname(entryPath)) + return { + kind: 'dangling', + landing: physicalPath(path.parse(entryPath).root, entryPath), + } + } + if (isUnusablePathError(err)) return { kind: 'skip' } if ((err as NodeJS.ErrnoException).code === 'ELOOP') { // A link that resolves back to itself reaches no directory at all, so // there is nothing behind it to deny and nothing a command can put @@ -491,20 +715,22 @@ function isDirectory( `[Sandbox] ${entryPath} is a symlink loop, which leads to nothing to deny: ${err}`, { level: 'warn' }, ) - return false + return { kind: 'skip' } } - // Something is there and could not be inspected. Fail closed on the - // deepest directory that can be reached TOWARDS IT rather than on the - // directory holding the link, which would again be every submodule - // beside it. + // Something is there and could not be inspected. What is behind the link + // is failed closed on the deepest directory that can be reached TOWARDS + // IT, rather than on the directory holding the link, which would take + // every submodule beside it over one entry; the link itself is a link + // like any other, and its holder is denied for it. const denied = deepestReachableAncestor(linkTarget(entryPath) ?? entryPath) ?? entryPath walk.unreadableDirs.add(denied) + walk.linkedEntryDirs.add(path.dirname(entryPath)) logForDebugging( `[Sandbox] Could not follow ${entryPath}, denying ${denied} whole: ${err}`, { level: 'warn' }, ) - return false + return { kind: 'skip' } } } @@ -534,7 +760,7 @@ function gitDirTargetDenyPaths( switch (kind) { case 'git-dir': case 'absent': - return gitDirDenyPaths(target, allowGitConfig) + return gitDirDenies(target, allowGitConfig).denyPaths case 'unreadable': { const denied = deepestReachableAncestor(target) ?? target logForDebugging( From 710cbae77ea0efeb1a26c4d8d73e33f6cdda1f49 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 22:04:43 +0000 Subject: [PATCH 034/141] Tests: the link, what it leads to, and what a degrade keeps Unit cases over the producer: a symlinked `hooks` denied as the link, the target and the git directory whole, with the target named as what a degrade must keep; a link that stays inside the git directory adding nothing to keep; a dangling one denied where it lands, and a chain of them at the end of the chain; a loop leaving only the whole-directory deny; an entry whose `lstat` refuses taken for a link; the `.git/modules` walk denying the directory that holds a symlinked entry and, for a dangling one, where it lands. The macOS entries for the same tree carry the link and the target and not the git directory whole, which is the difference between the two backends' resolution. Degrading: a collapsed git directory keeps what its symlinked entry leads to, under a `modules` denied whole as well, and a kept deny is a mount the step does not give back. Live under bubblewrap, both halves. In a repository of 700 submodule git directories where two of the collapsed ones have a symlinked `hooks` - one into the write root, one at a path that is not there yet - the write through the link, the creation of the landing, and the `rm` and `mv` of the link are each refused while an ordinary project write succeeds. In a repository where nothing is degraded, with a symlinked `.git/hooks` and a symlinked `.git/modules/lib`, the same attempts are refused and `git status` still works. The walk's scan answers carry the new list, and the two links into a directory this process cannot search now also deny the `modules` that holds them: what is BEHIND a link is still failed closed at the deepest directory reachable towards it rather than at the holder, and the link itself is what the holder is denied for. --- test/sandbox/mandatory-deny-paths.test.ts | 441 +++++++++++++++++++++- 1 file changed, 435 insertions(+), 6 deletions(-) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 4e3f95d5a..71ddccb2e 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -45,6 +45,7 @@ import { import { GitMetadataError, SubmoduleWalkBudgetError, + gitDirDenies, gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, @@ -2288,6 +2289,41 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { expect(hasHooksPatternTrue).toBe(true) }) + it.if(!isWindows)( + 'carries a symlinked entry as the link, the target and the git directory', + () => { + // A Seatbelt filter matches where the write lands, so the entry's own + // path covers the link and the target needs an entry of its own; the + // git directory whole is what keeps the link from being replaced, + // which on macOS the unlink re-deny of a literal entry does as well. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-deny-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const shared = join(dir, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(shared) + // Not the git directory whole: a Seatbelt filter matches the link's + // own path for a rename or an unlink, so the entry covers it, and + // git writes in the repository go on working. + expect(paths).not.toContain(gitDir) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + it('defaults to blocking .git/config when no argument provided', () => { const patterns = macGetMandatoryDenyEntries().map(e => e.path) @@ -2342,6 +2378,166 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) + it.if(!isWindows)( + 'denies a git directory whole where an entry is a symlink, and what the link leads to', + () => { + // The deny of the entry lands where the link points - the Linux bind + // resolves the path, a Seatbelt filter matches where the write goes - + // so the link's own path carries nothing, and it sits in a directory + // the sandbox can write. The git directory whole is the handle on it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const shared = join(dir, 'repo', 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'hooks'), + shared, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + // What a read-only bind of the git directory would not cover, and so + // what a degrade of it has to keep. + expect(denies.escapingDenyPaths).toEqual([shared]) + // What holds the link, for the backend whose denies resolve. + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }, + ) + + it.if(!isWindows)( + 'leaves an ordinary git directory exactly as it was', + () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + writeFileSync(join(gitDir, 'config'), '[core]\n') + + expect(gitDirDenies(gitDir, false)).toEqual({ + denyPaths: gitDirDenyPaths(gitDir, false), + escapingDenyPaths: [], + linkedEntryDirs: [], + }) + }, + ) + + it.if(!isWindows)( + 'keeps a link inside the git directory out of the escaping set', + () => { + // hooks -> hooks.real, both under the git directory: the bind that + // denies the directory whole covers the target as well, so a degrade + // has nothing to keep. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'hooks.real'), { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks.real'), join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + }, + ) + + it.if(!isWindows)( + 'denies where a DANGLING entry lands, so the target cannot be created', + () => { + // The link is there and its target is not: a command can create the + // target and the host's git runs what it finds there. Denying the + // landing is what blocks creating it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'not-there-yet') + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(landing, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(landing) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([landing]) + }, + ) + + it.if(!isWindows)( + 'follows a chain of dangling links to where a write through it would land', + () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'end') + symlinkSync(landing, join(dir, 'repo', 'middle')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(dir, 'repo', 'middle'), join(gitDir, 'hooks')) + + expect(gitDirDenies(gitDir, false).denyPaths).toContain(landing) + }, + ) + + it.if(!isWindows)( + 'denies the git directory whole for an entry that is a symlink loop, and nothing behind it', + () => { + // A loop reaches nothing: there is nothing to deny at the other end, + // and nothing a command can create through it. Replacing the link is + // what the whole-directory deny is for. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks'), join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'takes an entry it cannot look at for one that needs both denies', + () => { + // lstat refused: whether it is a link is unknown, so the entry keeps + // the deny it has (and keeps it through a degrade, in case it is one) + // and the git directory is denied whole as a link would need. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + chmodSync(gitDir, 0o000) + try { + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + } finally { + chmodSync(gitDir, 0o755) + } + }, + ) + + it.if(!isWindows)( + 'denies the directory holding a symlinked modules entry, and where a dangling one lands', + () => { + // The walk follows the link and denies the git directory it reaches; + // the link itself stays replaceable in a writable `modules`, and a + // pointer that names it would then find whatever was put in its place. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, join(modules, 'lib')) + const landing = join(dir, 'repo', 'not-there-yet') + symlinkSync(landing, join(modules, 'gone')) + + const scan = submoduleGitDirs(modules) + + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.gitDirs).toEqual([join(modules, 'lib'), landing].sort()) + const tree = gitDirTreeDenies(gitDir, false) + expect(tree.linkedEntryDirs).toEqual([modules]) + expect(gitDirTreeDenyPaths(tree)).toContain(join(landing, 'hooks')) + }, + ) + it('gives the files git reads a placeholder that is no redirect', () => { // `.` is the git directory itself, which is where git looks when there // is no commondir, and no config.worktree reads as an empty one does. @@ -2847,6 +3043,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ gitDirs: [gitDir], unreadableDirs: [], + linkedEntryDirs: [], }) }) @@ -2933,6 +3130,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ gitDirs: [gitDir], unreadableDirs: [], + linkedEntryDirs: [join(dir, 'modules', 'a')], }) }) @@ -2950,6 +3148,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ gitDirs: [gitDir], unreadableDirs: [], + linkedEntryDirs: [], }) }, ) @@ -2966,20 +3165,24 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ gitDirs: [gitDir], unreadableDirs: [], + linkedEntryDirs: [], }) }, 30000, ) it.if(!isWindows && process.getuid?.() !== 0)( - 'denies where a symlinked entry leads, not the directory holding it, and once', + 'denies where a symlinked entry leads, and the directory holding the link', () => { - // Two links into a directory this process cannot search. What is behind - // them is unknown, so the deny is the deepest directory that can be + // Two links into a directory this process cannot search. What is BEHIND + // them is unknown, so that deny is the deepest directory that can be // reached towards them - the locked directory itself - and not the // `modules` that holds the links, which would take every submodule - // beside them. One entry, not three: the same path is reached by the - // listing of `locked` and by each of the two links. + // beside them over one unreadable target. One entry, not three: the + // same path is reached by the listing of `locked` and by each of the + // two links. The links THEMSELVES are another matter: neither deny + // lands on a link's own path, and `modules` is writable, so the + // directory holding them is denied for their sake. const locked = join(dir, 'modules', 'locked') const sibling = makeGitDir(join(dir, 'modules', 'lib')) mkdirSync(locked, { recursive: true }) @@ -2990,6 +3193,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ gitDirs: [sibling], unreadableDirs: [locked], + linkedEntryDirs: [join(dir, 'modules')], }) } finally { chmodSync(locked, 0o755) @@ -3309,9 +3513,11 @@ describe.if(isSupportedPlatform)( modulesDir: string, gitDirNames: string[], extra: string[] = [], + escaping: (gitDir: string) => string[] = () => [], ): SubmoduleDenyPlan { const gitDirs = gitDirNames.map(name => { const gitDir = join(modulesDir, name) + const escapingDenyPaths = escaping(gitDir) return { gitDir, denyPaths: [ @@ -3319,12 +3525,14 @@ describe.if(isSupportedPlatform)( join(gitDir, 'commondir'), join(gitDir, 'config'), join(gitDir, 'config.worktree'), + ...escapingDenyPaths, ], + escapingDenyPaths, } }) return { denyPaths: [...extra, ...gitDirs.flatMap(sub => sub.denyPaths)], - repositories: [{ modulesDir, gitDirs, unreadableDirs: [] }], + repositories: [{ modulesDir, gitDirs, wholeDirDenies: [] }], } } @@ -3357,6 +3565,100 @@ describe.if(isSupportedPlatform)( expect(denies).not.toContain(join(modules, 'b', 'config')) }) + it('keeps what a symlinked entry leads to when the git directory goes whole', () => { + // The one read-only bind that replaces a submodule's precise denies + // covers what is under the git directory. A symlinked entry leads + // out of it, and dropping that deny leaves one write through the + // link landing on hooks the host's git runs. + const modules = join(dir, '.git', 'modules') + const planted = join(dir, 'planted') + const plan = planFor(modules, ['a', 'b'], [], gitDir => + gitDir === join(modules, 'b') ? [planted] : [], + ) + + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + + expect(denies).toContain(join(modules, 'b')) + expect(denies).toContain(planted) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + }) + + it('keeps it under a modules directory denied whole as well', () => { + const modules = join(dir, '.git', 'modules') + const planted = join(dir, 'planted') + const plan = planFor(modules, ['a', 'b'], [], gitDir => + gitDir === join(modules, 'b') ? [planted] : [], + ) + + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) + + expect(denies).toContain(modules) + expect(denies).toContain(planted) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + expect(denies).not.toContain(join(modules, 'a', 'hooks')) + }) + + it('counts a kept deny as a mount the step does not give back', () => { + // Three denies each, of which one leads out of the git directory and + // is kept: a step gives back two mounts, not three, so one that has + // to find three takes both git directories. + const modules = join(dir, '.git', 'modules') + const gitDirs = ['a', 'b'].map(name => { + const gitDir = join(modules, name) + const planted = join(dir, 'planted', name) + return { + gitDir, + denyPaths: [join(gitDir, 'hooks'), planted, join(gitDir, 'config')], + escapingDenyPaths: [planted], + } + }) + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirs.flatMap(submodule => submodule.denyPaths), + repositories: [{ modulesDir: modules, gitDirs, wholeDirDenies: [] }], + } + + expect(collapseFurther(plan, NO_COLLAPSE, 2)).toEqual({ + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + expect(collapseFurther(plan, NO_COLLAPSE, 3)).toEqual({ + wholeGitDirs: 2, + wholeModulesDirs: 0, + }) + }) + + it('adds the bind of a git directory that is a deny path already once', () => { + // A git directory holding a symlinked entry is denied whole by the + // producer, so it is in the list before any degrade reaches it. + const modules = join(dir, '.git', 'modules') + const gitDir = join(modules, 'b') + const plan = planFor(modules, ['a', 'b']) + const withWhole: SubmoduleDenyPlan = { + denyPaths: [gitDir, ...plan.denyPaths], + repositories: plan.repositories.map(repository => ({ + ...repository, + gitDirs: repository.gitDirs.map(submodule => + submodule.gitDir === gitDir + ? { ...submodule, denyPaths: [gitDir, ...submodule.denyPaths] } + : submodule, + ), + })), + } + + const denies = collapsedDenyPaths(withWhole, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + + expect(denies.filter(deny => deny === gitDir)).toEqual([gitDir]) + }) + it('runs out, which is what too_many_arguments is left for', () => { const plan = planFor(join(dir, '.git', 'modules'), ['a', 'b']) const everything = { wholeGitDirs: 2, wholeModulesDirs: 1 } @@ -3730,6 +4032,133 @@ describe.if(isSupportedPlatform)( 300000, ) + it.if(LIVE)( + 'holds both halves of a symlinked entry in a collapsed submodule', + async () => { + // Seven hundred submodule git directories, so the tail is collapsed + // to one read-only bind each. Two of the collapsed ones have a hooks + // that is a symlink: one to a directory inside the write root, one + // to a path that is not there yet. The bind covers the link and not + // what it leads to, and both halves have to hold. + const { checkout } = makeSuperproject(700) + const gitDir = join(checkout, '.git') + const planted = join(checkout, 'planted') + const landing = join(checkout, 'not-there-yet') + mkdirSync(planted, { recursive: true }) + // Names sorting last, so the collapse takes these first. + const live = makeGitDir(join(gitDir, 'modules', 'zz-live')) + const dangling = makeGitDir(join(gitDir, 'modules', 'zz-dangling')) + for (const [sub, target] of [ + [live, planted], + [dangling, landing], + ] as const) { + rmSync(join(sub, 'hooks'), { recursive: true }) + symlinkSync(target, join(sub, 'hooks')) + } + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(planted, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + `! mkdir -p ${landing} || echo LANDING_CREATABLE; ` + + `! echo x > ${join(landing, 'post-commit')} || echo LANDING_WRITABLE; ` + + `! rm -f ${join(live, 'hooks')} || echo LINK_UNLINKABLE; ` + + `! mv ${join(live, 'hooks')} ${join(live, 'hooks.moved')} || echo LINK_RENAMABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('LANDING_CREATABLE') + expect(result.stdout).not.toContain('LANDING_WRITABLE') + expect(result.stdout).not.toContain('LINK_UNLINKABLE') + expect(result.stdout).not.toContain('LINK_RENAMABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(existsSync(join(planted, 'post-commit'))).toBe(false) + // The landing itself is the wrap's own mount point, which is what + // blocks creating it; nothing of the command's is inside it. + expect(existsSync(join(landing, 'post-commit'))).toBe(false) + }, + 300000, + ) + + it.if(LIVE)( + 'holds a symlinked hooks and a symlinked modules entry where nothing is degraded', + async () => { + // One small repository: every deny is precise. The links are what + // the precise denies cannot land on, so the git directory holding + // one, and the `modules` holding the other, are denied whole - and + // the repository still reads. + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + const gitDir = join(checkout, '.git') + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const shared = join(checkout, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const outside = makeGitDir(join(dir, 'elsewhere')) + const entry = join(gitDir, 'modules', 'lib') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + symlinkSync(outside, entry) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(shared, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo HOOKS_LINK_UNLINKABLE; ` + + `! mv ${join(gitDir, 'hooks')} ${join(gitDir, 'hooks.moved')} || echo HOOKS_LINK_RENAMABLE; ` + + `! rm -f ${entry} || echo MODULES_LINK_UNLINKABLE; ` + + `! echo x > ${join(outside, 'hooks', 'post-commit')} || echo LINKED_GIT_DIR_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout, outside], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_LINK_UNLINKABLE') + expect(result.stdout).not.toContain('HOOKS_LINK_RENAMABLE') + expect(result.stdout).not.toContain('MODULES_LINK_UNLINKABLE') + expect(result.stdout).not.toContain('LINKED_GIT_DIR_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) + it.if(LIVE)( 'lets a nested repository take its first submodule while the wrap is degrading', async () => { From ae0f0cb53ac92645c23b152792b4242b15a69cbf Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 22:04:43 +0000 Subject: [PATCH 035/141] README: what a symlinked git entry is denied by, and what it costs Says that such an entry is two things and both are denied, which of the two each backend's own deny covers, what a dangling link and an unclassifiable entry get, and what a whole-directory deny costs in that one repository - `git status`, `log` and `diff` keep working, `git add` and `git commit` do not. Adds the planted symlink to what a command can leave the commands after it, beside the wide `.git/modules`, and states the limit that remains: a `.git` that is itself a link, or a `.git/modules` that is, has what it points at denied and the link itself in the working tree, where there is nothing to bind over. --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index edbf7b3b6..474953cf4 100644 --- a/README.md +++ b/README.md @@ -733,11 +733,12 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. +- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, a Seatbelt filter matches the path as an unlink or a rename names it. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while the profile fits; - 2. a submodule git directory that does not fit is denied WHOLE, one read-only bind — git writes inside that submodule fail read-only, its `objects`, `refs` and `index` with them, and nothing outside it changes. A checked-out submodule's `.git` pointer names the same git directory, so its copy of those denies goes with them; the pointer file keeps its own deny, which names a file and has nothing to collapse into; + 2. a submodule git directory that does not fit is denied WHOLE, one read-only bind — git writes inside that submodule fail read-only, its `objects`, `refs` and `index` with them, and nothing outside it changes. A checked-out submodule's `.git` pointer names the same git directory, so its copy of those denies goes with them; the pointer file keeps its own deny, which names a file and has nothing to collapse into, and so does what an entry that is a symlink leads to, which lies outside the git directory and no bind of it covers; 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule really under it goes with it. An entry the walk reached through a symlink can be anywhere, and one whose real path is outside keeps the deny it had: a bind of `modules` does not cover it. With one write root and no network restrictions, that starts at about 590 submodule git directories, or about 425 where each is checked out. A `SRT_DEBUG` warning says what was degraded, at which level, and for which repositories; there is no other wrap-time channel to report it on. The Linux violation monitor works the same denies out for itself, once for the session and with nothing degraded, so a command that forces a degrade later leaves it naming the path inside a submodule git directory the wrap has by then denied whole — the write is refused either way, and it is the path in the report that is stale, never what bubblewrap enforces. `too_many_arguments` is what is left for a profile that does not fit with every submodule deny degraded: measured at about 1475 CHECKED-OUT submodules, whose pointer files and pins no degrading saves, and at a few hundred separate nested repositories, whose own four denies are not submodule denies. Bare submodule git directories do not reach it at any count, multi-segment (`vendor/lib`) and nested names included: the one bind over `.git/modules` is where they end. @@ -760,7 +761,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `too_many_arguments` where a command plants enough CHECKED-OUT submodules — a `.git` pointer file and the git directory it names — that the profile does not fit with every submodule deny degraded, measured at about 1475 of them. -A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. +A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: @@ -768,7 +769,10 @@ A directory the command makes unreadable does **not** refuse anything: it is den - `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason; - `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; -- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. +- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`; +- on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**. + +**Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`. A `.git` that is itself a symlink to a git directory elsewhere, or a `.git/modules` that is, is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. **Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. From 070f06161a121864a72e008bc934371b9879af30 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 22:09:02 +0000 Subject: [PATCH 036/141] Keep a repository denied whole for its own entry out of the submodule plan A git directory denied whole because one of ITS OWN entries is a symlink is not a submodule deny: it holds `.git/modules` rather than sitting under it, and a bind of `modules` would never cover it. Counting it as one gave a repository with no submodules at all a place in the degrade plan, and with enough pressure elsewhere in the wrap the modules level would then have denied an absent `.git/modules` - a mount point planted where `git submodule add` writes. Only the whole-directory denies under `modules` go into a repository's, so a repository with nothing under it stays out of the plan as before. --- src/sandbox/linux-deny-collapse.ts | 16 +++++++++++++--- test/sandbox/mandatory-deny-paths.test.ts | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index b90f16ed1..f3c42f726 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -15,9 +15,11 @@ export interface RepositorySubmodules { /** Sorted by path, which is the order a collapse works back through. */ gitDirs: Array<{ gitDir: string } & GitDirDenies> /** - * The whole-directory denies of this repository: what the walk could not + * The whole-directory denies UNDER `modulesDir`: what the walk could not * see through, and the directories holding an entry it followed through a - * symlink. A bind of `modules` covers the ones really under it. + * symlink. A bind of `modules` covers the ones really under it. The git + * directory's own whole-deny is not one of these: it holds `modules` + * rather than sitting under it, and it is no reason to degrade anything. */ wholeDirDenies: string[] } @@ -58,7 +60,15 @@ export const NO_COLLAPSE: CollapseLevel = { export function repositorySubmodules( denies: GitDirTreeDenies, ): RepositorySubmodules | undefined { - const wholeDirDenies = [...denies.unreadableDirs, ...denies.linkedEntryDirs] + // Under `modules` only: a git directory denied whole because one of its OWN + // entries is a symlink is not a submodule deny, and a repository with + // nothing under `modules` must stay out of the plan altogether — an absent + // `.git/modules` is never denied, since a mount point planted at one stops + // `git submodule add` working in that repository. + const wholeDirDenies = [ + ...denies.unreadableDirs, + ...denies.linkedEntryDirs, + ].filter(denyPath => isAtOrUnder(denyPath, denies.modulesDir)) if (denies.submodules.length === 0 && wholeDirDenies.length === 0) { return undefined } diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 71ddccb2e..c5bd6ce6a 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -3695,6 +3695,24 @@ describe.if(isSupportedPlatform)( ) }) + it.if(!isWindows)( + 'keeps a repository denied whole for its own symlinked entry out of the plan', + () => { + // The git directory is denied whole because its `hooks` is a link. + // That is not a submodule deny, and it is no reason to give a + // repository with no submodules a `.git/modules` to degrade. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const shared = join(dir, 'repo', 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const denies = gitDirTreeDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(repositorySubmodules(denies)).toBeUndefined() + }, + ) + it.if(!isWindows)( 'keeps the deny of an entry whose real path is outside a modules directory denied whole', () => { From a300d6f15d594411372119855c22726aad9a0ef9 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 19 Sep 2026 22:14:23 +0000 Subject: [PATCH 037/141] Hold the link in a git directory a pointer leads to as well A `gitdir:` pointer's target - a linked worktree's main repository, the git directory a nested `.git` file names - is a git directory like any other, and an entry of it that is a symlink is replaceable in the same way. Its deny paths came back as a flat list, which had nowhere to carry the whole-directory deny the backend whose denies resolve needs, so that one route kept the hole the rest of this round closed. `gitFileDenies` is the same walk of a pointer and its `commondir` with the whole-directory denies kept apart, as `gitDirDenies` keeps them, and `gitFileDenyPaths` is now its deny paths - which is what macOS and the tests went on reading, unchanged. Nothing else about a pointer's target is enumerated: its own `.git/modules` is still not walked. --- src/sandbox/linux-sandbox-utils.ts | 10 ++-- src/sandbox/mandatory-deny-paths.ts | 69 +++++++++++++++-------- test/sandbox/mandatory-deny-paths.test.ts | 23 ++++++++ 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index bb60d5077..a9b725d7f 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -35,7 +35,7 @@ import { gitDirDenies, gitDirTreeDenies, gitDirTreeDenyPaths, - gitFileDenyPaths, + gitFileDenies, gitRedirectPlaceholder, } from './mandatory-deny-paths.js' import type { GitDirTreeDenies } from './mandatory-deny-paths.js' @@ -691,7 +691,8 @@ function cwdMandatoryDenyPlan( } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. - denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig)) + const pointer = gitFileDenies(dotGitPath, allowGitConfig) + denyPaths.push(...pointer.denyPaths, ...pointer.linkedEntryDirs) } return { denyPaths, repositories } @@ -984,9 +985,10 @@ async function linuxGetMandatoryDenyPaths( denyGitDir(path.join(cwd, ...relative.slice(0, gitAt + 1))) } else if (relative.length > 1) { // cwd's own pointer file is handled above, before the scan. - denyPaths.push( - ...asProfileRefusal(() => gitFileDenyPaths(match, allowGitConfig)), + const pointer = asProfileRefusal(() => + gitFileDenies(match, allowGitConfig), ) + denyPaths.push(...pointer.denyPaths, ...pointer.linkedEntryDirs) } } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index f8c145e08..8ad75e7cf 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -400,7 +400,32 @@ export function gitFileDenyPaths( gitFile: string, allowGitConfig: boolean, ): string[] { + return gitFileDenies(gitFile, allowGitConfig).denyPaths +} + +/** + * {@link gitFileDenyPaths} with the whole-directory denies kept apart, as + * {@link gitDirDenies} keeps them: a git directory a pointer leads to is a + * git directory like any other, so an entry of it that is a symlink needs + * the directory holding the link denied whole on the backend whose denies + * resolve. Nothing else is enumerated through a pointer — a target's own + * `.git/modules` is not walked — so this is what it adds. + */ +export function gitFileDenies( + gitFile: string, + allowGitConfig: boolean, +): GitDirDenies { const denyPaths = [gitFile] + const linkedEntryDirs: string[] = [] + const targetDenies = ( + target: string, + kind: GitDirKind, + source: string, + ): void => { + const denies = gitDirTargetDenies(target, kind, allowGitConfig, source) + denyPaths.push(...denies.denyPaths) + linkedEntryDirs.push(...denies.linkedEntryDirs) + } try { const pointer = readGitMetadataFile(gitFile) let target: string | undefined @@ -414,18 +439,18 @@ export function gitFileDenyPaths( `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, { level: 'warn' }, ) - return denyPaths + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } case 'none': break } - if (target === undefined) return denyPaths + if (target === undefined) { + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } + } const gitDirs = gitMetadataTargets(path.dirname(gitFile), target).map( gitDir => ({ gitDir, kind: gitDirKind(gitDir) }), ) for (const { gitDir, kind } of gitDirs) { - denyPaths.push( - ...gitDirTargetDenyPaths(gitDir, kind, allowGitConfig, gitFile), - ) + targetDenies(gitDir, kind, gitFile) } // A linked worktree's git directory holds the path of the main one, whose @@ -467,14 +492,7 @@ export function gitFileDenyPaths( if (commonTarget === undefined) continue for (const commonDir of gitMetadataTargets(gitDir, commonTarget)) { if (commonDir === gitDir) continue - denyPaths.push( - ...gitDirTargetDenyPaths( - commonDir, - gitDirKind(commonDir), - allowGitConfig, - commonFile, - ), - ) + targetDenies(commonDir, gitDirKind(commonDir), commonFile) } } } catch (err) { @@ -482,7 +500,7 @@ export function gitFileDenyPaths( // A dangling pointer names nothing git would read. A pointer this process // cannot read is one the host's git cannot read either, so the file // itself is the whole deny; an unreadable TARGET is denied whole by - // gitDirTargetDenyPaths instead. + // gitDirTargetDenies instead. if (!isAbsenceError(err)) { logForDebugging( `[Sandbox] Could not follow ${gitFile}, denying only the file itself: ${err}`, @@ -490,7 +508,7 @@ export function gitFileDenyPaths( ) } } - return denyPaths + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } } /** @@ -745,42 +763,47 @@ function linkTarget(link: string): string | undefined { } /** - * Deny paths for a directory a `gitdir:` or `commondir` names. An existing + * What a directory a `gitdir:` or `commondir` names is denied by. An existing * directory that is not a git directory is left alone: file content must not * be able to point the deny list at, say, a Rails `config/`. An absent one is * still denied, so the sandboxed command cannot create the target and fill it * with hooks before the host's git first uses it. */ -function gitDirTargetDenyPaths( +function gitDirTargetDenies( target: string, kind: GitDirKind, allowGitConfig: boolean, source: string, -): string[] { +): GitDirDenies { + const only = (denyPaths: string[]): GitDirDenies => ({ + denyPaths, + escapingDenyPaths: [], + linkedEntryDirs: [], + }) switch (kind) { case 'git-dir': case 'absent': - return gitDirDenies(target, allowGitConfig).denyPaths + return gitDirDenies(target, allowGitConfig) case 'unreadable': { const denied = deepestReachableAncestor(target) ?? target logForDebugging( `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, { level: 'warn' }, ) - return [denied] + return only([denied]) } case 'unusable': logForDebugging( `[Sandbox] ${source} names ${target}, which is longer than the filesystem allows: no file can be there for git to read or for a command to create; denying only ${source}`, { level: 'warn' }, ) - return [] + return only([]) case 'other': logForDebugging( `[Sandbox] ${source} names ${target}, which is not a git directory; denying only ${source}`, { level: 'warn' }, ) - return [] + return only([]) } } @@ -936,7 +959,7 @@ function gitMetadataTargets(base: string, target: string): string[] { * cannot. A component that cannot be walked — missing, unreadable, or a loop * past the hop limit — ends it, since the kernel cannot traverse one either * and nothing beyond it can redirect the path; the rest is taken as written - * and classified by {@link gitDirTargetDenyPaths} like any other target. + * and classified by {@link gitDirTargetDenies} like any other target. */ function physicalPath(base: string, target: string): string { let current = path.isAbsolute(target) ? path.parse(target).root : base diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index c5bd6ce6a..194e78a1c 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -49,6 +49,7 @@ import { gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, + gitFileDenies, gitFileDenyPaths, gitRedirectPlaceholder, submoduleGitDirs, @@ -2514,6 +2515,28 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) + it.if(!isWindows)( + 'denies whole a git directory a pointer leads to, where an entry of it is a symlink', + () => { + // A pointer's target is a git directory like any other: the link + // inside it is replaceable where nothing holds the directory around + // it, and the backend whose denies resolve needs that held. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const shared = join(dir, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const pointer = makePointer('checkout', '../gitdir') + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toContain(shared) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + // The list a backend that needs none of that emits is what it was. + expect(gitFileDenyPaths(pointer, false)).toEqual(denies.denyPaths) + }, + ) + it.if(!isWindows)( 'denies the directory holding a symlinked modules entry, and where a dangling one lands', () => { From 516e012d649c0c0d011d8c093b913482dcff688e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:02:37 +0000 Subject: [PATCH 038/141] Hold every hop of a symlinked git directory entry's chain A git directory entry that is a symlink was held at its two ends: the entry's own path, and the target the chain lands on. A chain can be longer than that. With `hooks -> ../hooks-link` and `hooks-link -> .githooks`, or with `hooks -> ../shared/hooks` where `shared` is a symlinked directory, the link BETWEEN the two ends sits in an ordinary directory of the work tree. A command that retargets it, or renames it aside and puts its own directory in its place, moves where the chain resolves without touching either end, and the host's git follows the chain the next time it runs. The chain walk now hands back every hop it went through - each link's own path and the directory holding it - rather than only the landing, and each hop is held the way the entry is. Its own path becomes a deny path, which is what holds it where a filter matches the name a rename or an unlink uses. On Linux the directory HOLDING it is denied whole, as the git directory is denied whole for the entry itself, and charged to the mount budget like any other deny; there the hop's own path is dropped instead, because a bind at a link lands on what the link leads to, which for a symlinked directory component is a whole directory nothing asked to deny. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only from the initial `--ro-bind / /` anyway. Where the directory holding a hop is the working directory or a write root there is nothing to bind over it - the bind would take read-only the whole tree the sandbox exists to let the command write - and a repository laid out that way is one the caller had before this library saw it, so the command is not refused for it either. One warning per wrap names the entry and the hop and says it can be retargeted from inside the sandbox. Failing closed is unchanged in kind. A hop that cannot be read leaves the rest of the chain unknown, so the git directory is denied whole as it is for an entry that cannot be classified, and every hop the walk did read is held all the same; a chain past the kernel's hop limit reaches nothing, and the whole deny is still the whole answer. The degrade warning also stops counting what a whole-directory deny of the same plan had already taken. A `.git/modules` denied whole for a symlinked entry leaves every submodule under it read-only before anything is degraded, so degrading those takes nothing away, and saying otherwise named a cost the profile never paid. --- src/sandbox/linux-deny-collapse.ts | 68 ++++++-- src/sandbox/linux-sandbox-utils.ts | 171 +++++++++++++++--- src/sandbox/mandatory-deny-paths.ts | 258 ++++++++++++++++++++++++---- src/sandbox/sandbox-manager.ts | 8 +- 4 files changed, 430 insertions(+), 75 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index f3c42f726..be8d16982 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -1,5 +1,9 @@ import * as fs from 'fs' -import type { GitDirDenies, GitDirTreeDenies } from './mandatory-deny-paths.js' +import type { + GitDirDenies, + GitDirTreeDenies, + GitEntryChainHop, +} from './mandatory-deny-paths.js' import { isAtOrUnder } from './sandbox-utils.js' /** @@ -36,6 +40,17 @@ export interface SubmoduleDenyPlan { * absent one is never denied and never costs anything. */ repositories: RepositorySubmodules[] + /** + * Every symlink BETWEEN a git directory entry and what it leads to that the + * wrap found, in the order it found them. What this backend takes from one + * is the directory holding it, the only thing a bind can hold a link by; + * the link's own path is a deny path for the backend that holds a link by + * its name and is dropped here. Which holders a wrap can actually bind + * depends on where the write roots are, so it is decided there rather than + * here and nothing collapses them: see `chainHopDenies` and + * `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. + */ + chainHops: GitEntryChainHop[] } /** @@ -207,33 +222,62 @@ function stepBack( return Math.max(taken, 1) } -/** One line naming what `level` degraded, for the wrap's warning. */ +/** + * One line naming what `level` degraded, for the wrap's warning. + * + * What a whole-directory deny of the plan's own already covers is left out of + * it: a submodule under a `.git/modules` denied whole for an entry that is a + * symlink is read-only whole before anything is degraded, so its precise + * denies were never what held it and a collapse of it takes nothing away. + * Saying otherwise names a cost the profile did not pay here. + */ export function describeCollapse( plan: SubmoduleDenyPlan, level: CollapseLevel, ): string { const order = collapsibleGitDirs(plan) + const alreadyWhole = coveredByWholeDirDeny(plan) const parts: string[] = [] if (level.wholeGitDirs > 0) { - const collapsed = order.slice(order.length - level.wholeGitDirs) - const repositories = [ - ...new Set(collapsed.map(entry => entry.modulesDir)), - ].join(', ') - parts.push( - `${level.wholeGitDirs} of the ${order.length} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, - ) + const collapsed = order + .slice(order.length - level.wholeGitDirs) + .filter(entry => !alreadyWhole(entry.gitDir)) + if (collapsed.length > 0) { + const repositories = [ + ...new Set(collapsed.map(entry => entry.modulesDir)), + ].join(', ') + parts.push( + `${collapsed.length} of the ${order.length} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, + ) + } } if (level.wholeModulesDirs > 0) { const collapsed = plan.repositories .slice(plan.repositories.length - level.wholeModulesDirs) .map(repository => repository.modulesDir) - parts.push( - `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${collapsed.length === 1 ? 'it' : 'them'}`, - ) + .filter(modulesDir => !alreadyWhole(modulesDir)) + if (collapsed.length > 0) { + parts.push( + `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${collapsed.length === 1 ? 'it' : 'them'}`, + ) + } } return parts.join('; ') } +/** Whether a path is read-only whole in this plan whatever it degrades: a + * directory the walk could not see through, or one holding an entry that is + * a symlink, covers everything really under it. */ +function coveredByWholeDirDeny( + plan: SubmoduleDenyPlan, +): (candidate: string) => boolean { + const covers = plan.repositories + .flatMap(repository => repository.wholeDirDenies) + .map(underDirectory) + if (covers.length === 0) return () => false + return candidate => covers.some(under => under(candidate)) +} + /** Every submodule git directory the plan may degrade, in the order a * collapse works back through: by repository as served, each repository's * own sorted. */ diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index a9b725d7f..826644d18 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -38,7 +38,10 @@ import { gitFileDenies, gitRedirectPlaceholder, } from './mandatory-deny-paths.js' -import type { GitDirTreeDenies } from './mandatory-deny-paths.js' +import type { + GitDirTreeDenies, + GitEntryChainHop, +} from './mandatory-deny-paths.js' import type { CollapseLevel, RepositorySubmodules, @@ -652,11 +655,13 @@ function denyScanFailed( */ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, + allowWritePaths: readonly string[] = [], ): string[] { - return collapsedDenyPaths( - cwdMandatoryDenyPlan(allowGitConfig, undefined), - NO_COLLAPSE, - ) + const plan = cwdMandatoryDenyPlan(allowGitConfig, undefined) + return [ + ...collapsedDenyPaths(plan, NO_COLLAPSE), + ...chainHopDenies(plan.chainHops, allowWritePaths).held, + ] } /** {@link linuxGetCwdMandatoryDenyPaths} with the working directory's own @@ -669,6 +674,7 @@ function cwdMandatoryDenyPlan( const cwd = process.cwd() const denyPaths = cwdDangerousDenyPaths(cwd) const repositories: RepositorySubmodules[] = [] + const chainHops: GitEntryChainHop[] = [] const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -680,35 +686,134 @@ function cwdMandatoryDenyPlan( // could not be looked at, which is no reason to skip the enumeration — // deny it whole instead. if (!isAbsenceErrno(err)) { - return { denyPaths: [...denyPaths, dotGitPath], repositories } + return { denyPaths: [...denyPaths, dotGitPath], repositories, chainHops } } } if (dotGitStat?.isDirectory()) { const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) + chainHops.push(...denies.chainHops) const repository = repositorySubmodules(denies) if (repository !== undefined) repositories.push(repository) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. const pointer = gitFileDenies(dotGitPath, allowGitConfig) - denyPaths.push(...pointer.denyPaths, ...pointer.linkedEntryDirs) + denyPaths.push( + ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), + ...pointer.linkedEntryDirs, + ) + chainHops.push(...pointer.chainHops) } - return { denyPaths, repositories } + return { denyPaths, repositories, chainHops } } /** * {@link gitDirTreeDenyPaths} plus the directories that have to be denied - * WHOLE because they hold an entry that is a symlink. Only this backend - * carries those: a bind lands on what a deny path resolves to, so the link's - * own path keeps nothing and the directory around it is the handle on it, - * while a Seatbelt filter matches the path as a rename or an unlink names it - * and needs no such thing (see `gitDirDenies` in - * src/sandbox/mandatory-deny-paths.ts). + * WHOLE because they hold an entry that is a symlink, and minus the deny + * paths that name a symlink itself. Both follow from the same thing: a bind + * lands on what a deny path resolves to, so the link's own path keeps + * nothing and the directory around it is the handle on it, while a Seatbelt + * filter matches the path as a rename or an unlink names it and needs + * neither (see `gitDirDenies` in src/sandbox/mandatory-deny-paths.ts). */ function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { - return [...gitDirTreeDenyPaths(denies), ...denies.linkedEntryDirs] + return [ + ...withoutChainHopLinks(gitDirTreeDenyPaths(denies), denies.chainHops), + ...denies.linkedEntryDirs, + ] +} + +/** + * `denyPaths` without the ones naming an intermediate hop of a chain. A bind + * there lands on whatever the link leads to — for a symlinked directory + * component a whole directory nothing asked to deny, and for a `.git/modules` + * entry the git directory it reaches — while holding the link itself, which + * is the point, is what the hop's HOLDER is denied whole for. + */ +function withoutChainHopLinks( + denyPaths: string[], + chainHops: readonly GitEntryChainHop[], +): string[] { + if (chainHops.length === 0) return denyPaths + const links = new Set(chainHops.map(hop => hop.link)) + return denyPaths.filter(denyPath => !links.has(denyPath)) +} + +/** + * Which of the symlinks BETWEEN a git directory entry and what it leads to + * this backend can hold, and which it cannot. + * + * A bind cannot be put on a link — the destination resolves — so the + * directory holding one is the whole handle, exactly as the git directory is + * the handle on the entry's own link. `held` is those directories, one + * read-only bind each, charged to the mount budget like any other deny. + * + * Three kinds are not in it. A hop a whole-directory deny already covers + * names no holder at all. A hop no allowed write path contains is read-only + * from the initial `--ro-bind / /` already, and a bind of the directory + * around it would buy nothing. A hop whose holder IS a write root or the + * working directory, or sits above one, has nothing that can be bound over it + * — the bind would take the whole tree read-only, and a repository laid out + * that way (`.git/hooks -> ../hooks-link`, with `hooks-link` in the + * repository root) is one the caller had before this library saw it, so + * refusing every command in it is the wrong answer too. That last kind comes + * back in `unheld`, for the warning that has to stand in for a bind. + */ +function chainHopDenies( + chainHops: readonly GitEntryChainHop[], + allowWritePaths: readonly string[], +): { held: string[]; unheld: GitEntryChainHop[] } { + const held = new Set() + const unheld: GitEntryChainHop[] = [] + const cwd = process.cwd() + for (const hop of chainHops) { + const holder = hop.holder + if (holder === undefined) continue + if (!allowWritePaths.some(allowed => isAtOrUnder(holder, allowed))) { + continue + } + if ( + isAtOrUnder(cwd, holder) || + allowWritePaths.some(allowed => isAtOrUnder(allowed, holder)) + ) { + unheld.push(hop) + continue + } + held.add(holder) + } + return { held: [...held], unheld } +} + +/** + * `plan` with the directories holding a chain hop this backend can bind added + * to its deny paths, and one warning per wrap for the hops it cannot bind — + * the only channel a wrap has to say so. Ordinary trees have no hops at all + * and pay nothing here. + */ +function withChainHopDenies( + plan: SubmoduleDenyPlan, + allowWritePaths: readonly string[], +): SubmoduleDenyPlan { + if (plan.chainHops.length === 0) return plan + const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) + if (unheld.length > 0) { + const told = unheld + .map(hop => `${hop.entry} reaches what it denies through ${hop.link}`) + .join('; ') + const holders = [...new Set(unheld.map(hop => hop.holder))].join(', ') + logForDebugging( + `[Sandbox Linux] ${told}. A read-only bind of the directory holding such a link is the only thing that holds it, and ${holders} is the working directory or a path this command may write, which binding read-only would take the whole tree with: a command in this sandbox can point the link somewhere else, and a git run on the host afterwards follows it there. Moving the link inside the git directory, or a denyWrite entry naming the directory holding it, closes that.`, + { level: 'warn' }, + ) + } + if (held.length === 0) return plan + const denied = new Set(plan.denyPaths) + return { + ...plan, + denyPaths: [...plan.denyPaths, ...held.filter(dir => !denied.has(dir))], + } } /** @@ -740,9 +845,12 @@ function cwdDangerousDenyPaths(cwd: string): string[] { * denied whole. The write is refused either way; the path in the report is * the one inside it. */ -export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { +export function linuxGetMonitorCwdDenyPaths( + allowGitConfig: boolean, + allowWritePaths: readonly string[] = [], +): string[] { try { - return linuxGetCwdMandatoryDenyPaths(allowGitConfig) + return linuxGetCwdMandatoryDenyPaths(allowGitConfig, allowWritePaths) } catch (err) { const cwd = process.cwd() logForDebugging( @@ -751,7 +859,7 @@ export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { ) return [ ...cwdDangerousDenyPaths(cwd), - ...monitorDotGitDenyPaths(cwd, allowGitConfig), + ...monitorDotGitDenyPaths(cwd, allowGitConfig, allowWritePaths), ] } } @@ -774,6 +882,7 @@ function monitorFallbackReason(cwd: string, err: unknown): string { function monitorDotGitDenyPaths( cwd: string, allowGitConfig: boolean, + allowWritePaths: readonly string[], ): string[] { const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -789,7 +898,11 @@ function monitorDotGitDenyPaths( if (dotGitStat.isFile()) return [dotGitPath] if (dotGitStat.isDirectory()) { const denies = gitDirDenies(dotGitPath, allowGitConfig) - return [...denies.denyPaths, ...denies.linkedEntryDirs] + return [ + ...withoutChainHopLinks(denies.denyPaths, denies.chainHops), + ...denies.linkedEntryDirs, + ...chainHopDenies(denies.chainHops, allowWritePaths).held, + ] } return [] } @@ -988,7 +1101,11 @@ async function linuxGetMandatoryDenyPaths( const pointer = asProfileRefusal(() => gitFileDenies(match, allowGitConfig), ) - denyPaths.push(...pointer.denyPaths, ...pointer.linkedEntryDirs) + denyPaths.push( + ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), + ...pointer.linkedEntryDirs, + ) + plan.chainHops.push(...pointer.chainHops) } } @@ -1006,6 +1123,7 @@ async function linuxGetMandatoryDenyPaths( gitDirTreeDenies(gitDir, allowGitConfig, { deadline: walkDeadline() }), ) denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) + plan.chainHops.push(...denies.chainHops) const repository = repositorySubmodules(denies) if (repository !== undefined) plan.repositories.push(repository) } @@ -2384,11 +2502,14 @@ async function generateFilesystemArgs( const plan = writeConfig === undefined ? undefined - : await linuxGetMandatoryDenyPaths( - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, + : withChainHopDenies( + await linuxGetMandatoryDenyPaths( + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + ), + (writeConfig.allowOnly ?? []).map(p => normalizePathForSandbox(p)), ) let level = plan === undefined diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 8ad75e7cf..28bc222fd 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -104,6 +104,65 @@ export class SubmoduleWalkBudgetError extends Error { } } +/** + * One symlink a chain walk went through, and the directory holding it. + * + * A bind cannot be put on a link — mounting at its own path resolves it — so + * the directory around it is the only handle the backend whose denies resolve + * has on it, while the backend that matches the name an unlink or a rename + * uses wants the link's own path. Both are here, so each backend can take + * what it holds a link by. + */ +interface ChainHop { + /** The link's own path, as the walk reached it. */ + link: string + /** The directory the link sits in. */ + holder: string +} + +/** Where a chain walk landed, and what it went through; see + * {@link resolveChain}. */ +interface ResolvedChain { + landing: string + /** In the order the walk met them: the first is the path the caller asked + * about, the rest are what lies between it and the landing. */ + hops: ChainHop[] +} + +/** + * A symlink BETWEEN a git directory entry and what it leads to: a second hop + * of the entry's own link, or a symlinked directory component on the way. + * + * Neither end of the chain covers one. The entry's deny holds the entry and + * the landing's deny holds the landing, while a hop in between sits in an + * ordinary directory of the work tree: a command that retargets it, or + * renames it aside and puts its own directory there, moves what the chain + * resolves to without touching either end, and the host's git follows the + * chain afterwards. + */ +export interface GitEntryChainHop { + /** The git directory entry whose chain goes through it. */ + entry: string + /** + * The link's own path, and a deny path of its own: that is what holds it + * on the backend which matches the name a rename or an unlink uses. The + * backend whose denies RESOLVE gets nothing from it — a bind at a link + * lands on what the link leads to, which for a symlinked directory + * component is a whole directory nothing asked to deny — and drops it: see + * `linuxGitDirTreeDenyPaths` in src/sandbox/linux-sandbox-utils.ts. + */ + link: string + /** + * The directory holding the link: what that backend has to deny WHOLE to + * hold it, as it denies the entry's own git directory whole. Undefined + * where such a deny already covers the link — a hop inside the git + * directory, or inside the directory holding a `.git/modules` entry. Where + * it is a write root or the working directory there is nothing to bind + * over it; the Linux backend's own note says what happens then. + */ + holder: string | undefined +} + /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { /** @@ -139,6 +198,13 @@ export interface SubmoduleScan { * and without duplicates. */ linkedEntryDirs: string[] + /** + * The hops BETWEEN an entry the walk followed through a symlink and what + * it lands on. One inside the directory holding that entry names no holder + * of its own: `linkedEntryDirs` denies that whole already. See + * {@link GitEntryChainHop}. + */ + chainHops: GitEntryChainHop[] } /** @@ -158,6 +224,10 @@ export interface GitDirTreeDenies { unreadableDirs: string[] /** Directories holding a symlinked entry; see {@link SubmoduleScan}. */ linkedEntryDirs: string[] + /** Every hop between a symlinked entry of this tree and what it leads to, + * the git directory's own entries, the walked `.git/modules` entries and + * the submodules' own alike. See {@link GitEntryChainHop}. */ + chainHops: GitEntryChainHop[] } /** @@ -247,6 +317,15 @@ export interface GitDirDenies { * an entry of a `.git/modules`. */ linkedEntryDirs: string[] + /** + * The hops between a symlinked entry of this git directory and what it + * leads to. Each hop's own path is in `denyPaths` already, and in + * `escapingDenyPaths` where no bind of the git directory covers it; what + * is here besides is the directory holding each, which is what the backend + * whose denies resolve has to deny whole to hold the link. See + * {@link GitEntryChainHop}. + */ + chainHops: GitEntryChainHop[] } /** @@ -268,6 +347,14 @@ export interface GitDirDenies { * anything else that writes the index or an object fail read-only until the * link is gone. * + * A chain with more than one link in it is more than two things, and the hops + * BETWEEN the entry and the landing are neither of them: the entry's deny + * holds the entry, the landing's deny holds the landing, and a hop in an + * ordinary directory of the work tree moves what the chain resolves to + * without touching either end. Each hop's own path is therefore a deny path + * as well, and `chainHops` carries the directory holding each for the backend + * that needs one — see {@link GitEntryChainHop}. + * * An ordinary git directory costs one lstat per entry and is denied by exactly * what it always was. */ @@ -277,7 +364,36 @@ export function gitDirDenies( ): GitDirDenies { const denyPaths: string[] = [] const escapingDenyPaths: string[] = [] + const chainHops: GitEntryChainHop[] = [] let denyWhole = false + // Resolved on the first symlinked entry and not before: an ordinary git + // directory must not pay a realpath for a question it never asks. + let realGitDir: string | undefined + const insideGitDir = (candidate: string): boolean => { + realGitDir ??= realPathOrSelf(gitDir) + return isAtOrUnder(candidate, realGitDir) + } + // The first hop IS the entry: its own path is a deny path already, and the + // directory holding it is this git directory, denied whole below. The rest + // lie between the two ends, and neither end holds them. + const holdChain = (entryPath: string, hops: ChainHop[]): void => { + for (const hop of hops.slice(1)) { + // A deny path like any other, for the backend that holds a link by its + // own name; the one that resolves its denies drops it again. + denyPaths.push(hop.link) + if (insideGitDir(hop.link)) { + chainHops.push({ entry: entryPath, link: hop.link, holder: undefined }) + continue + } + // No bind of the git directory covers it, so a degrade must not drop it. + escapingDenyPaths.push(hop.link) + chainHops.push({ + entry: entryPath, + link: hop.link, + holder: insideGitDir(hop.holder) ? undefined : hop.holder, + }) + } + } for (const entryPath of gitDirDenyPaths(gitDir, allowGitConfig)) { const entry = gitDirEntry(entryPath) switch (entry.kind) { @@ -290,9 +406,10 @@ export function gitDirDenies( // same place, while on macOS a filter matches where the write lands // and the target is the only one of the two that names it. denyPaths.push(entryPath, entry.target) - if (!isAtOrUnder(entry.target, realPathOrSelf(gitDir))) { + if (!insideGitDir(entry.target)) { escapingDenyPaths.push(entry.target) } + holdChain(entryPath, entry.hops) break case 'unreachable': // A loop, or a name no file can occupy: there is nothing behind it to @@ -303,10 +420,13 @@ export function gitDirDenies( case 'unknown': // There and not classifiable. Fail closed both ways: the deny the // entry has anyway, kept through a degrade in case it is a link, and - // the whole-directory deny one would need. + // the whole-directory deny one would need. The hops behind it that + // COULD be read are held as a resolved chain's are; what lies past + // the one that could not is what the whole deny stands in for. denyWhole = true denyPaths.push(entryPath) escapingDenyPaths.push(entryPath) + holdChain(entryPath, entry.hops) break } } @@ -314,6 +434,7 @@ export function gitDirDenies( denyPaths, escapingDenyPaths, linkedEntryDirs: denyWhole ? [gitDir] : [], + chainHops, } } @@ -321,12 +442,13 @@ export function gitDirDenies( type GitDirEntry = /** Not a symlink, or nothing at all: the deny it has always had. */ | { kind: 'plain' } - /** A symlink, resolved or dangling, that leads to `target`. */ - | { kind: 'link'; target: string } + /** A symlink, resolved or dangling, leading to `target` through `hops`. */ + | { kind: 'link'; target: string; hops: ChainHop[] } /** A symlink that reaches nothing: a loop, or a name no file can occupy. */ | { kind: 'unreachable' } - /** There, and what it is could not be worked out. */ - | { kind: 'unknown' } + /** There, and what it is could not be worked out; `hops` is as much of the + * chain as the walk read before it stopped. */ + | { kind: 'unknown'; hops: ChainHop[] } function gitDirEntry(entryPath: string): GitDirEntry { let stats: fs.Stats @@ -343,9 +465,17 @@ function gitDirEntry(entryPath: string): GitDirEntry { `[Sandbox] Could not tell whether ${entryPath} is a symlink (${err}); denying the git directory holding it whole, and the entry as it stands`, { level: 'warn' }, ) - return { kind: 'unknown' } + return { kind: 'unknown', hops: [] } } if (!stats.isSymbolicLink()) return { kind: 'plain' } + // Walked from the directory holding the entry, itself resolved first, so + // that a symlink ABOVE the git directory - a /tmp, a home reached through a + // link - counts as where this repository lives and not as a hop of this + // chain. The landing is what walking the whole path from the root gives. + const chain = resolveChain( + physicalPath(path.parse(entryPath).root, path.dirname(entryPath)), + path.basename(entryPath), + ) try { fs.statSync(entryPath) } catch (err) { @@ -355,19 +485,16 @@ function gitDirEntry(entryPath: string): GitDirEntry { } if (!isAbsenceErrno(err)) { logForDebugging( - `[Sandbox] Could not follow ${entryPath} (${err}); denying the git directory holding it whole, and the entry as it stands`, + `[Sandbox] Could not follow ${entryPath} (${err}); denying the git directory holding it whole, the entry as it stands, and the hops of its chain that could be read`, { level: 'warn' }, ) - return { kind: 'unknown' } + return { kind: 'unknown', hops: chain.hops } } // Dangling, which is no reason to pass over it: nothing is there yet and // a command can put a hooks directory there for the host's git to find, // so the deny goes where the link lands and blocks creating it. } - return { - kind: 'link', - target: physicalPath(path.parse(entryPath).root, entryPath), - } + return { kind: 'link', target: chain.landing, hops: chain.hops } } /** @@ -417,6 +544,7 @@ export function gitFileDenies( ): GitDirDenies { const denyPaths = [gitFile] const linkedEntryDirs: string[] = [] + const chainHops: GitEntryChainHop[] = [] const targetDenies = ( target: string, kind: GitDirKind, @@ -425,6 +553,7 @@ export function gitFileDenies( const denies = gitDirTargetDenies(target, kind, allowGitConfig, source) denyPaths.push(...denies.denyPaths) linkedEntryDirs.push(...denies.linkedEntryDirs) + chainHops.push(...denies.chainHops) } try { const pointer = readGitMetadataFile(gitFile) @@ -439,12 +568,12 @@ export function gitFileDenies( `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, { level: 'warn' }, ) - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } case 'none': break } if (target === undefined) { - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } const gitDirs = gitMetadataTargets(path.dirname(gitFile), target).map( gitDir => ({ gitDir, kind: gitDirKind(gitDir) }), @@ -508,7 +637,7 @@ export function gitFileDenies( ) } } - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs } + return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } /** @@ -545,17 +674,33 @@ export function gitDirTreeDenies( ...modules.linkedEntryDirs, ...submodules.flatMap(submodule => submodule.linkedEntryDirs), ], + chainHops: [ + ...own.chainHops, + ...modules.chainHops, + ...submodules.flatMap(submodule => submodule.chainHops), + ], } } /** {@link gitDirTreeDenies} as one list, which is what a backend with no * ceiling to stay under emits. */ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { - return [ + const denyPaths = [ ...denies.ownDenyPaths, ...denies.unreadableDirs, ...denies.submodules.flatMap(submodule => submodule.denyPaths), ] + // A hop of an entry under `.git/modules` has no other list to sit in, while + // a hop of a git directory's OWN entry is already in that directory's deny + // paths: named once either way, since a duplicate literal buys a backend + // nothing. + const named = new Set(denyPaths) + for (const hop of denies.chainHops) { + if (named.has(hop.link)) continue + named.add(hop.link) + denyPaths.push(hop.link) + } + return denyPaths } /** @@ -583,6 +728,7 @@ export function submoduleGitDirs( gitDirs: [], unreadableDirs: new Set(), linkedEntryDirs: new Set(), + chainHops: [], } // Where the walk still has to look, and where it has already been. A // directory listed to see whether it is a git directory carries its entries @@ -643,6 +789,7 @@ export function submoduleGitDirs( gitDirs: walk.gitDirs.sort(), unreadableDirs: [...walk.unreadableDirs].sort(), linkedEntryDirs: [...walk.linkedEntryDirs].sort(), + chainHops: walk.chainHops, } } @@ -654,6 +801,7 @@ interface SubmoduleWalk { gitDirs: string[] unreadableDirs: Set linkedEntryDirs: Set + chainHops: GitEntryChainHop[] } /** What one entry of a walked directory is worth looking into. */ @@ -697,6 +845,9 @@ function listDirectory( * A command can plant such a link and leave every submodule beside it * read-only for the commands after it, which is the answer this makes to a * command that plants git directories too. + * + * A chain that goes through further links is held hop by hop as a git + * directory's own entry is: see {@link GitEntryChainHop}. */ function walkEntry( entry: fs.Dirent, @@ -705,19 +856,35 @@ function walkEntry( ): WalkEntry { if (entry.isDirectory()) return { kind: 'directory' } if (!entry.isSymbolicLink()) return { kind: 'skip' } + const holder = path.dirname(entryPath) + const chain = resolveChain( + physicalPath(path.parse(entryPath).root, holder), + entry.name, + ) + // The first hop is the entry, whose holder joins `linkedEntryDirs` below; + // the rest lie between it and what it lands on, and that deny reaches + // neither them nor the directories they sit in. + const holdChain = (): void => { + for (const hop of chain.hops.slice(1)) { + walk.chainHops.push({ + entry: entryPath, + link: hop.link, + holder: isAtOrUnder(hop.holder, holder) ? undefined : hop.holder, + }) + } + } try { if (!fs.statSync(entryPath).isDirectory()) return { kind: 'skip' } - walk.linkedEntryDirs.add(path.dirname(entryPath)) + walk.linkedEntryDirs.add(holder) + holdChain() return { kind: 'directory' } } catch (err) { if (isAbsenceError(err)) { // Dangling. Nothing is there to walk, and a command can create it: what // the link lands on is denied as a git directory that is not there yet. - walk.linkedEntryDirs.add(path.dirname(entryPath)) - return { - kind: 'dangling', - landing: physicalPath(path.parse(entryPath).root, entryPath), - } + walk.linkedEntryDirs.add(holder) + holdChain() + return { kind: 'dangling', landing: chain.landing } } if (isUnusablePathError(err)) return { kind: 'skip' } if ((err as NodeJS.ErrnoException).code === 'ELOOP') { @@ -743,7 +910,8 @@ function walkEntry( const denied = deepestReachableAncestor(linkTarget(entryPath) ?? entryPath) ?? entryPath walk.unreadableDirs.add(denied) - walk.linkedEntryDirs.add(path.dirname(entryPath)) + walk.linkedEntryDirs.add(holder) + holdChain() logForDebugging( `[Sandbox] Could not follow ${entryPath}, denying ${denied} whole: ${err}`, { level: 'warn' }, @@ -779,6 +947,7 @@ function gitDirTargetDenies( denyPaths, escapingDenyPaths: [], linkedEntryDirs: [], + chainHops: [], }) switch (kind) { case 'git-dir': @@ -953,18 +1122,23 @@ function gitMetadataTargets(base: string, target: string): string[] { /** * Where the kernel lands walking `target` from `base`, symlinks followed as - * it meets them. `path.resolve` folds `..` lexically, and `fs.realpathSync` - * folds its argument the same way before resolving it, so neither answers - * this; a walk also reaches a tail that does not exist yet, which realpath - * cannot. A component that cannot be walked — missing, unreadable, or a loop - * past the hop limit — ends it, since the kernel cannot traverse one either - * and nothing beyond it can redirect the path; the rest is taken as written - * and classified by {@link gitDirTargetDenies} like any other target. + * it meets them, and every symlink it went through on the way. + * `path.resolve` folds `..` lexically, and `fs.realpathSync` folds its + * argument the same way before resolving it, so neither answers this; a walk + * also reaches a tail that does not exist yet, which realpath cannot. A + * component that cannot be walked — missing, unreadable, or a loop past the + * hop limit — ends it, since the kernel cannot traverse one either and + * nothing beyond it can redirect the path; the rest is taken as written and + * classified by {@link gitDirTargetDenies} like any other target. + * + * The hops are what a caller protecting the landing still has to hold: each + * one is a name a command able to write the directory around it can point + * somewhere else, which moves the landing without touching either end. */ -function physicalPath(base: string, target: string): string { +function resolveChain(base: string, target: string): ResolvedChain { let current = path.isAbsolute(target) ? path.parse(target).root : base let pending = target.split('/') - let hops = 0 + const hops: ChainHop[] = [] while (pending.length > 0) { const name = pending.shift() if (name === undefined || name === '' || name === '.') continue @@ -977,23 +1151,33 @@ function physicalPath(base: string, target: string): string { try { if (fs.lstatSync(next).isSymbolicLink()) link = fs.readlinkSync(next) } catch { - return path.join(next, ...pending) + return { landing: path.join(next, ...pending), hops } } if (link === undefined) { current = next continue } + // `current` is where the walk really is, so the hop is recorded under + // the name the kernel reached it by rather than the one it was spelled + // from: that is the name an unlink or a rename of the link uses. + hops.push({ link: next, holder: current }) // A loop is where the walk stops, and what it hands back: the rest of // the path folded past it would name a directory this cannot vouch for, // while the link itself reads as unreadable and is denied whole. - hops += 1 - if (hops > MAX_SYMLINK_RESOLUTION_DEPTH) return next + if (hops.length > MAX_SYMLINK_RESOLUTION_DEPTH) { + return { landing: next, hops } + } // A link's own target is walked in its place, from the directory holding // it unless it is absolute. if (path.isAbsolute(link)) current = path.parse(link).root pending = [...link.split('/'), ...pending] } - return current + return { landing: current, hops } +} + +/** {@link resolveChain} for a caller that wants only where the walk landed. */ +function physicalPath(base: string, target: string): string { + return resolveChain(base, target).landing } /** diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 501c8a716..3113410ba 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -733,7 +733,13 @@ async function initialize( // them, so the monitor must not judge by them either. ...(config.filesystem.disabled ? [] - : linuxGetMonitorCwdDenyPaths(getAllowGitConfig())), + : linuxGetMonitorCwdDenyPaths( + getAllowGitConfig(), + // The write roots decide which directory holding a symlink + // between a git entry and its target a wrap can bind: the + // monitor has to judge by the same ones. + monitoredWrites.allowOnly.map(p => normalizePathForSandbox(p)), + )), ], ignoreViolations: config.ignoreViolations, resolveCommandText, From fb4bc9ca428e12ec2ce1262068f185b67a525afb Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:02:37 +0000 Subject: [PATCH 039/141] Tests: the hops between an entry and what it leads to Unit: a two-link chain carries the entry, the landing and the link between them, with the link's holder named as what a bind has to take; a symlinked directory component on the way is a hop like any other; a hop inside the git directory is named but needs no directory of its own; a chain of dangling links carries the hop as well as the landing, which is what the existing test for that shape now asserts; a chain running into a directory this process cannot search leaves the git directory denied whole and still holds the hop it could read; a loop has no hop to hold; and a `.git/modules` entry reached through a hop carries it as the flat deny list. macOS: the entries for a chain name the entry, the hop and the landing, and deny neither the git directory nor the directory holding the hop whole. Linux, by argv: the directory holding the hop is bound read-only where it is a subdirectory, the landing keeps its own bind, what a symlinked directory component leads to is NOT bound, and where the working directory holds the hop nothing read-only lands over it after its own write bind while the warning names the entry and the hop. Live under bubblewrap: where a subdirectory holds the hop, `ln -sfn` of it, renaming it aside, unlinking it and creating a name beside it are all refused, the entry cannot be unlinked, a write through to the landing is refused, an ordinary project write and `git status` still work, and the host still resolves the chain where it did. Where the working directory holds it the retarget lands, pinned as a fact rather than left to be found, while both ends of the chain stay refused. And one for the degrade warning: a plan whose `.git/modules` is already denied whole says nothing when the submodules under it are degraded. --- test/sandbox/mandatory-deny-paths.test.ts | 498 +++++++++++++++++++++- 1 file changed, 494 insertions(+), 4 deletions(-) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 194e78a1c..6c7ecdb43 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -18,6 +18,7 @@ import { writeFileSync, readFileSync, symlinkSync, + readlinkSync, existsSync, statSync, realpathSync, @@ -31,6 +32,7 @@ import { lastMountAt, } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' import { wrapCommandWithSandboxMacOS, macGetMandatoryDenyEntries, @@ -2325,6 +2327,40 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { }, ) + it.if(!isWindows)('names every hop of a chain, and denies none whole', () => { + // A Seatbelt filter matches the name a rename or an unlink uses, so each + // hop's own path is the whole handle on it there: no directory is denied + // whole for one, and git goes on working in the repository. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-chain-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const landing = join(dir, '.githooks') + mkdirSync(landing, { recursive: true }) + const hop = join(dir, 'hooks-link') + symlinkSync('.githooks', hop) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(hop) + expect(paths).toContain(landing) + // Neither the git directory nor the directory holding the hop: holding + // a link by the directory around it is the other backend's answer. + expect(paths).not.toContain(gitDir) + expect(paths).not.toContain(dir) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + it('defaults to blocking .git/config when no argument provided', () => { const patterns = macGetMandatoryDenyEntries().map(e => e.path) @@ -2419,6 +2455,7 @@ describe('Git metadata deny paths - Unit Tests', () => { denyPaths: gitDirDenyPaths(gitDir, false), escapingDenyPaths: [], linkedEntryDirs: [], + chainHops: [], }) }, ) @@ -2461,15 +2498,143 @@ describe('Git metadata deny paths - Unit Tests', () => { ) it.if(!isWindows)( - 'follows a chain of dangling links to where a write through it would land', + 'follows a chain of dangling links to where a write through it would land, and holds the hop', () => { + // The landing's deny blocks creating it and the entry's deny holds the + // entry; the link in between is held by neither, and a command that + // points it elsewhere moves where the chain lands without touching + // either end. const gitDir = makeGitDir(join(dir, 'repo', '.git')) const landing = join(dir, 'repo', 'end') - symlinkSync(landing, join(dir, 'repo', 'middle')) + const middle = join(dir, 'repo', 'middle') + symlinkSync(landing, middle) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(middle, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(landing) + expect(denies.denyPaths).toContain(middle) + expect(denies.escapingDenyPaths).toContain(middle) + expect(denies.chainHops).toEqual([ + { + entry: join(gitDir, 'hooks'), + link: middle, + holder: join(dir, 'repo'), + }, + ]) + }, + ) + + it.if(!isWindows)('carries every hop of a two-link chain', () => { + // hooks -> ../hooks-link -> .githooks: the entry and the landing are + // denied, and the link between them is a name of its own. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const hop = join(dir, 'repo', 'hooks-link') + mkdirSync(landing, { recursive: true }) + symlinkSync('.githooks', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'hooks'), + landing, + hop, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + // No bind of the git directory covers either, so a degrade keeps both. + expect(denies.escapingDenyPaths).toEqual([landing, hop]) + expect(denies.chainHops).toEqual([ + { entry: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo') }, + ]) + }) + + it.if(!isWindows)( + 'names a symlinked directory component on the way as a hop', + () => { + // hooks -> ../shared/hooks with shared -> shared-real: the hop is a + // directory in the middle of the path, not a second link at the end, + // and retargeting it moves the chain just the same. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const real = join(dir, 'repo', 'shared-real') + mkdirSync(join(real, 'hooks'), { recursive: true }) + const hop = join(dir, 'repo', 'shared') + symlinkSync('shared-real', hop) rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(join(dir, 'repo', 'middle'), join(gitDir, 'hooks')) + symlinkSync('../shared/hooks', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) - expect(gitDirDenies(gitDir, false).denyPaths).toContain(landing) + expect(denies.denyPaths).toContain(join(real, 'hooks')) + expect(denies.denyPaths).toContain(hop) + expect(denies.chainHops).toEqual([ + { entry: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo') }, + ]) + }, + ) + + it.if(!isWindows)( + 'leaves a hop inside the git directory to the whole-directory deny', + () => { + // hooks -> hooks-link -> ../.githooks, with the hop under the git + // directory: the bind that denies the directory whole already holds + // it, so it needs no directory of its own and a degrade may drop it. + // Its own path is still named, for the backend that has no such bind. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const hop = join(gitDir, 'hooks-link') + mkdirSync(landing, { recursive: true }) + symlinkSync('../.githooks', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('hooks-link', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(hop) + expect(denies.escapingDenyPaths).toEqual([landing]) + expect(denies.chainHops).toEqual([ + { entry: join(gitDir, 'hooks'), link: hop, holder: undefined }, + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'holds the hops of a chain it could read where a later one cannot be', + () => { + // The chain runs into a directory this process cannot search, so what + // is behind it is unknown and the git directory is denied whole for + // it. The hop it DID read is held as a resolved chain's is. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const locked = join(dir, 'repo', 'locked') + const hop = join(dir, 'repo', 'mid') + mkdirSync(locked, { recursive: true }) + symlinkSync(join(locked, 'target'), hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../mid', join(gitDir, 'hooks')) + chmodSync(locked, 0o000) + try { + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(gitDir, 'hooks')) + expect(denies.denyPaths).toContain(hop) + expect(denies.escapingDenyPaths).toContain(hop) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + entry: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + } finally { + chmodSync(locked, 0o755) + } }, ) @@ -2492,6 +2657,9 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) expect(denies.linkedEntryDirs).toEqual([gitDir]) expect(denies.escapingDenyPaths).toEqual([]) + // A chain past the kernel's hop limit reaches nothing, so there is no + // hop to hold either: the whole deny is the whole answer. + expect(denies.chainHops).toEqual([]) }, ) @@ -2561,6 +2729,34 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) + it.if(!isWindows)( + 'holds the hops between a symlinked modules entry and the git directory it reaches', + () => { + // modules/lib -> ../../mid -> elsewhere: the deny of `modules` holds + // the entry and the git directory it reaches keeps its own denies, but + // the link in between sits in the work tree and moves the whole chain. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + const hop = join(dir, 'repo', 'mid') + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, hop) + symlinkSync(hop, join(modules, 'lib')) + + const scan = submoduleGitDirs(modules) + + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.chainHops).toEqual([ + { entry: join(modules, 'lib'), link: hop, holder: join(dir, 'repo') }, + ]) + // The hop's own path has no other list to sit in, so the flat form is + // where a backend reads it. + const tree = gitDirTreeDenies(gitDir, false) + expect(gitDirTreeDenyPaths(tree)).toContain(hop) + expect(tree.chainHops).toEqual(scan.chainHops) + }, + ) + it('gives the files git reads a placeholder that is no redirect', () => { // `.` is the git directory itself, which is where git looks when there // is no commondir, and no config.worktree reads as an empty one does. @@ -3067,6 +3263,7 @@ describe('Git metadata deny paths - Unit Tests', () => { gitDirs: [gitDir], unreadableDirs: [], linkedEntryDirs: [], + chainHops: [], }) }) @@ -3154,6 +3351,7 @@ describe('Git metadata deny paths - Unit Tests', () => { gitDirs: [gitDir], unreadableDirs: [], linkedEntryDirs: [join(dir, 'modules', 'a')], + chainHops: [], }) }) @@ -3172,6 +3370,7 @@ describe('Git metadata deny paths - Unit Tests', () => { gitDirs: [gitDir], unreadableDirs: [], linkedEntryDirs: [], + chainHops: [], }) }, ) @@ -3189,6 +3388,7 @@ describe('Git metadata deny paths - Unit Tests', () => { gitDirs: [gitDir], unreadableDirs: [], linkedEntryDirs: [], + chainHops: [], }) }, 30000, @@ -3217,6 +3417,7 @@ describe('Git metadata deny paths - Unit Tests', () => { gitDirs: [sibling], unreadableDirs: [locked], linkedEntryDirs: [join(dir, 'modules')], + chainHops: [], }) } finally { chmodSync(locked, 0o755) @@ -3556,6 +3757,7 @@ describe.if(isSupportedPlatform)( return { denyPaths: [...extra, ...gitDirs.flatMap(sub => sub.denyPaths)], repositories: [{ modulesDir, gitDirs, wholeDirDenies: [] }], + chainHops: [], } } @@ -3644,6 +3846,7 @@ describe.if(isSupportedPlatform)( const plan: SubmoduleDenyPlan = { denyPaths: gitDirs.flatMap(submodule => submodule.denyPaths), repositories: [{ modulesDir: modules, gitDirs, wholeDirDenies: [] }], + chainHops: [], } expect(collapseFurther(plan, NO_COLLAPSE, 2)).toEqual({ @@ -3663,6 +3866,7 @@ describe.if(isSupportedPlatform)( const gitDir = join(modules, 'b') const plan = planFor(modules, ['a', 'b']) const withWhole: SubmoduleDenyPlan = { + chainHops: [], denyPaths: [gitDir, ...plan.denyPaths], repositories: plan.repositories.map(repository => ({ ...repository, @@ -3705,6 +3909,28 @@ describe.if(isSupportedPlatform)( expect(told).toContain(modules) }) + it('says nothing about submodules a whole-directory deny already took', () => { + // An entry of this `.git/modules` is a symlink, so the directory is + // denied whole before anything is degraded and every submodule under + // it is read-only already. A degrade of them takes nothing away, and + // saying that it did names a cost the profile never paid. + const modules = join(dir, '.git', 'modules') + mkdirSync(join(modules, 'a'), { recursive: true }) + mkdirSync(join(modules, 'b'), { recursive: true }) + const plan = planFor(modules, ['a', 'b']) + const held: SubmoduleDenyPlan = { + ...plan, + repositories: plan.repositories.map(repository => ({ + ...repository, + wholeDirDenies: [modules], + })), + } + + expect( + describeCollapse(held, { wholeGitDirs: 2, wholeModulesDirs: 1 }), + ).toBe('') + }) + it('has nothing to degrade, and nothing to deny, for a repository with no submodules', () => { // An absent `.git/modules` is not a directory to deny: a /dev/null // mount point planted at one stops `git submodule add` working in @@ -3755,6 +3981,7 @@ describe.if(isSupportedPlatform)( const plan: SubmoduleDenyPlan = { denyPaths: gitDirTreeDenyPaths(gitDirTreeDenies(gitDir, false)), repositories: repository === undefined ? [] : [repository], + chainHops: [], } const denies = collapsedDenyPaths(plan, { @@ -4274,6 +4501,269 @@ describe.if(isSupportedPlatform)( }, 300000, ) + + /** + * A chain between a git directory entry and what it leads to is held hop + * by hop: each hop's own path is a deny path, and on this backend, whose + * denies resolve, the directory holding each is denied whole as the git + * directory itself is. Where that directory is the working directory or + * a write root there is nothing to bind over it, and the wrap says so + * instead. + */ + describe('a chain with a hop in the middle', () => { + /** A repository whose `hooks` reaches `.githooks` through `hop`. */ + function makeChain(holder: string): { + checkout: string + gitDir: string + hop: string + landing: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const landing = join(checkout, '.githooks') + mkdirSync(landing, { recursive: true }) + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(landing, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(hop, join(gitDir, 'hooks')) + return { checkout, gitDir, hop, landing } + } + + it.if(isLinux)( + 'binds the directory holding the hop read-only where it is a subdirectory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir, landing } = makeChain(holder) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + // A deny bind is emitted after the write root's own bind; an + // ancestor pin spells the same words before it. + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The two ends keep what they had: the git directory whole, and + // the landing on its own. + expect( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', landing, landing), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) + + it.if(isLinux)( + 'holds a symlinked directory component by its holder, not by what it leads to', + async () => { + // hooks -> links/shared/hooks with shared -> shared-real. A bind + // at the hop's own path would land on `shared-real` and take a + // whole directory nothing asked to deny; the directory holding the + // link is what holds it. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const real = join(checkout, 'shared-real') + mkdirSync(join(real, 'hooks'), { recursive: true }) + const holder = join(checkout, 'links') + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'shared') + symlinkSync(real, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(hop, 'hooks'), join(gitDir, 'hooks')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The landing keeps its own deny; the directory above it is only + // ever the ancestor pin, which is spliced in before that bind. + expect( + lastIndexOfMount( + command, + '--ro-bind', + join(real, 'hooks'), + join(real, 'hooks'), + ), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', real, real), + ).toBeLessThan(writeRootBind) + }, + 60000, + ) + + it.if(isLinux)( + 'warns rather than bind where the directory holding the hop is the working directory', + async () => { + const checkout = join(dir, 'repo') + const { gitDir, hop } = makeChain(checkout) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + // Nothing read-only over the write root after its own bind: that + // would take the whole tree the sandbox exists to let a command + // write. Only the pins spell it, and they come before. + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + const told = warnings.join('\n') + expect(told).toContain(join(gitDir, 'hooks')) + expect(told).toContain(hop) + }, + 60000, + ) + + it.if(LIVE)( + 'refuses every way to move a hop a subdirectory holds', + async () => { + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + const holder = join(checkout, 'links') + const { gitDir, hop, landing } = makeChain(holder) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! ln -sfn ${evil} ${hop} || echo HOP_RETARGETABLE; ` + + `! mv ${hop} ${hop}.aside || echo HOP_RENAMABLE; ` + + `! rm -f ${hop} || echo HOP_UNLINKABLE; ` + + `! mkdir ${join(holder, 'other')} || echo HOLDER_WRITABLE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `! echo x > ${join(landing, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('HOP_RETARGETABLE') + expect(result.stdout).not.toContain('HOP_RENAMABLE') + expect(result.stdout).not.toContain('HOP_UNLINKABLE') + expect(result.stdout).not.toContain('HOLDER_WRITABLE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The host still resolves the chain where it did. + expect(readlinkSync(hop)).toBe(landing) + expect(existsSync(join(landing, 'post-commit'))).toBe(false) + }, + 300000, + ) + + it.if(LIVE)( + 'records that a hop the working directory holds can still be retargeted', + async () => { + // The limit, pinned as a fact rather than left to be found: there + // is no bind that holds this link, so the wrap warns and the + // retarget lands. Both ends of the chain are held all the same. + const checkout = join(dir, 'repo') + const { gitDir, hop, landing } = makeChain(checkout) + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `ln -sfn ${evil} ${hop} || echo HOP_HELD; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `! echo x > ${join(landing, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(warnings.join('\n')).toContain(hop) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + // Honestly recorded: the hop moved, so the host's git would now + // resolve `.git/hooks` to the directory the command chose. + expect(result.stdout).not.toContain('HOP_HELD') + expect(readlinkSync(hop)).toBe(evil) + }, + 300000, + ) + }) }, ) From 829fcc5fcb4e7f254a514b7208e139cfc31455a3 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:02:37 +0000 Subject: [PATCH 040/141] README: a chain is held hop by hop, and three limits that are not What holds a hop, on each backend, and what it costs: the directory holding the link is read-only for the command on Linux, and only that directory, never what the link leads to. Three limits recorded beside "a link that IS the git directory", each measured rather than reasoned: - a hop the working directory or a write root holds, which no bind can take, so the wrap warns instead; and why snapshotting such a hop and restoring it after the command was tried and not adopted. - a hook FILE that is a symlink into the work tree (`ln -s ../../scripts/ pre-commit .git/hooks/pre-commit`): the hooks directory is denied, so no name can be added there and none re-linked, but the script it leads to is ordinary project content and its content is writable on every build. - a pre-existing HARD link of a denied file: a bind protects a path, not an inode, so an append through the other name lands. A sandboxed command cannot make such a link to a bound file - `link(2)` across a mount fails EXDEV - so it has to pre-exist. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 474953cf4..2e3755477 100644 --- a/README.md +++ b/README.md @@ -734,6 +734,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. - **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, a Seatbelt filter matches the path as an unlink or a rename names it. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. +- **A chain is held hop by hop, not just at its two ends.** An entry need not point straight at what it leads to: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. The entry's deny holds the entry and the landing's deny holds the landing; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. +- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above, and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory, and moving the link inside the git directory closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: @@ -770,10 +772,15 @@ A directory the command makes unreadable does **not** refuse anything: it is den - `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; - `git init` and `git clone` into a subdirectory, which create `.git/hooks/`; -- on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**. +- on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**; +- on Linux, every write inside a directory that HOLDS a hop of such an entry's chain, for the same reason: with `hooks -> ../links/hop`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. **Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`. A `.git` that is itself a symlink to a git directory elsewhere, or a `.git/modules` that is, is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. +**Known limit: a hook FILE that is a symlink into the work tree.** The install idiom `ln -s ../../scripts/pre-commit .git/hooks/pre-commit`, with `hooks` itself a real directory, is not one of the entries above: the hooks directory is denied, so no name can be added there and none re-linked, but the script the link leads to is ordinary project content, and its CONTENT is writable — directly and through the link — on every build. That is the sandbox doing its job: a tracked script in the work tree is exactly what a command is there to edit, and the sandbox has no way to tell the script the user chose to run as a hook from any other file in the repository. A `denyWrite` entry naming the script is the remedy where that matters. + +**Known limit: a pre-existing HARD link of a denied file.** A bind protects a path, not an inode, so an append through a second name for `.git/config` elsewhere in the work tree lands. A sandboxed command cannot make such a link to a bound file (the bind is its own mount, and `link(2)` across one fails `EXDEV`), so it has to pre-exist. + **Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. **Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: From d9e01c88e8306ae55373f1e8306f31d3b751852c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:41:39 +0000 Subject: [PATCH 041/141] Hold the links a gitdir: pointer's value walks through A pointer file's value is a path git walks as the kernel does: the link first, a later `..` after it. The file was denied and the git directory it named was denied, and a symlink BETWEEN the two was held by neither - a command that retargets it moves where the host's git lands without touching either end. Run against real git 2.55: `gitdir: hop/../evil` with `hop` a symlink opens the kernel's landing, a checkout whose pointer is a git directory only where the path folds is refused outright, and retargeting the link between two runs moves `--absolute-git-dir` from one git directory to another. So `gitMetadataTargets` now hands back the hops of that walk as well as where it lands, and `gitFileDenies` holds each of them the way a symlinked entry's hops are held: the link's own path is a deny path, which is what holds it where a filter matches the name a rename or an unlink uses, and the directory holding it goes to `chainHops`, which the Linux backend binds read-only where it can and warns about where it cannot. A followed `commondir`'s value is walked the same way, and names itself as the source. The walk now runs for every value rather than only for one holding a `..`; what it resolves to is unchanged, and a value that is absolute no longer resolves the base it never looks at. A chain past the kernel's own hop limit reaches nothing - git fails on it too - so it hands back no hops at all, as a symlinked entry that loops already did, and the whole-directory deny its landing gets is the whole answer. `GitEntryChainHop` is `GitChainHop`, with `kind` saying which end named the chain and `entry` renamed to `source`, since for a pointer it is the `.git` file or the `commondir` rather than an entry of a git directory. --- src/sandbox/linux-deny-collapse.ts | 10 +- src/sandbox/linux-sandbox-utils.ts | 26 ++--- src/sandbox/mandatory-deny-paths.ts | 157 +++++++++++++++++++--------- 3 files changed, 130 insertions(+), 63 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index be8d16982..2dc8a6af8 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -2,7 +2,7 @@ import * as fs from 'fs' import type { GitDirDenies, GitDirTreeDenies, - GitEntryChainHop, + GitChainHop, } from './mandatory-deny-paths.js' import { isAtOrUnder } from './sandbox-utils.js' @@ -41,8 +41,10 @@ export interface SubmoduleDenyPlan { */ repositories: RepositorySubmodules[] /** - * Every symlink BETWEEN a git directory entry and what it leads to that the - * wrap found, in the order it found them. What this backend takes from one + * Every symlink a git directory is reached THROUGH that the wrap found — + * between an entry and what it leads to, or in the path a `gitdir:` + * pointer's value walks — in the order it found them. What this backend + * takes from one * is the directory holding it, the only thing a bind can hold a link by; * the link's own path is a deny path for the backend that holds a link by * its name and is dropped here. Which holders a wrap can actually bind @@ -50,7 +52,7 @@ export interface SubmoduleDenyPlan { * here and nothing collapses them: see `chainHopDenies` and * `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. */ - chainHops: GitEntryChainHop[] + chainHops: GitChainHop[] } /** diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 826644d18..6a9f6b86d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -38,10 +38,7 @@ import { gitFileDenies, gitRedirectPlaceholder, } from './mandatory-deny-paths.js' -import type { - GitDirTreeDenies, - GitEntryChainHop, -} from './mandatory-deny-paths.js' +import type { GitDirTreeDenies, GitChainHop } from './mandatory-deny-paths.js' import type { CollapseLevel, RepositorySubmodules, @@ -674,7 +671,7 @@ function cwdMandatoryDenyPlan( const cwd = process.cwd() const denyPaths = cwdDangerousDenyPaths(cwd) const repositories: RepositorySubmodules[] = [] - const chainHops: GitEntryChainHop[] = [] + const chainHops: GitChainHop[] = [] const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -734,7 +731,7 @@ function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { */ function withoutChainHopLinks( denyPaths: string[], - chainHops: readonly GitEntryChainHop[], + chainHops: readonly GitChainHop[], ): string[] { if (chainHops.length === 0) return denyPaths const links = new Set(chainHops.map(hop => hop.link)) @@ -742,7 +739,8 @@ function withoutChainHopLinks( } /** - * Which of the symlinks BETWEEN a git directory entry and what it leads to + * Which of the symlinks a git directory is reached THROUGH — between an entry + * and what it leads to, or in the path a `gitdir:` pointer's value walks — * this backend can hold, and which it cannot. * * A bind cannot be put on a link — the destination resolves — so the @@ -762,11 +760,11 @@ function withoutChainHopLinks( * back in `unheld`, for the warning that has to stand in for a bind. */ function chainHopDenies( - chainHops: readonly GitEntryChainHop[], + chainHops: readonly GitChainHop[], allowWritePaths: readonly string[], -): { held: string[]; unheld: GitEntryChainHop[] } { +): { held: string[]; unheld: GitChainHop[] } { const held = new Set() - const unheld: GitEntryChainHop[] = [] + const unheld: GitChainHop[] = [] const cwd = process.cwd() for (const hop of chainHops) { const holder = hop.holder @@ -800,11 +798,15 @@ function withChainHopDenies( const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) if (unheld.length > 0) { const told = unheld - .map(hop => `${hop.entry} reaches what it denies through ${hop.link}`) + .map(hop => + hop.kind === 'pointer' + ? `the git directory ${hop.source} names is reached through ${hop.link}` + : `${hop.source} reaches what it denies through ${hop.link}`, + ) .join('; ') const holders = [...new Set(unheld.map(hop => hop.holder))].join(', ') logForDebugging( - `[Sandbox Linux] ${told}. A read-only bind of the directory holding such a link is the only thing that holds it, and ${holders} is the working directory or a path this command may write, which binding read-only would take the whole tree with: a command in this sandbox can point the link somewhere else, and a git run on the host afterwards follows it there. Moving the link inside the git directory, or a denyWrite entry naming the directory holding it, closes that.`, + `[Sandbox Linux] ${told}. A read-only bind of the directory holding such a link is the only thing that holds it, and ${holders} is the working directory or a path this command may write, which binding read-only would take the whole tree with: a command in this sandbox can point the link somewhere else, and a git run on the host afterwards follows it there. Moving the link inside the git directory, spelling the pointer's path without it, or a denyWrite entry naming the directory holding it, closes that.`, { level: 'warn' }, ) } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 28bc222fd..70ded2b2f 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -130,19 +130,29 @@ interface ResolvedChain { } /** - * A symlink BETWEEN a git directory entry and what it leads to: a second hop - * of the entry's own link, or a symlinked directory component on the way. + * A symlink a git directory is reached THROUGH: a second hop of a symlinked + * entry's own link, a symlinked directory component on the way, or a + * component of the path a `gitdir:` pointer or a `commondir` names. * - * Neither end of the chain covers one. The entry's deny holds the entry and - * the landing's deny holds the landing, while a hop in between sits in an - * ordinary directory of the work tree: a command that retargets it, or - * renames it aside and puts its own directory there, moves what the chain - * resolves to without touching either end, and the host's git follows the - * chain afterwards. + * Neither end of such a chain covers one. The entry's or the pointer file's + * deny holds that end and the landing's deny holds the other, while a hop in + * between sits in an ordinary directory of the work tree: a command that + * retargets it, or renames it aside and puts its own directory there, moves + * what the chain resolves to without touching either end, and the host's git + * follows the chain afterwards. */ -export interface GitEntryChainHop { - /** The git directory entry whose chain goes through it. */ - entry: string +export interface GitChainHop { + /** + * Where the chain starts: a git directory ENTRY that is itself a symlink, + * or the VALUE of a `gitdir:` pointer or a `commondir`, which git walks the + * same way and a command redirects the same way. Held alike; the warning + * that stands in where one cannot be held says which, because what closes + * it differs. + */ + kind: 'entry' | 'pointer' + /** The git directory entry, or the metadata file, whose chain goes + * through it. */ + source: string /** * The link's own path, and a deny path of its own: that is what holds it * on the backend which matches the name a rename or an unlink uses. The @@ -202,9 +212,9 @@ export interface SubmoduleScan { * The hops BETWEEN an entry the walk followed through a symlink and what * it lands on. One inside the directory holding that entry names no holder * of its own: `linkedEntryDirs` denies that whole already. See - * {@link GitEntryChainHop}. + * {@link GitChainHop}. */ - chainHops: GitEntryChainHop[] + chainHops: GitChainHop[] } /** @@ -226,8 +236,8 @@ export interface GitDirTreeDenies { linkedEntryDirs: string[] /** Every hop between a symlinked entry of this tree and what it leads to, * the git directory's own entries, the walked `.git/modules` entries and - * the submodules' own alike. See {@link GitEntryChainHop}. */ - chainHops: GitEntryChainHop[] + * the submodules' own alike. See {@link GitChainHop}. */ + chainHops: GitChainHop[] } /** @@ -319,13 +329,14 @@ export interface GitDirDenies { linkedEntryDirs: string[] /** * The hops between a symlinked entry of this git directory and what it - * leads to. Each hop's own path is in `denyPaths` already, and in - * `escapingDenyPaths` where no bind of the git directory covers it; what + * leads to — and, from {@link gitFileDenies}, the ones in the path a + * pointer's value walks. Each hop's own path is in `denyPaths` already, and + * in `escapingDenyPaths` where no bind of the git directory covers it; what * is here besides is the directory holding each, which is what the backend * whose denies resolve has to deny whole to hold the link. See - * {@link GitEntryChainHop}. + * {@link GitChainHop}. */ - chainHops: GitEntryChainHop[] + chainHops: GitChainHop[] } /** @@ -353,7 +364,7 @@ export interface GitDirDenies { * ordinary directory of the work tree moves what the chain resolves to * without touching either end. Each hop's own path is therefore a deny path * as well, and `chainHops` carries the directory holding each for the backend - * that needs one — see {@link GitEntryChainHop}. + * that needs one — see {@link GitChainHop}. * * An ordinary git directory costs one lstat per entry and is denied by exactly * what it always was. @@ -364,7 +375,7 @@ export function gitDirDenies( ): GitDirDenies { const denyPaths: string[] = [] const escapingDenyPaths: string[] = [] - const chainHops: GitEntryChainHop[] = [] + const chainHops: GitChainHop[] = [] let denyWhole = false // Resolved on the first symlinked entry and not before: an ordinary git // directory must not pay a realpath for a question it never asks. @@ -382,13 +393,19 @@ export function gitDirDenies( // own name; the one that resolves its denies drops it again. denyPaths.push(hop.link) if (insideGitDir(hop.link)) { - chainHops.push({ entry: entryPath, link: hop.link, holder: undefined }) + chainHops.push({ + kind: 'entry', + source: entryPath, + link: hop.link, + holder: undefined, + }) continue } // No bind of the git directory covers it, so a degrade must not drop it. escapingDenyPaths.push(hop.link) chainHops.push({ - entry: entryPath, + kind: 'entry', + source: entryPath, link: hop.link, holder: insideGitDir(hop.holder) ? undefined : hop.holder, }) @@ -516,7 +533,9 @@ export function gitRedirectPlaceholder(denyPath: string): string | undefined { * * A `..` in either path can land the kernel somewhere other than where the * path folds to on paper, so both directories are denied — see - * {@link gitMetadataTargets}. + * {@link gitMetadataTargets} — and every symlink the walk of the path went + * through is denied as well, since a command that retargets one of those + * moves the directory git opens without touching the file or the landing. * * Throws {@link GitMetadataError} when the pointer or the `commondir` names * something this cannot resolve the way git does; the wrap is then refused @@ -531,11 +550,12 @@ export function gitFileDenyPaths( } /** - * {@link gitFileDenyPaths} with the whole-directory denies kept apart, as - * {@link gitDirDenies} keeps them: a git directory a pointer leads to is a - * git directory like any other, so an entry of it that is a symlink needs - * the directory holding the link denied whole on the backend whose denies - * resolve. Nothing else is enumerated through a pointer — a target's own + * {@link gitFileDenyPaths} with the whole-directory denies and the chain hops + * kept apart, as {@link gitDirDenies} keeps them: a git directory a pointer + * leads to is a git directory like any other, so an entry of it that is a + * symlink needs the directory holding the link denied whole on the backend + * whose denies resolve, and so does a link in the path the pointer's own + * value walks. Nothing else is enumerated through a pointer — a target's own * `.git/modules` is not walked — so this is what it adds. */ export function gitFileDenies( @@ -544,7 +564,7 @@ export function gitFileDenies( ): GitDirDenies { const denyPaths = [gitFile] const linkedEntryDirs: string[] = [] - const chainHops: GitEntryChainHop[] = [] + const chainHops: GitChainHop[] = [] const targetDenies = ( target: string, kind: GitDirKind, @@ -555,6 +575,21 @@ export function gitFileDenies( linkedEntryDirs.push(...denies.linkedEntryDirs) chainHops.push(...denies.chainHops) } + // A pointer's VALUE is a path git walks as the kernel does, so a symlink in + // it is held as a symlinked entry is: the file naming it is denied and the + // git directory it reaches is denied, while a command that retargets a link + // between the two moves where git lands without touching either end. + const holdChain = (source: string, hops: ChainHop[]): void => { + for (const hop of hops) { + denyPaths.push(hop.link) + chainHops.push({ + kind: 'pointer', + source, + link: hop.link, + holder: hop.holder, + }) + } + } try { const pointer = readGitMetadataFile(gitFile) let target: string | undefined @@ -575,9 +610,12 @@ export function gitFileDenies( if (target === undefined) { return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } - const gitDirs = gitMetadataTargets(path.dirname(gitFile), target).map( - gitDir => ({ gitDir, kind: gitDirKind(gitDir) }), - ) + const targets = gitMetadataTargets(path.dirname(gitFile), target) + holdChain(gitFile, targets.hops) + const gitDirs = targets.gitDirs.map(gitDir => ({ + gitDir, + kind: gitDirKind(gitDir), + })) for (const { gitDir, kind } of gitDirs) { targetDenies(gitDir, kind, gitFile) } @@ -619,7 +657,9 @@ export function gitFileDenies( break } if (commonTarget === undefined) continue - for (const commonDir of gitMetadataTargets(gitDir, commonTarget)) { + const commonTargets = gitMetadataTargets(gitDir, commonTarget) + holdChain(commonFile, commonTargets.hops) + for (const commonDir of commonTargets.gitDirs) { if (commonDir === gitDir) continue targetDenies(commonDir, gitDirKind(commonDir), commonFile) } @@ -801,7 +841,7 @@ interface SubmoduleWalk { gitDirs: string[] unreadableDirs: Set linkedEntryDirs: Set - chainHops: GitEntryChainHop[] + chainHops: GitChainHop[] } /** What one entry of a walked directory is worth looking into. */ @@ -847,7 +887,7 @@ function listDirectory( * command that plants git directories too. * * A chain that goes through further links is held hop by hop as a git - * directory's own entry is: see {@link GitEntryChainHop}. + * directory's own entry is: see {@link GitChainHop}. */ function walkEntry( entry: fs.Dirent, @@ -867,7 +907,8 @@ function walkEntry( const holdChain = (): void => { for (const hop of chain.hops.slice(1)) { walk.chainHops.push({ - entry: entryPath, + kind: 'entry', + source: entryPath, link: hop.link, holder: isAtOrUnder(hop.holder, holder) ? undefined : hop.holder, }) @@ -1099,25 +1140,44 @@ function gitMetadataPath(contents: Buffer, file: string): string | undefined { return decoded } +/** Where a `gitdir:` or `commondir` value leads, and what it goes through; + * see {@link gitMetadataTargets}. */ +interface GitMetadataTargets { + gitDirs: string[] + /** Every symlink the walk of the value went through, in the order it met + * them. A link ABOVE the file holding the value is not one of them: the + * base is resolved before the walk starts, so a checkout reached through a + * symlink is where the repository lives rather than a hop of this chain. */ + hops: ChainHop[] +} + /** * The git directories a `gitdir:` or `commondir` value read in `base` leads * to: the path as it folds on paper, which is the spelling these denies have * always used, and — when a `..` in it could send the kernel elsewhere — the * directory the kernel actually reaches. git hands the two strings to stat * joined and unnormalised, so a symlink is followed before a later `..` - * applies and `a/link/../b` need not be `a/b`. Both are denied when they - * differ: git opens one of them, and denying the other costs one bind. + * applies and `a/link/../b` need not be `a/b`; run against real git, it opens + * the kernel's landing and refuses when only the lexical one is a git + * directory (test/sandbox/git-pointer-parity.test.ts pins that). Both are + * denied when they differ: denying more than git follows costs one bind. */ -function gitMetadataTargets(base: string, target: string): string[] { +function gitMetadataTargets(base: string, target: string): GitMetadataTargets { const lexical = path.resolve(base, target) - if (!target.split('/').includes('..')) return [lexical] const root = path.parse(lexical).root - const physical = physicalPath(physicalPath(root, base), target) + const chain = resolveChain( + // An absolute value starts at the root and never looks at the base, so + // resolving the base then is a walk that buys nothing. + path.isAbsolute(target) ? root : physicalPath(root, base), + target, + ) + const hops = chain.hops + if (!target.split('/').includes('..')) return { gitDirs: [lexical], hops } // Both sides resolved the same way, so a base that merely spells itself // differently (a /var that is a symlink to /private/var) is no difference. - return physical === physicalPath(root, lexical) - ? [lexical] - : [lexical, physical] + return chain.landing === physicalPath(root, lexical) + ? { gitDirs: [lexical], hops } + : { gitDirs: [lexical, chain.landing], hops } } /** @@ -1163,9 +1223,12 @@ function resolveChain(base: string, target: string): ResolvedChain { hops.push({ link: next, holder: current }) // A loop is where the walk stops, and what it hands back: the rest of // the path folded past it would name a directory this cannot vouch for, - // while the link itself reads as unreadable and is denied whole. + // while the link itself reads as unreadable and is denied whole. Past + // the kernel's own limit the chain reaches nothing — git fails on it as + // this does — so there is nothing behind it to protect and no hop of it + // worth holding, which is the answer a symlinked ENTRY that loops gets. if (hops.length > MAX_SYMLINK_RESOLUTION_DEPTH) { - return { landing: next, hops } + return { landing: next, hops: [] } } // A link's own target is walked in its place, from the directory holding // it unless it is absolute. From 6319e2b67fb8edf6a31839e8c4ccf4b5463f8a79 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:41:39 +0000 Subject: [PATCH 042/141] Tests: the links between a pointer and the git directory it names Unit cases for a value that goes through one symlinked component, through two, with a `..` after the link, through a link that leads nowhere yet, and into a directory this process cannot search; for a `commondir` value, which names itself as the source; and for a loop, which has no hop to hold. A link ABOVE the pointer file stays what it was: where the checkout lives, not a hop. The macOS entries for a pointer's chain carry the file, the link and the hooks it leads to, and deny nothing whole. On Linux the argv shows the directory holding the hop bound read-only where it is a subdirectory, and no read-only bind over the working directory where that holds it, with the warning naming the pointer file and the link instead. Live under bubblewrap: retargeting, renaming aside and unlinking such a hop are all refused where a subdirectory holds it, while `git status` and an ordinary project write still succeed - and where the working directory holds it the retarget lands, pinned as a fact rather than left to be found. The parity test gains the two halves of git's own rule, against real git: with both candidates git directories it opens the kernel's landing, and with only the lexical one there it refuses the checkout. --- test/sandbox/git-pointer-parity.test.ts | 48 +++ test/sandbox/mandatory-deny-paths.test.ts | 460 ++++++++++++++++++++-- 2 files changed, 484 insertions(+), 24 deletions(-) diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index e188dc01f..113d53b62 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -644,6 +644,54 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { ) }) + it('follows a .. after a symlink where the kernel lands, not where the path folds', () => { + // Both candidates are git directories git would accept, so which one it + // opens says what git's rule IS rather than which one happened to exist. + // It opens the kernel's landing: the link is followed and the `..` + // applied afterwards, which is the walk mandatory-deny-paths.ts makes. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(join(caseDir, 'real', 'side'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + const lexical = makeGitDir(join(checkout, 'evil')) + const physical = makeGitDir(join(caseDir, 'real', 'evil')) + const hop = join(checkout, 'hop') + symlinkSync(join(caseDir, 'real', 'side'), hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: hop/../evil\n') + + const opened = gitResolves(checkout, '--absolute-git-dir') + expect(opened).toBe(physical) + expect(opened).not.toBe(lexical) + // So the deny list has to carry that directory's hooks — and the link + // that decides which directory it is, since a command that retargets it + // moves where the next git run lands. + const denyPaths = gitFileDenyPaths(pointer, false) + expect(denyPaths).toContain(join(physical, 'hooks')) + expect(denyPaths).toContain(hop) + expect(assertParity('a .. after a symlink', pointer, checkout)).toBe( + 'checked', + ) + }) + + it('refuses a pointer that is a git directory only where the path folds', () => { + // The other half of the same rule: with the kernel's landing absent, git + // does not fall back to the lexical path, it refuses the checkout. The + // deny list names that path all the same — denying more than git follows + // is the safe direction, and the directory can still be created. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(join(caseDir, 'real', 'side'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + const lexical = makeGitDir(join(checkout, 'evil')) + symlinkSync(join(caseDir, 'real', 'side'), join(checkout, 'hop')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: hop/../evil\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() + expect(gitFileDenyPaths(pointer, false)).toContain(join(lexical, 'hooks')) + }) + // Linux only: the target has to exist for git to follow it, and macOS // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The // refusal itself is platform-independent and covered everywhere by the diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 6c7ecdb43..af37f253a 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -2361,6 +2361,37 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { } }) + it.if(!isWindows)("names every hop of a pointer's own path", () => { + // The same answer where the chain is the VALUE of a `.git` pointer file + // rather than an entry of a git directory: each link the path goes + // through is a name of its own, and nothing is denied whole for it. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-pointer-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const gitDir = join(dir, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(join(dir, 'real'), hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(checkout, '.git')) + expect(paths).toContain(hop) + expect(paths).toContain(join(hop, 'gd', 'hooks')) + expect(paths).not.toContain(checkout) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + it('defaults to blocking .git/config when no argument provided', () => { const patterns = macGetMandatoryDenyEntries().map(e => e.path) @@ -2518,7 +2549,8 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.escapingDenyPaths).toContain(middle) expect(denies.chainHops).toEqual([ { - entry: join(gitDir, 'hooks'), + kind: 'entry', + source: join(gitDir, 'hooks'), link: middle, holder: join(dir, 'repo'), }, @@ -2550,7 +2582,12 @@ describe('Git metadata deny paths - Unit Tests', () => { // No bind of the git directory covers either, so a degrade keeps both. expect(denies.escapingDenyPaths).toEqual([landing, hop]) expect(denies.chainHops).toEqual([ - { entry: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo') }, + { + kind: 'entry', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, ]) }) @@ -2573,7 +2610,12 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.denyPaths).toContain(join(real, 'hooks')) expect(denies.denyPaths).toContain(hop) expect(denies.chainHops).toEqual([ - { entry: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo') }, + { + kind: 'entry', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, ]) }, ) @@ -2598,7 +2640,12 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.denyPaths).toContain(hop) expect(denies.escapingDenyPaths).toEqual([landing]) expect(denies.chainHops).toEqual([ - { entry: join(gitDir, 'hooks'), link: hop, holder: undefined }, + { + kind: 'entry', + source: join(gitDir, 'hooks'), + link: hop, + holder: undefined, + }, ]) expect(denies.linkedEntryDirs).toEqual([gitDir]) }, @@ -2627,7 +2674,8 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.linkedEntryDirs).toEqual([gitDir]) expect(denies.chainHops).toEqual([ { - entry: join(gitDir, 'hooks'), + kind: 'entry', + source: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo'), }, @@ -2747,7 +2795,12 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(scan.linkedEntryDirs).toEqual([modules]) expect(scan.chainHops).toEqual([ - { entry: join(modules, 'lib'), link: hop, holder: join(dir, 'repo') }, + { + kind: 'entry', + source: join(modules, 'lib'), + link: hop, + holder: join(dir, 'repo'), + }, ]) // The hop's own path has no other list to sit in, so the flat form is // where a backend reads it. @@ -2960,21 +3013,36 @@ describe('Git metadata deny paths - Unit Tests', () => { }) it.if(!isWindows)( - 'denies where a .. after a symlink lands, and the lexical path too', + 'denies where a .. after a symlink lands, the lexical path and the link between', () => { // checkout/hop is real/side, so the kernel reads hop/../evil as // real/evil while folding it on paper gives checkout/evil. git opens - // the first; denying only the second leaves its hooks writable. + // the first; denying only the second leaves its hooks writable. The + // link itself is a third thing: retargeting it moves where git lands + // without touching the pointer file or either directory. const physical = makeGitDir(join(dir, 'real', 'evil')) mkdirSync(join(dir, 'real', 'side'), { recursive: true }) const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') - symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + const hop = join(dir, 'checkout', 'hop') + symlinkSync(join(dir, 'real', 'side'), hop) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, + hop, ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), ...gitDirDenyPaths(physical, false), ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + expect(gitFileDenyPaths(pointer, false)).toEqual(denies.denyPaths) }, ) @@ -2987,27 +3055,38 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(gitFileDenyPaths(pointer, false)).toEqual([ pointer, + join(dir, 'checkout', 'hop'), ...gitDirDenyPaths(lexical, false), ...gitDirDenyPaths(physical, false), ]) }) it.if(!isWindows)('walks a chain of symlinks as the kernel does', () => { - // first is second, second is an absolute path to real/side/deeper. + // first is second, second is an absolute path to real/side/deeper. Both + // links are on the way to what git opens, and each is held. const physical = makeGitDir(join(dir, 'real', 'evil')) mkdirSync(join(dir, 'real', 'side', 'deeper'), { recursive: true }) const pointer = writePointer('checkout', 'gitdir: first/../../evil\n') - symlinkSync('second', join(dir, 'checkout', 'first')) - symlinkSync( - join(dir, 'real', 'side', 'deeper'), - join(dir, 'checkout', 'second'), - ) + const first = join(dir, 'checkout', 'first') + const second = join(dir, 'checkout', 'second') + symlinkSync('second', first) + symlinkSync(join(dir, 'real', 'side', 'deeper'), second) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, + first, + second, ...gitDirDenyPaths(join(dir, 'evil'), false), ...gitDirDenyPaths(physical, false), ]) + // In the order the walk met them, each named where the kernel reached it. + expect(denies.chainHops.map(hop => hop.link)).toEqual([first, second]) + expect(denies.chainHops.map(hop => hop.holder)).toEqual([ + join(dir, 'checkout'), + join(dir, 'checkout'), + ]) }) it.if(!isWindows)( @@ -3015,15 +3094,27 @@ describe('Git metadata deny paths - Unit Tests', () => { () => { // The kernel cannot traverse a missing directory, so nothing after it // redirects the path: gone/x is denied against being created, and so - // is the lexical checkout/x. + // is the lexical checkout/x. The link it did read is held all the same. const pointer = writePointer('checkout', 'gitdir: hop/../x\n') - symlinkSync('gone/deeper', join(dir, 'checkout', 'hop')) + const hop = join(dir, 'checkout', 'hop') + symlinkSync('gone/deeper', hop) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, + hop, ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) }, ) @@ -3034,27 +3125,47 @@ describe('Git metadata deny paths - Unit Tests', () => { // Past the hop limit the walk stops on the loop itself, which is where // the deny goes: the whole directory that still reads. - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), join(dir, 'checkout'), ]) + // A chain the kernel cannot walk either reaches nothing, so there is no + // hop to hold: the whole-directory deny is the whole answer, as it is + // for a symlinked entry that loops. + expect(denies.chainHops).toEqual([]) }) it.if(!isWindows)('resolves a commondir the same way', () => { const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) const physical = makeGitDir(join(dir, 'real', 'common')) + const hop = join(worktreeGitDir, 'hop') mkdirSync(join(dir, 'real', 'side'), { recursive: true }) - symlinkSync(join(dir, 'real', 'side'), join(worktreeGitDir, 'hop')) + symlinkSync(join(dir, 'real', 'side'), hop) writeFileSync(join(worktreeGitDir, 'commondir'), 'hop/../common\n') const pointer = makePointer('wt-checkout', worktreeGitDir) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), + hop, ...gitDirDenyPaths(join(worktreeGitDir, 'common'), false), ...gitDirDenyPaths(physical, false), ]) + // The file that named the path is the commondir, not the pointer: it is + // what a reader has to look at to see where the chain starts. + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: join(worktreeGitDir, 'commondir'), + link: hop, + holder: worktreeGitDir, + }, + ]) }) it.if(!isWindows)( @@ -3069,11 +3180,102 @@ describe('Git metadata deny paths - Unit Tests', () => { const pointer = join(dir, 'link', '.git') writeFileSync(pointer, 'gitdir: ../target\n') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ pointer, ...gitDirDenyPaths(join(dir, 'target'), false), ...gitDirDenyPaths(physical, false), ]) + // The link ABOVE the pointer file is where this checkout lives, not a + // hop of the chain its value walks: the walk starts where the kernel + // already is, so nothing here is held for it. + expect(denies.chainHops).toEqual([]) + }, + ) + + it.if(!isWindows)( + 'holds a symlinked directory component of a value with no .. in it', + () => { + // `gitdir: linkdir/gd` reaches the git directory through `linkdir`, + // and git opens what the link leads to. Nothing else in the deny list + // names the link, so without this a command retargets it and every + // later git run in this checkout reads a git directory of its choosing. + makeGitDir(join(dir, 'realdir', 'gd')) + const pointer = writePointer('checkout', 'gitdir: linkdir/gd\n') + const hop = join(dir, 'checkout', 'linkdir') + symlinkSync(join(dir, 'realdir'), hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(hop, 'gd'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }, + ) + + it.if(!isWindows)('holds a link of a value that leads nowhere yet', () => { + // `mid` is there and what it names is not: the deny goes where the path + // lands, so a command cannot fill that git directory in, and the link is + // held so it cannot be aimed at one that already exists either. + const pointer = writePointer('checkout', 'gitdir: mid/gitdir\n') + const hop = join(dir, 'checkout', 'mid') + symlinkSync('gone', hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(hop, 'gitdir'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'holds the link it read where what lies past it cannot be', + () => { + // The chain runs into a directory this process cannot search, so the + // git directory is failed closed on the deepest ancestor it can reach + // and the link it DID read is held as any other is. + const locked = join(dir, 'checkout', 'locked') + mkdirSync(join(locked, 'inner'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: mid/gitdir\n') + const hop = join(dir, 'checkout', 'mid') + symlinkSync(join(locked, 'inner'), hop) + chmodSync(locked, 0o000) + try { + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([pointer, hop, join(dir, 'checkout')]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + } finally { + chmodSync(locked, 0o755) + } }, ) @@ -4718,6 +4920,216 @@ describe.if(isSupportedPlatform)( 300000, ) + /** + * A checkout whose `.git` is a POINTER FILE naming its git directory + * through `hop`: the same chain from the other producer, since a + * pointer's value is a path the kernel walks like any other. + */ + function makePointerChain(holder: string): { + checkout: string + pointer: string + hop: string + gitDir: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, 'gitdir-real')) + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(gitDir, hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${hop}\n`) + return { checkout, pointer, hop, gitDir } + } + + it.if(isLinux)( + "binds the directory holding a hop of a pointer's value read-only", + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { pointer, gitDir } = makePointerChain(holder) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The two ends keep what they had: the pointer file itself, and the + // hooks of the git directory its value reaches — spelled where the + // deny RESOLVES to, which is what a bind can be put on. + expect( + lastIndexOfMount(command, '--ro-bind', pointer, pointer), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount( + command, + '--ro-bind', + join(gitDir, 'hooks'), + join(gitDir, 'hooks'), + ), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) + + it.if(isLinux)( + "warns where the working directory holds a hop of a pointer's value", + async () => { + const checkout = join(dir, 'repo') + const { pointer, hop } = makePointerChain(checkout) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + const told = warnings.join('\n') + // Named as what it is: the path a pointer file leads through. + expect(told).toContain(pointer) + expect(told).toContain(hop) + }, + 60000, + ) + + it.if(LIVE)( + "refuses every way to move a hop of a pointer's value a subdirectory holds", + async () => { + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + // The repository git just made, reached through a link: its `.git` + // becomes the pointer file naming it. + const gitDir = join(checkout, 'gitdir-real') + renameSync(join(checkout, '.git'), gitDir) + const holder = join(checkout, 'links') + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(gitDir, hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${hop}\n`) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = makeGitDir(join(checkout, 'evil-gitdir')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! ln -sfn ${evil} ${hop} || echo HOP_RETARGETABLE; ` + + `! mv ${hop} ${hop}.aside || echo HOP_RENAMABLE; ` + + `! rm -f ${hop} || echo HOP_UNLINKABLE; ` + + `! mkdir ${join(holder, 'other')} || echo HOLDER_WRITABLE; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! echo x > ${join(gitDir, 'hooks', 'post-commit')} || echo HOOKS_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('HOP_RETARGETABLE') + expect(result.stdout).not.toContain('HOP_RENAMABLE') + expect(result.stdout).not.toContain('HOP_UNLINKABLE') + expect(result.stdout).not.toContain('HOLDER_WRITABLE') + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The host still resolves the pointer where it did. + expect(readlinkSync(hop)).toBe(gitDir) + }, + 300000, + ) + + it.if(LIVE)( + "records that a hop of a pointer's value in the working directory can still be retargeted", + async () => { + // The same limit from the other producer, pinned as a fact: no bind + // holds a link the working directory itself holds, so the wrap warns + // and the retarget lands. The pointer file and the git directory it + // named are held all the same. + const checkout = join(dir, 'repo') + const { pointer, hop, gitDir } = makePointerChain(checkout) + const evil = makeGitDir(join(checkout, 'evil-gitdir')) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `ln -sfn ${evil} ${hop} || echo HOP_HELD; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! echo x > ${join(gitDir, 'hooks', 'post-commit')} || echo HOOKS_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(warnings.join('\n')).toContain(hop) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_WRITABLE') + // Honestly recorded: the hop moved, so the host's git would now read + // the git directory the command chose. + expect(result.stdout).not.toContain('HOP_HELD') + expect(readlinkSync(hop)).toBe(evil) + }, + 300000, + ) + it.if(LIVE)( 'records that a hop the working directory holds can still be retargeted', async () => { From d5e0eb2a2285c2fb0c307ef535d8e529df9be646 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:41:39 +0000 Subject: [PATCH 043/141] README: a chain is held hop by hop whichever end names it The section that said an ENTRY's chain is held hop by hop now says the same for the value of a `gitdir:` pointer or a `commondir`, records that git opens the kernel's landing rather than the lexical one, and says what a chain past the kernel's hop limit gets and why a link above the file is not a hop. The limit for a hop nothing can be bound over names the pointer shape, where the directory holding the hop is the working directory by construction. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2e3755477..53c6219dc 100644 --- a/README.md +++ b/README.md @@ -731,11 +731,11 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. -- A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. +- A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. - **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, a Seatbelt filter matches the path as an unlink or a rename names it. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. -- **A chain is held hop by hop, not just at its two ends.** An entry need not point straight at what it leads to: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. The entry's deny holds the entry and the landing's deny holds the landing; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. -- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above, and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory, and moving the link inside the git directory closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. +- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. +- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: @@ -773,7 +773,7 @@ A directory the command makes unreadable does **not** refuse anything: it is den - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; - `git init` and `git clone` into a subdirectory, which create `.git/hooks/`; - on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**; -- on Linux, every write inside a directory that HOLDS a hop of such an entry's chain, for the same reason: with `hooks -> ../links/hop`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. +- on Linux, every write inside a directory that HOLDS a hop of such a chain — an entry's or a `gitdir:` pointer value's — for the same reason: with `hooks -> ../links/hop`, or with `gitdir: links/hop/gd`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. **Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`. A `.git` that is itself a symlink to a git directory elsewhere, or a `.git/modules` that is, is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. From 14b869f054650b9740f69a0da55d5b670069f495 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 17:53:16 +0000 Subject: [PATCH 044/141] Reflow the note this round's rename left wrapped short Comment lines only: the plan's `chainHops` note in src/sandbox/linux-deny-collapse.ts, whose first sentence grew and left the rest of the paragraph broken mid-clause. Prettier does not reflow comments. --- src/sandbox/linux-deny-collapse.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 2dc8a6af8..634dd13fb 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -44,13 +44,12 @@ export interface SubmoduleDenyPlan { * Every symlink a git directory is reached THROUGH that the wrap found — * between an entry and what it leads to, or in the path a `gitdir:` * pointer's value walks — in the order it found them. What this backend - * takes from one - * is the directory holding it, the only thing a bind can hold a link by; - * the link's own path is a deny path for the backend that holds a link by - * its name and is dropped here. Which holders a wrap can actually bind - * depends on where the write roots are, so it is decided there rather than - * here and nothing collapses them: see `chainHopDenies` and - * `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. + * takes from one is the directory holding it, the only thing a bind can + * hold a link by; the link's own path is a deny path for the backend that + * holds a link by its name and is dropped here. Which holders a wrap can + * actually bind depends on where the write roots are, so it is decided + * there rather than here and nothing collapses them: see `chainHopDenies` + * and `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. */ chainHops: GitChainHop[] } From e233a89685bda1fe7ffd51ad06a36c1509d1691f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 18:26:50 +0000 Subject: [PATCH 045/141] Deny where a git deny path lands, not only how it is spelled A Seatbelt filter is compared against the path the kernel resolved an operation to, while the path in the filter is compared as a string. Measured on macOS with `(allow default)` and one `(deny file-write* (subpath P))`, against a `dlink` leading out of the tree: P = dlink/sub (through the link): nothing is refused at all P = dlink (the link itself): only `rm` and `mv` of the link P = the landing: every write, by either spelling, and not the link's own name `literal` and `regex` filters answer the same, and so does `file-read*`, where a deny on the link also stops the traversal through it. So a path that goes through a symlink needs both ends named: as written, which is the only name an unlink or a rename of a link in it uses, and at the landing, which is the only one a write to what it leads to matches. The macOS entries carried the landing only where a `..` made the two physical resolutions differ. A value with no `..` in it - `gitdir: links/shared/gd`, or a submodule behind a symlinked `.git/modules` entry - left every deny under the git directory it reaches spelled through the link, matching nothing: a hook written there landed under either spelling, and a `commondir` could be created at the landing. Every path the git producers read off disk now becomes both spellings, de-duplicated. Where nothing on the way is a symlink the two are the same string and the same single entry, which is every path of an ordinary repository; the `/tmp` and `/var` prefixes collapse the same way, path normalisation already rewriting them onto the `/private` spelling Seatbelt compares against. `physicalDenyPath` is the walk, and `fs.realpathSync` could not serve as one: it throws on the absent tail - a `hooks` or a `config.worktree` that is not there - which is exactly what these denies exist to stop a command creating. Linux is untouched and needs nothing: bubblewrap resolves a deny path before it binds and lands on the same directory whichever spelling it was handed. The 84-configuration argv dump is byte-identical. --- src/sandbox/macos-sandbox-utils.ts | 62 +++++++++++++++++++++++++---- src/sandbox/mandatory-deny-paths.ts | 16 ++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index d498a9851..eb7f554ea 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -25,6 +25,7 @@ import { gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenyPaths, + physicalDenyPath, } from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' @@ -146,7 +147,9 @@ export function macGetMandatoryDenyEntries( // directories against being renamed out from under the deny. The producers // in mandatory-deny-paths.ts return the string arrays the Linux backend // binds, and every string in them was read off the filesystem, so each - // becomes a literal entry here. + // becomes a literal entry here — both of its spellings, since a filter and + // a bind hold a path that goes through a symlink by different ends (see + // gitDiskDenyEntries). const dotGit = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined try { @@ -156,27 +159,26 @@ export function macGetMandatoryDenyEntries( // could not be looked at, which is no reason to skip the enumeration: a // literal entry is a subpath deny, so this denies it whole, as the Linux // backend's bind of the same path does. - if (!isAbsenceErrno(err)) return [...entries, toLiteralPathEntry(dotGit)] + if (!isAbsenceErrno(err)) + return [...entries, ...gitDiskDenyEntries([dotGit])] } if (dotGitStat?.isDirectory()) { entries.push( - ...gitDirTreeDenyPaths(gitDirTreeDenies(dotGit, allowGitConfig)).map( - toLiteralPathEntry, + ...gitDiskDenyEntries( + gitDirTreeDenyPaths(gitDirTreeDenies(dotGit, allowGitConfig)), ), ) } else { // Absent, or a pointer file: the repository's own hooks and config are // denied either way, so neither can be created under a .git that is not // there yet. - entries.push( - ...gitDirDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), - ) + entries.push(...gitDiskDenyEntries(gitDirDenyPaths(dotGit, allowGitConfig))) if (dotGitStat?.isFile()) { // cwd checked out as a linked worktree or submodule: .git is a pointer // file. Nested pointer files are matched by vnode type instead // (gitPointerFilter), which cannot follow them. entries.push( - ...gitFileDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ...gitDiskDenyEntries(gitFileDenyPaths(dotGit, allowGitConfig)), ) } } @@ -184,6 +186,50 @@ export function macGetMandatoryDenyEntries( return entries } +/** + * Literal entries for the deny paths a git producer read off disk: each as it + * was written, and the path the kernel lands on walking it, without + * duplicates. + * + * Seatbelt matches a filter against the path an operation RESOLVED to, while + * the path in the filter is compared as a string, so the two spellings of a + * path that goes through a symlink hold different halves of it. Measured with + * `(deny file-write* (subpath P))` and a `dlink` leading out of the tree: + * + * P = dlink/sub P = dlink P = the landing + * write P/f lands lands REFUSED + * write link/f lands lands REFUSED + * rm / mv dlink lands REFUSED lands + * + * `literal` and `regex` filters answer the same, and so does `file-read*`. + * The path as written is therefore the only name an unlink or a rename of a + * link in it uses, and the landing the only one a write to what it leads to + * matches — a chain needs both ends named, and either alone leaves a hole. + * + * Emitted here rather than by the producers both backends share, because + * Linux has no such split: bwrap resolves a deny path before it binds and + * lands on the same directory whichever spelling it was handed. + * + * The two collapse to one entry wherever nothing on the way is a symlink, + * which is every path of an ordinary repository, and wherever the difference + * is only the `/tmp` and `/var` prefixes ({@link normalizePathForSandbox} + * canonicalises those onto `/private`, which is the spelling Seatbelt + * compares against). + */ +function gitDiskDenyEntries(denyPaths: readonly string[]): PathEntry[] { + const entries: PathEntry[] = [] + const named = new Set() + for (const denyPath of denyPaths) { + for (const spelling of [denyPath, physicalDenyPath(denyPath)]) { + const entry = toLiteralPathEntry(spelling) + if (named.has(entry.path)) continue + named.add(entry.path) + entries.push(entry) + } + } + return entries +} + /** * SBPL filter matching a regular file named `.git` anywhere under cwd, a * `gitdir:` pointer. Matched by vnode type so a repository's .git DIRECTORY diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 70ded2b2f..0869a4d1b 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -1243,6 +1243,22 @@ function physicalPath(base: string, target: string): string { return resolveChain(base, target).landing } +/** + * Where the kernel lands walking `denyPath` from the root: the spelling a + * backend needs whose filters are matched against the path an operation + * RESOLVED to rather than the one it was spelled from (macOS Seatbelt — see + * `gitDiskDenyEntries` in src/sandbox/macos-sandbox-utils.ts). `denyPath` + * itself where nothing on the way is a symlink, which is every path of an + * ordinary repository. + * + * `fs.realpathSync` does not answer this: it throws on a path that is not + * there, and an absent `hooks` or `config.worktree` is exactly what these + * denies exist to stop a command from creating. + */ +export function physicalDenyPath(denyPath: string): string { + return physicalPath(path.parse(denyPath).root, denyPath) +} + /** * The deepest ancestor of `target` (itself included) this process can still * stat as a directory. Denying that directory fails closed when the path From e36206576999467f8439dbe68e034b039d107c31 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 18:26:50 +0000 Subject: [PATCH 046/141] Tests: the git directory a macOS deny reaches only through a link Profile-level, on the entry list and so on every POSIX host: a pointer whose value walks through a symlinked component carries the hop, the git directory as the pointer spells it and the same four files at the landing, the absent `commondir` and `config.worktree` among them; a submodule behind a symlinked `.git/modules` entry carries both spellings the same way. Both fail at the previous head, where the entry list held the link spelling alone. Two more pin what must NOT change: the chain of a symlinked ENTRY is walked from a resolved directory, so each hop and the landing are already the paths the kernel reaches and none of them gains a second spelling, and an ordinary repository names every path exactly once. A macOS-only arm runs the real profile under `sandbox-exec` on the pointer shape: a hook written through the link spelling, the same hook written at the landing and a `commondir` created at the landing are each refused, `rm` and `mv` of the link are refused, and an ordinary write in the checkout succeeds. The three writes all land at the previous head. --- test/sandbox/macos-git-chain-landing.test.ts | 134 +++++++++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 144 +++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 test/sandbox/macos-git-chain-landing.test.ts diff --git a/test/sandbox/macos-git-chain-landing.test.ts b/test/sandbox/macos-git-chain-landing.test.ts new file mode 100644 index 000000000..e83883d41 --- /dev/null +++ b/test/sandbox/macos-git-chain-landing.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterAll, beforeAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { isMacOS } from '../helpers/platform.js' + +/** + * A `gitdir:` pointer whose value goes through a symlink, run under + * sandbox-exec. + * + * Seatbelt compares its filters against the path an operation RESOLVED to, + * while the path in the filter is compared as a string, so the pointer's own + * spelling of the git directory — `/linkdir/gd/hooks` — matches + * nothing at all, and the deny that blocks a write to the hooks git will + * actually run has to name where the link lands. The link's own name still + * needs its own spelling, for the unlink that would point the chain + * somewhere else. The generator emits both; these run them. + * + * The profile-level counterparts are in mandatory-deny-paths.test.ts and run + * on every POSIX host. + */ + +interface ChainTree { + /** Write root, so only the denies are under test. */ + root: string + /** The checkout, and the working directory. */ + checkout: string + /** `/linkdir` -> `/real`. */ + link: string + /** `/real/gd`, where the pointer really lands. */ + gitDir: string +} + +function chainTree(): ChainTree { + const root = join(realpathSync(tmpdir()), `git-chain-landing-${Date.now()}`) + const checkout = join(root, 'checkout') + const gitDir = join(root, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + const link = join(checkout, 'linkdir') + symlinkSync(join(root, 'real'), link) + return { root, checkout, link, gitDir } +} + +describe.if(isMacOS)( + 'macOS sandbox: a pointer whose value walks through a symlink', + () => { + let tree: ChainTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = chainTree() + process.chdir(tree.checkout) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function run(command: string): { status: number | null; stderr: string } { + const result = spawnSync( + wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }), + { + shell: true, + encoding: 'utf8', + timeout: 10000, + // Assert on the message, so pin the language it is written in. + env: { ...process.env, LC_ALL: 'C' }, + }, + ) + return { status: result.status, stderr: result.stderr || '' } + } + + function expectRefused(command: string): void { + const result = run(command) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + } + + it('allows an ordinary write in the checkout (sanity check)', () => { + const result = run( + `echo ok > ${JSON.stringify(join(tree.checkout, 'file.txt'))}`, + ) + expect(result.status).toBe(0) + }) + + it('refuses a hook written through the spelling the pointer uses', () => { + expectRefused( + `echo x > ${JSON.stringify(join(tree.link, 'gd', 'hooks', 'pre-commit'))}`, + ) + }) + + it('refuses the same hook written at the landing', () => { + expectRefused( + `echo x > ${JSON.stringify(join(tree.gitDir, 'hooks', 'pre-commit'))}`, + ) + }) + + it('refuses a commondir created at the landing', () => { + // Not there at all, which is the point: the file git reads to move the + // hooks and config it runs elsewhere is denied so it cannot be made. + expectRefused( + `echo . > ${JSON.stringify(join(tree.gitDir, 'commondir'))}`, + ) + }) + + it('refuses to unlink the link the pointer walks through', () => { + expectRefused(`rm ${JSON.stringify(tree.link)}`) + }) + + it('refuses to rename the link aside', () => { + expectRefused( + `mv ${JSON.stringify(tree.link)} ${JSON.stringify(join(tree.checkout, 'moved'))}`, + ) + }) + }, +) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index af37f253a..d3eae1f6f 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -2392,6 +2392,150 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { } }) + it.if(!isWindows)( + "names a pointer's git directory by the landing as well as through the link", + () => { + // The link spelling holds the link's own name and nothing under it, + // because Seatbelt compares its filters against the path an operation + // resolved to; the landing holds every write that reaches the git + // directory, by either spelling. Both, or half the chain is open. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-landing-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const gitDir = join(dir, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(join(dir, 'real'), hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + // Through the link: the hop's own name, and the git directory's + // files as the pointer spells them. + expect(paths).toContain(hop) + expect(paths).toContain(join(hop, 'gd', 'hooks')) + expect(paths).toContain(join(hop, 'gd', 'config')) + // And the same files where the kernel puts them, absent ones + // included: `commondir` and `config.worktree` are not there, and + // creating one is what the deny blocks. + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(join(gitDir, 'config')) + expect(paths).toContain(join(gitDir, 'commondir')) + expect(paths).toContain(join(gitDir, 'config.worktree')) + // One entry per spelling and no more. + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)( + 'names a submodule reached through a symlinked modules entry both ways', + () => { + // A symlinked ENTRY of `.git/modules` puts the whole submodule git + // directory behind a link, so every file denied inside it is spelled + // through that link. The entry's own name is what an unlink of it + // uses; where it leads is what a hook written into that submodule + // matches. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-module-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const submodule = join(dir, 'elsewhere') + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main') + const entry = join(gitDir, 'modules', 'lib') + symlinkSync(submodule, entry) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(entry, 'hooks')) + expect(paths).toContain(join(entry, 'config')) + expect(paths).toContain(join(submodule, 'hooks')) + expect(paths).toContain(join(submodule, 'config')) + expect(paths).toContain(join(submodule, 'config.worktree')) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)( + 'names the hops of a symlinked entry once, landing and all', + () => { + // The chain of a symlinked entry is walked from a resolved directory, + // so each hop and the landing are already the paths the kernel reaches: + // nothing here has a second spelling, and nothing is named twice. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-entry-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const landing = join(dir, '.githooks') + mkdirSync(landing, { recursive: true }) + const hop = join(dir, 'hooks-link') + symlinkSync('.githooks', hop) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(hop) + expect(paths).toContain(landing) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)( + 'leaves an ordinary repository named exactly once per path', + () => { + // Nothing on the way is a symlink, so the walk lands where the path was + // written and the second spelling is the first: no repository without + // one pays a single extra rule for this. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-plain-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + it('defaults to blocking .git/config when no argument provided', () => { const patterns = macGetMandatoryDenyEntries().map(e => e.path) From 176da8d9bdb6ad0ea79c9600b0baeb14215caa81 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 18:26:50 +0000 Subject: [PATCH 047/141] README: a filter and a bind hold a path through a link by different ends Says what Seatbelt actually compares - the path the kernel resolved the operation to, which for an unlink or a rename is the link's own since the last component is not followed - in place of "the path as the operation names it", and adds what follows from it: a deny whose own spelling goes through a link holds that link's name and nothing beneath it, so every path the git denies were read off disk is named twice on macOS, as written and at the landing. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 43b551500..58a586c80 100644 --- a/README.md +++ b/README.md @@ -779,8 +779,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. -- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, a Seatbelt filter matches the path as an unlink or a rename names it. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. -- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. +- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. +- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. - **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: From cbe6558f61028c06100fac93ee28f30c78c31ea9 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 18:43:04 +0000 Subject: [PATCH 048/141] A chain hop keeps its own name and only its own name The landing rule is right for every deny path in the list but one. A chain HOP is a link the walk went through, and what it leads to is a whole directory nothing asked to deny: the `/private/var` a `/var/folders` pointer value walks through is the plainest case, and denying it takes the checkout, the write root and everything beside them read-only. macOS CI caught it on two linked-worktree cases whose positive control - an ordinary write in the checkout - stopped succeeding. A hop's own name is the entire handle on it, which is the mirror image of Linux dropping hop links from its binds for the same reason (`withoutChainHopLinks`): a bind there resolves the link and lands on the same directory this would have denied. What lies PAST the hop is protected by the git directory's own deny paths, which are in the same list and do carry both spellings - so nothing of the fix is given up. Pinned by a case that fails without it: a pointer reaching its git directory through a link denies the hop and the hooks at the landing, and denies neither the directory the hop leads to nor the checkout. --- src/sandbox/macos-sandbox-utils.ts | 43 ++++++++++++++++------- test/sandbox/mandatory-deny-paths.test.ts | 35 ++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index eb7f554ea..4047c956c 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -24,9 +24,10 @@ import { gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, - gitFileDenyPaths, + gitFileDenies, physicalDenyPath, } from './mandatory-deny-paths.js' +import type { GitChainHop } from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' import type { @@ -159,27 +160,28 @@ export function macGetMandatoryDenyEntries( // could not be looked at, which is no reason to skip the enumeration: a // literal entry is a subpath deny, so this denies it whole, as the Linux // backend's bind of the same path does. - if (!isAbsenceErrno(err)) - return [...entries, ...gitDiskDenyEntries([dotGit])] + if (!isAbsenceErrno(err)) { + return [...entries, ...gitDiskDenyEntries([dotGit], [])] + } } if (dotGitStat?.isDirectory()) { + const tree = gitDirTreeDenies(dotGit, allowGitConfig) entries.push( - ...gitDiskDenyEntries( - gitDirTreeDenyPaths(gitDirTreeDenies(dotGit, allowGitConfig)), - ), + ...gitDiskDenyEntries(gitDirTreeDenyPaths(tree), tree.chainHops), ) } else { // Absent, or a pointer file: the repository's own hooks and config are // denied either way, so neither can be created under a .git that is not // there yet. - entries.push(...gitDiskDenyEntries(gitDirDenyPaths(dotGit, allowGitConfig))) + entries.push( + ...gitDiskDenyEntries(gitDirDenyPaths(dotGit, allowGitConfig), []), + ) if (dotGitStat?.isFile()) { // cwd checked out as a linked worktree or submodule: .git is a pointer // file. Nested pointer files are matched by vnode type instead // (gitPointerFilter), which cannot follow them. - entries.push( - ...gitDiskDenyEntries(gitFileDenyPaths(dotGit, allowGitConfig)), - ) + const pointer = gitFileDenies(dotGit, allowGitConfig) + entries.push(...gitDiskDenyEntries(pointer.denyPaths, pointer.chainHops)) } } @@ -215,12 +217,29 @@ export function macGetMandatoryDenyEntries( * is only the `/tmp` and `/var` prefixes ({@link normalizePathForSandbox} * canonicalises those onto `/private`, which is the spelling Seatbelt * compares against). + * + * A CHAIN HOP is the exception, and gets its own path alone: what a hop leads + * to is a whole directory nothing asked to deny - the `/private/var` a + * `/var/folders` pointer walks through, the shared directory a `links/gd` + * value crosses - and denying it would take everything beside the git + * directory with it. The link's own name is the entire handle on a hop, which + * is why Linux drops it from its binds for the mirror-image reason (see + * `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts). What lies + * past the hop is protected by the git directory's own deny paths, which are + * in this same list and do get both spellings. */ -function gitDiskDenyEntries(denyPaths: readonly string[]): PathEntry[] { +function gitDiskDenyEntries( + denyPaths: readonly string[], + chainHops: readonly GitChainHop[], +): PathEntry[] { + const hopLinks = new Set(chainHops.map(hop => hop.link)) const entries: PathEntry[] = [] const named = new Set() for (const denyPath of denyPaths) { - for (const spelling of [denyPath, physicalDenyPath(denyPath)]) { + const spellings = hopLinks.has(denyPath) + ? [denyPath] + : [denyPath, physicalDenyPath(denyPath)] + for (const spelling of spellings) { const entry = toLiteralPathEntry(spelling) if (named.has(entry.path)) continue named.add(entry.path) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index d3eae1f6f..e2b7e1ae3 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -2437,6 +2437,41 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { }, ) + it.if(!isWindows)('never denies what a chain HOP leads to', () => { + // A hop's own name is the whole handle on it: what it leads to is a + // directory nothing asked to deny - the `/private/var` a `/var/folders` + // pointer walks through - and denying that would take the checkout and + // everything beside it read-only. What lies past the hop is held by the + // git directory's own deny paths, which do carry both spellings. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-hop-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const shared = join(dir, 'shared') + const gitDir = join(shared, 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(shared, hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(hop) + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).not.toContain(shared) + expect(paths).not.toContain(dir) + expect(paths).not.toContain(checkout) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + it.if(!isWindows)( 'names a submodule reached through a symlinked modules entry both ways', () => { From b3b2bb5c8a372e2d58c62e919e242746a7caa430 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 18:57:19 +0000 Subject: [PATCH 049/141] README: a hop keeps its own name alone The landing rule holds for every deny path the git producers read off disk except a chain hop, whose landing is a whole directory nothing asked to deny. Says so where the rule is stated. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b8151d2b..e75be71f6 100644 --- a/README.md +++ b/README.md @@ -780,7 +780,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. - **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. -- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. +- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. - **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: From b6c79e0baf6758afd6c516020d6635073dc0bfec Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 19:20:11 +0000 Subject: [PATCH 050/141] Record which mount points a running sandbox relies on, and let the kernel say so A deny on a path that does not exist needs a mount point on the host, and unlinking one while a sandbox is still bound over it detaches that mount inside the sandbox: the denied path can then be created, and what is written there lands on the host. So a mount point may only be removed when no running sandbox relies on it. Nothing a process counts for itself can decide that. A second srt process sees another's live mount point as a leftover and takes it away when it exits; a caller that crashes, cleans up early, twice, or never, each gets a different answer. Ask the kernel instead. Each wrap writes a manifest naming the mount points it relies on into a per-user runtime directory and points bubblewrap's --lock-file at it: bubblewrap's sandbox init opens that path and holds an fcntl read lock on it for exactly the sandbox's lifetime, which the kernel drops however the sandbox ends, SIGKILL included. /proc/locks then answers, for any process and at any moment, whether a sandbox that named a mount point is still running. The lock belongs to the sandbox's init process, not to the command: bubblewrap opens the manifest with O_CLOEXEC, so the descriptor does not survive the exec, and a POSIX lock is only dropped by the process that took it. Every error reads as "still in use": an unreadable /proc/locks, a directory that cannot be listed, a manifest that cannot be read while a sandbox holds its lock, and a manifest younger than the moment bubblewrap takes to reach that lock. --- src/sandbox/bwrap-mount-manifests.ts | 750 +++++++++++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 src/sandbox/bwrap-mount-manifests.ts diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts new file mode 100644 index 000000000..3cdfd9dc2 --- /dev/null +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -0,0 +1,750 @@ +/** + * Which mount points on the host a running sandbox still relies on (Linux). + * + * A deny on a path that does not exist needs a mount point there: bwrap makes + * an empty file (or, for an intermediate component, an empty directory) on the + * host and binds /dev/null or an empty directory over it. Unlinking that file + * while a sandbox is still bound over it detaches the mount inside that + * sandbox — the denied path can then be created, and what is written lands on + * the host. A mount point may therefore only be removed when no running + * sandbox relies on it, and nothing a process counts for itself can decide + * that: a second srt process, a caller that crashes, one that cleans up early, + * twice or never, each gets a different answer. + * + * The kernel is asked instead. Each wrap writes a manifest naming the mount + * points it relies on into a per-user runtime directory and passes + * `--lock-file ` to bwrap. bubblewrap's sandbox init process opens + * that path and holds an fcntl read lock (F_RDLCK through F_SETLK) on it for + * exactly the sandbox's lifetime; the kernel drops the lock however the + * sandbox ends, SIGKILL included. /proc/locks then answers, for any process + * and at any moment, whether a sandbox that named a mount point is still + * running, so cleanup becomes a garbage collect any process may run at any + * time and any number of times: a mount point goes only when no live manifest + * names it. + * + * The lock belongs to the sandbox's init process, not to the command: bwrap + * opens the manifest with O_CLOEXEC, so the descriptor does not survive the + * exec into the command, and a POSIX lock is only dropped by the process that + * took it. The directory is bound read-only into every sandbox whose + * invocation names a manifest in it, so a sandboxed command can neither + * delete nor rewrite one. + */ + +import { randomBytes } from 'node:crypto' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { z } from 'zod' +import { logForDebugging } from '../utils/debug.js' +import { isAbsenceErrno } from './sandbox-utils.js' + +/** The only manifest layout this version writes and reads. */ +const MANIFEST_VERSION = 1 +const MANIFEST_SUFFIX = '.json' + +/** + * A manifest written this recently counts as live even with no lock on it and + * its writer gone. bubblewrap takes the lock in the sandbox's init process, + * which it forks alongside the command, so a wrapping process killed between + * the exec and that fcntl would otherwise leave a starting sandbox's mount + * point unclaimed. The window to cover is bubblewrap's own startup, a few + * milliseconds; this is two orders of magnitude more, and short enough that a + * mount point a killed process left behind is collectable while the command + * after it is still being wrapped. + */ +const MANIFEST_GRACE_MS = 500 + +/** How long a collect or a publish waits for the directory lock. */ +const DIRECTORY_LOCK_WAIT_MS = 2_000 + +/** Between attempts at the directory lock. */ +const DIRECTORY_LOCK_RETRY_MS = 25 + +/** + * A directory lock this old is broken, for a process that died between taking + * it and the unlink. Far past the milliseconds a collect takes. + */ +const DIRECTORY_LOCK_STALE_MS = 60_000 + +/** + * A manifest whose contents cannot be read is dropped once it is this old and + * no sandbox holds its lock — long past anything that could still be using + * what it names, and long enough that another version of this library writing + * to the same directory keeps its manifests for as long as it needs them. + */ +const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 + +const ManifestSchema = z.object({ + version: z.literal(MANIFEST_VERSION), + /** The process that wrapped, and its start time, to tell a recycled pid. */ + pid: z.number().int().nonnegative(), + start: z.string(), + /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ + created: z.number(), + /** The mount points bwrap makes on the host for that wrap's deny paths. */ + paths: z.array(z.string()), + /** + * The empty directories those mount points bind FROM, which this library + * makes itself. A live bind's source must stay, so they go the same way the + * mount points do, and are held to what {@link removeMountSource} asks: a + * source that is no longer our own private empty directory is not ours to + * remove. + */ + sources: z.array(z.string()), +}) + +type Manifest = z.infer & { + file: string + /** The inode /proc/locks reports a lock on. */ + inode: number + /** + * Whether this process has taken the manifest away: it wrote it, the caller + * says the command is done, and nothing held the lock. Only a lock can still + * make it live, and only for the moment between that reading and the unlink + * — the path is gone, so no further sandbox can open it. + */ + released: boolean +} + +/** Manifests this process wrote and has not released. */ +const ownManifests = new Set() + +let manifestDirectory: string | undefined +let directoryFailureLogged = false + +/** + * Field 22 of /proc/PID/stat, the process's start time in clock ticks. The + * comm field can hold spaces and parentheses, so the fields are counted from + * the last ')' rather than from the start of the line. + */ +function startTimeFromProcStat(stat: string): string | undefined { + return stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19] +} + +function processStartTime(pid: number): string | undefined { + try { + return startTimeFromProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')) + } catch { + return undefined + } +} + +/** + * Whether the process a manifest names is still running. Only "the process is + * gone" answers no: a /proc that cannot be read must not make a live sandbox's + * manifest collectable. + */ +function writerIsRunning(pid: number, start: string): boolean { + try { + return ( + startTimeFromProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')) === + start + ) + } catch (e) { + return !isAbsenceErrno(e) + } +} + +/** + * The inodes /proc/locks reports a lock on, of any kind. Any lock on a + * manifest is the one bubblewrap's sandbox init holds — nothing else opens one + * to lock it — and the device is deliberately not compared: a filesystem whose + * /proc/locks device differs from the one stat reports (an overlay, a bind of + * a subvolume) would otherwise hide a live sandbox's lock, while an inode + * number colliding with an unrelated locked file's only keeps a mount point + * around longer. + * + * `undefined` when the list could not be read, which every caller reads as + * "every manifest is locked". + */ +function lockedInodes(): Set | undefined { + let text: string + try { + text = fs.readFileSync('/proc/locks', 'utf8') + } catch (e) { + logForDebugging( + `[Sandbox Linux] /proc/locks could not be read (${String(e)}) - leaving every mount point where it is`, + { level: 'warn' }, + ) + return undefined + } + const inodes = new Set() + for (const line of text.split('\n')) { + // The major:minor:inode word, which sits one field later on the lines that + // describe a blocked request ("2: -> POSIX ADVISORY WRITE ..."). + for (const word of line.split(/\s+/)) { + const match = /^[0-9a-f]+:[0-9a-f]+:(\d+)$/.exec(word) + if (match?.[1] !== undefined) { + inodes.add(Number(match[1])) + break + } + } + } + return inodes +} + +/** Whether `dir` is a directory of ours that nobody else can write. */ +function isOurPrivateDirectory(dir: string): boolean { + try { + const stat = fs.lstatSync(dir) + return ( + stat.isDirectory() && + stat.uid === process.getuid?.() && + (stat.mode & 0o077) === 0 + ) + } catch { + return false + } +} + +function makeOurPrivateDirectory(dir: string): boolean { + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + // mkdir asks for 0700 but the umask applies, and a directory an earlier + // run left keeps the mode it was made with, so set it here rather than let + // the check below reject a directory that is ours. + fs.chmodSync(dir, 0o700) + } catch { + // Someone else's directory at that name, or a filesystem that will not + // take one: the check below decides. + } + return isOurPrivateDirectory(dir) +} + +/** + * The directory manifests live in: under $XDG_RUNTIME_DIR when there is one, + * else a per-user directory under the system temp dir, else — when neither can + * be made ours alone, as in a sandbox that binds them read-only — a private + * directory only this process knows, which keeps the guarantee within this + * process and loses only what other processes would have read. + * + * Revalidated on every use: the temp dir is somewhere sandboxed commands + * commonly write, and a directory swapped for a symlink between two wraps + * would put manifests somewhere else entirely. + */ +function ensureManifestDirectory(): string | undefined { + if ( + manifestDirectory !== undefined && + isOurPrivateDirectory(manifestDirectory) + ) { + return manifestDirectory + } + const runtimeDir = process.env['XDG_RUNTIME_DIR'] + const shared = + runtimeDir !== undefined && path.isAbsolute(runtimeDir) + ? [path.join(runtimeDir, 'srt-mount-points')] + : [] + shared.push( + path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), + ) + for (const candidate of shared) { + if (makeOurPrivateDirectory(candidate)) { + manifestDirectory = candidate + return candidate + } + } + try { + const private_ = fs.mkdtempSync(path.join(tmpdir(), 'srt-mount-points-')) + fs.chmodSync(private_, 0o700) + manifestDirectory = private_ + logForDebugging( + `[Sandbox Linux] No shared directory for mount point manifests, using one only this process knows: ${private_}`, + { level: 'warn' }, + ) + return private_ + } catch (e) { + manifestDirectory = undefined + if (!directoryFailureLogged) { + directoryFailureLogged = true + logForDebugging( + `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are left on the host`, + { level: 'warn' }, + ) + } + return undefined + } +} + +/** Block this thread, the only sleep available to a synchronous cleanup. */ +function sleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +/** Break a directory lock whose holder is gone, or that is simply too old. */ +function breakStaleDirectoryLock(lockFile: string): boolean { + let holder: string + let age: number + try { + const fd = fs.openSync(lockFile, 'r') + try { + age = Date.now() - fs.fstatSync(fd).mtimeMs + holder = fs.readFileSync(fd, 'utf8') + } finally { + fs.closeSync(fd) + } + } catch { + // Gone between the failed create and this read: the next attempt takes it. + return true + } + const [pid, start] = holder.trim().split(' ') + const held = + pid !== undefined && + start !== undefined && + writerIsRunning(Number(pid), start) && + age < DIRECTORY_LOCK_STALE_MS + if (held) { + return false + } + try { + fs.unlinkSync(lockFile) + } catch { + // Another process broke it first. + } + return true +} + +/** + * Run `body` while holding the manifest directory's lock, so a collect works + * from a set of manifests no other srt process is changing under it, and + * report whether it ran. + * + * The lock serialises srt processes; it is not what makes a mount point safe + * to remove. That is the kernel's answer about each manifest, re-read + * immediately before every unlink, which is also what covers the one actor + * outside this lock: the bubblewrap a wrap has already handed to its caller. + */ +function withDirectoryLock( + dir: string, + body: () => T, +): { ran: true; value: T } | { ran: false } { + const lockFile = path.join(dir, 'directory.lock') + const deadline = Date.now() + DIRECTORY_LOCK_WAIT_MS + for (;;) { + let fd: number + try { + fd = fs.openSync(lockFile, 'wx', 0o600) + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') { + return { ran: false } + } + if (!breakStaleDirectoryLock(lockFile) && Date.now() >= deadline) { + return { ran: false } + } + sleep(DIRECTORY_LOCK_RETRY_MS) + continue + } + try { + fs.writeSync( + fd, + `${process.pid} ${processStartTime(process.pid) ?? ''}\n`, + ) + } catch { + // A holder that cannot be identified is broken by age alone. + } finally { + fs.closeSync(fd) + } + try { + return { ran: true, value: body() } + } finally { + try { + fs.unlinkSync(lockFile) + } catch { + // Broken by another process while we held it: nothing to undo. + } + } + } +} + +function readManifest(file: string): Manifest | undefined { + let inode: number + let text: string + try { + const fd = fs.openSync(file, 'r') + try { + inode = fs.fstatSync(fd).ino + text = fs.readFileSync(fd, 'utf8') + } finally { + fs.closeSync(fd) + } + } catch { + return undefined + } + let json: unknown + try { + json = JSON.parse(text) + } catch { + return undefined + } + const parsed = ManifestSchema.safeParse(json) + return parsed.success + ? { ...parsed.data, file, inode, released: false } + : undefined +} + +/** Whether a sandbox that named this manifest may still be running. */ +function isLive(manifest: Manifest, locked: Set): boolean { + if (locked.has(manifest.inode)) { + return true + } + if (manifest.released) { + return false + } + return ( + Date.now() - manifest.created < MANIFEST_GRACE_MS || + writerIsRunning(manifest.pid, manifest.start) + ) +} + +/** + * Remove an empty directory a placeholder bound from, if it is still our own. + * It sits under the system temp dir, which sandboxed commands commonly can + * write, so anything that is not a private empty directory of ours — a + * symlink, a directory whose mode has been widened, one with something in it — + * is somebody else's and is left exactly as found. + */ +function removeMountSource(source: string): boolean { + try { + const stat = fs.lstatSync(source) + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 || + fs.readdirSync(source).length > 0 + ) { + logForDebugging( + `[Sandbox Linux] Left the empty-directory mount source behind, it is no longer our own empty directory: ${source}`, + ) + return false + } + fs.rmdirSync(source) + logForDebugging( + `[Sandbox Linux] Cleaned up the empty-directory mount source: ${source}`, + ) + return true + } catch { + // Gone, or no longer ours to look at. + } + return false +} + +/** + * Remove a mount point, if it is still the empty file or directory bwrap made. + * Anything with content, or a directory something has written into, is left + * where it is. + */ +function removeMountPoint(mountPoint: string): boolean { + try { + const stat = fs.statSync(mountPoint) + if (stat.isFile() && stat.size === 0) { + fs.unlinkSync(mountPoint) + logForDebugging( + `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, + ) + return true + } + if (stat.isDirectory()) { + if (fs.readdirSync(mountPoint).length > 0) { + logForDebugging( + `[Sandbox Linux] Left a bwrap mount point directory behind, something has written into it: ${mountPoint}`, + ) + return false + } + // rmdir, not a recursive remove: it neither follows a symlink nor + // descends, so a path that is no longer ours is left exactly as found. + fs.rmdirSync(mountPoint) + logForDebugging( + `[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`, + ) + return true + } + } catch { + // Already gone, or no longer ours to look at. + } + return false +} + +/** What {@link publishMountPointManifest} wrote, for the bwrap invocation. */ +export type MountPointManifest = { + /** The directory to bind read-only inside the sandbox. */ + dir: string + /** The manifest, for bubblewrap's --lock-file. */ + file: string +} + +/** + * Record the mount points this wrap relies on, and the empty directories they + * bind from, before the sandbox that relies on them can start, and say where + * to point bubblewrap's --lock-file. + * + * `undefined` when there is nothing to record, or when no manifest could be + * written — in which case the caller must not track those mount points at all: + * what this process cannot record, no process may remove. + */ +export function publishMountPointManifest( + mountPoints: readonly string[], + sources: readonly string[], +): MountPointManifest | undefined { + if (mountPoints.length === 0 && sources.length === 0) { + return undefined + } + const dir = ensureManifestDirectory() + if (dir === undefined) { + return undefined + } + const file = path.join( + dir, + `${process.pid}-${randomBytes(8).toString('hex')}${MANIFEST_SUFFIX}`, + ) + const temporary = `${file}.tmp` + const body: z.infer = { + version: MANIFEST_VERSION, + pid: process.pid, + start: processStartTime(process.pid) ?? '', + created: Date.now(), + paths: [...new Set(mountPoints)], + sources: [...new Set(sources)], + } + // Written beside the manifest and renamed onto it, so a collect in another + // process reads it whole or not at all. Taking the directory lock for the + // rename keeps a collect from running between the manifests it listed and + // its unlinks; publishing without the lock is still sound, because the + // rename is atomic and the sandbox this manifest covers cannot have started. + try { + fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) + const publish = (): void => fs.renameSync(temporary, file) + if (!withDirectoryLock(dir, publish).ran) { + publish() + } + } catch (e) { + try { + fs.unlinkSync(temporary) + } catch { + // Never made. + } + logForDebugging( + `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are left on the host`, + { level: 'warn' }, + ) + return undefined + } + ownManifests.add(file) + return { dir, file } +} + +/** + * Drop the manifest of a wrap that never produced a command. No sandbox can + * hold its lock, so the mount points it named are no one's. + */ +export function discardMountPointManifest(file: string): void { + ownManifests.delete(file) + try { + fs.unlinkSync(file) + } catch { + // Collected already. + } +} + +/** + * The mount points named by a manifest whose sandbox may still be running. + * + * An empty set when the directory or /proc/locks cannot be read: the shape of + * the file on the host still recognises a mount point an earlier sandbox left + * behind, and this only adds the ones a live manifest vouches for. + */ +export function liveMountPoints(): Set { + const live = new Set() + const dir = ensureManifestDirectory() + const locked = dir === undefined ? undefined : lockedInodes() + if (dir === undefined || locked === undefined) { + return live + } + let names: string[] + try { + names = fs.readdirSync(dir) + } catch { + return live + } + for (const name of names) { + if (!name.endsWith(MANIFEST_SUFFIX)) continue + const manifest = readManifest(path.join(dir, name)) + if (manifest !== undefined && isLive(manifest, locked)) { + for (const mountPoint of manifest.paths) { + live.add(mountPoint) + } + } + } + return live +} + +/** + * Release the manifests of this process's finished wraps and remove every + * mount point no live manifest names, from any process, at any time, any + * number of times. Returns the mount points removed. + */ +export function collectMountPoints(): string[] { + const dir = ensureManifestDirectory() + if (dir === undefined) { + return [] + } + const collected = withDirectoryLock(dir, () => collectUnderLock(dir)) + if (!collected.ran) { + logForDebugging( + `[Sandbox Linux] Another process holds the mount point directory, leaving this pass to it: ${dir}`, + ) + return [] + } + return collected.value +} + +function collectUnderLock(dir: string): string[] { + let names: string[] + try { + names = fs.readdirSync(dir) + } catch (e) { + logForDebugging( + `[Sandbox Linux] The mount point manifests could not be listed (${String(e)}) - nothing removed`, + { level: 'warn' }, + ) + return [] + } + const manifests: Manifest[] = [] + const unreadable: string[] = [] + for (const name of names) { + if (!name.endsWith(MANIFEST_SUFFIX)) continue + const file = path.join(dir, name) + const manifest = readManifest(file) + if (manifest === undefined) { + unreadable.push(file) + } else { + manifests.push(manifest) + } + } + const locked = lockedInodes() + if (locked === undefined) { + return [] + } + + // Release this process's own wraps: the caller says their commands are done. + // One whose manifest is still locked has a sandbox running under it, and + // unlinking the manifest would take the kernel's answer about it away. What + // a released manifest named is still collected below — it is the usual way a + // mount point goes, right after the command that relied on it. + const outstanding: Manifest[] = [] + for (const manifest of manifests) { + if (ownManifests.has(manifest.file) && !locked.has(manifest.inode)) { + ownManifests.delete(manifest.file) + manifest.released = true + try { + fs.unlinkSync(manifest.file) + } catch { + // Collected by another process already. + } + } + outstanding.push(manifest) + } + + // A manifest whose contents cannot be read — a file another version of this + // library wrote, a truncated one, one this process may not read — names + // mount points that cannot be honoured one by one. While the kernel says a + // sandbox is running under it, or it is new enough that one may be starting, + // nothing at all is removed; once it is neither, whatever it names is not in + // use and it stands in the way of nothing. + let unreadableIsLive = false + for (const file of unreadable) { + let stat: fs.Stats + try { + stat = fs.statSync(file) + } catch { + continue + } + const age = Date.now() - stat.mtimeMs + if (locked.has(stat.ino) || age < MANIFEST_GRACE_MS) { + unreadableIsLive = true + } else if (age > UNREADABLE_MANIFEST_MAX_AGE_MS) { + try { + fs.unlinkSync(file) + } catch { + // Gone already. + } + } + } + if (unreadableIsLive) { + logForDebugging( + '[Sandbox Linux] A mount point manifest a sandbox is running under could not be read - leaving every mount point where it is', + { level: 'warn' }, + ) + return [] + } + + const finished: Manifest[] = [] + const claimed = new Set() + for (const manifest of outstanding) { + if (isLive(manifest, locked)) { + for (const path of [...manifest.paths, ...manifest.sources]) { + claimed.add(path) + } + } else { + finished.push(manifest) + } + } + // Each path once, and as a mount point where a manifest names it as one. + const candidates = new Map() + for (const manifest of finished) { + for (const mountPoint of manifest.paths) { + if (!claimed.has(mountPoint)) { + candidates.set(mountPoint, false) + } + } + for (const source of manifest.sources) { + if (!claimed.has(source) && !candidates.has(source)) { + candidates.set(source, true) + } + } + } + + const removed: string[] = [] + for (const [candidate, isSource] of candidates) { + // The kernel is asked again immediately before the unlink. A manifest + // cannot appear mid-pass — publishing takes this same directory lock — but + // the bubblewrap a wrap has already handed to its caller takes its lock + // outside it, so a manifest that was unlocked when this pass started can + // be locked by the time its mount point is reached. + const lockedNow = lockedInodes() + if (lockedNow === undefined) { + return removed + } + const live = finished.some( + manifest => + (manifest.paths.includes(candidate) || + manifest.sources.includes(candidate)) && + isLive(manifest, lockedNow), + ) + if (live) { + continue + } + if (isSource ? removeMountSource(candidate) : removeMountPoint(candidate)) { + removed.push(candidate) + } + } + for (const manifest of finished) { + if (manifest.released) { + continue + } + const lockedNow = lockedInodes() + if (lockedNow === undefined) { + return removed + } + if (isLive(manifest, lockedNow)) { + continue + } + try { + fs.unlinkSync(manifest.file) + } catch { + // Collected by another process already. + } + } + return removed +} From ec16c45182bbf90afdf2dc1488d34129108f7168 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 19:20:11 +0000 Subject: [PATCH 051/141] Remove a mount point only when no running sandbox relies on it wrapCommandWithSandboxLinux() named every mount point it made in a set in this process's memory, and cleanupBwrapMountPoints() counted the wraps it had been told about and emptied that set when the count reached zero. The count only ever described this process, so a second srt process took away a live sandbox's mount points, and a caller that crashed, cleaned up early, twice or never broke the count itself. Each wrap now names its mount points in a manifest and passes --ro-bind --lock-file to bubblewrap, and the cleanup is a garbage collect against those manifests: it is safe from any process, at any time, any number of times, and it also takes away what other srt processes have finished with. The counter and the exactly-once contract are gone; cleanupBwrapMountPoints() keeps its signature, and `force` no longer changes what is removed. The manifest directory is bound read-only after every allow and deny bind, so a sandboxed command can neither delete nor rewrite a manifest, and so bubblewrap can open one whatever the profile has mounted over its directory. A command wrapped but not started when a cleanup runs no longer keeps its mount points: the cleanup releases them, and bubblewrap then refuses to start that command ("Unable to open lock file") rather than running it with a mount point another call has taken away. --- src/sandbox/linux-sandbox-utils.ts | 259 +++++++++++++---------------- 1 file changed, 120 insertions(+), 139 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 4579a9424..20b3a3860 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -8,6 +8,13 @@ import type { ChildProcess } from 'node:child_process' import { endianness, tmpdir } from 'node:os' import path, { join } from 'node:path' import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js' +import { + collectMountPoints, + discardMountPointManifest, + liveMountPoints, + publishMountPointManifest, + type MountPointManifest, +} from './bwrap-mount-manifests.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { generateProxyEnvVars, @@ -413,12 +420,6 @@ async function linuxGetMandatoryDenyPaths( return [...new Set(denyPaths)] } -// Track mount points created by bwrap for non-existent deny paths. -// When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty -// file on the host as a mount point. These persist after bwrap exits and must -// be cleaned up explicitly. -const bwrapMountPoints: Set = new Set() - // The source of the empty-directory mount points: at most one at a time, made // on first use, reused while a sandbox is running, and removed with the mount // points — never before, because a live bind's source must stay. @@ -458,17 +459,19 @@ function ensureEmptyMountSourceDir(): string { } /** - * Is `p` a mount point an earlier sandbox left behind? bwrap makes the mount - * point for `--ro-bind /dev/null ` with ensure_file(dest, 0444): - * an empty regular file with no write bits. The set above lives in memory, so - * a process that dies without an 'exit' event (SIGKILL, OOM) leaves the file - * on the host with nothing left that knows what it is. Files that only look - * similar are written by their creators with write bits (a lockfile, an empty - * file materialised on purpose: 0666 & ~umask) or have content or a second - * link. An empty read-only DIRECTORY left the same way is not recognisable: - * it looks like anyone's empty directory. + * Is `p` a mount point some sandbox made? bwrap makes the mount point for + * `--ro-bind /dev/null ` with ensure_file(dest, 0444): an empty + * regular file with no write bits. Files that only look similar are written by + * their creators with write bits (a lockfile, an empty file materialised on + * purpose: 0666 & ~umask) or have content or a second link. An empty read-only + * DIRECTORY left the same way is not recognisable: it looks like anyone's + * empty directory. + * + * Whether the sandbox that made it is still running is not asked here and is + * not the caller's business: this wrap covers the path and names it in its own + * manifest either way, and the manifests decide who may remove it. */ -function isStaleBwrapMountPoint(p: string): boolean { +function isBwrapMountPoint(p: string): boolean { try { const stat = fs.lstatSync(p) return ( @@ -755,12 +758,12 @@ export type LinuxSandboxProfileErrorCode = * Thrown when a Linux bubblewrap profile cannot be run on this host: what the * configuration expands to is past a limit, or, for `command_too_long` and * `nul_in_path`, what the caller passed in is. The command was not run and no - * profile file stays open, so do not run the per-command cleanup - * (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that - * threw: it would release a second time, and a sandbox still running would - * lose its mount points. Branch on `.code`, never on `.message`, which - * carries the sizes of the moment. Other wrap-time failures (a shell that is - * not on PATH, a bridge socket that is gone) are plain Errors. + * profile file stays open; the wrap has already let go of the mount points it + * had named, so the per-command cleanup (`cleanupAfterCommand()`, + * `cleanupBwrapMountPoints()`) is neither needed nor harmful after one. + * Branch on `.code`, never on `.message`, which carries the sizes of the + * moment. Other wrap-time failures (a shell that is not on PATH, a bridge + * socket that is gone) are plain Errors. */ export class LinuxSandboxProfileError extends Error { readonly code: LinuxSandboxProfileErrorCode @@ -870,13 +873,6 @@ function renderBwrapInvocation( return viaArgsFile } -// Number of wrapped commands that have been generated but whose cleanup has -// not yet run. cleanupBwrapMountPoints() defers file deletion while this is -// positive, because deleting a mount point file on the host while another -// bwrap instance is still running detaches that instance's bind mount and -// the deny rule stops applying inside it. -let activeSandboxCount = 0 - let exitHandlerRegistered = false /** @@ -905,84 +901,26 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called automatically on process exit as a safety net. * - * Each call decrements the active-sandbox counter that was incremented by - * wrapCommandWithSandboxLinux(). File deletion is deferred until the counter - * reaches zero. Deleting a mount point file on the host while another bwrap - * instance is still running detaches that instance's bind mount (the dentry - * is unhashed, so path lookup no longer finds the mount) and the deny rule - * stops applying inside that sandbox. - * - * Pass `{ force: true }` to delete unconditionally — used by the process-exit - * handler and reset() where deferral is not meaningful. + * A mount point is removed only when no running sandbox relies on it, which + * the kernel answers rather than this process (see bwrap-mount-manifests.ts). + * So the call is a garbage collect that any process may make at any time — + * early, twice, or not at all — and it also takes away what other srt + * processes have finished with. `force` is kept for callers that pass it and + * makes no difference to what is removed. * - * Also closes the `--args` profiles the wraps of this batch opened. + * It does not wait for a wrap whose command this process has not run yet: the + * `--args` profiles below are closed, and a manifest this process wrote and + * has no sandbox running under is released, so a command wrapped but not yet + * started refuses to start (bwrap exits with "Unable to open lock file") + * rather than running with a mount point another call has removed. */ -export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { - if (!opts?.force) { - if (activeSandboxCount > 0) { - activeSandboxCount-- - } - if (activeSandboxCount > 0) { - logForDebugging( - `[Sandbox Linux] Deferring mount point cleanup — ${activeSandboxCount} sandbox(es) still active`, - ) - return - } - } else { - activeSandboxCount = 0 - } - - for (const mountPoint of bwrapMountPoints) { - try { - // Only remove if it's still the empty file/directory bwrap created. - // If something else has written real content, leave it alone. - const stat = fs.statSync(mountPoint) - if (stat.isFile() && stat.size === 0) { - fs.unlinkSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, - ) - } else if (stat.isDirectory()) { - // Empty directory mount points are created for intermediate - // components (Fix 2). Only remove if still empty. - const entries = fs.readdirSync(mountPoint) - if (entries.length === 0) { - fs.rmdirSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`, - ) - } - } - } catch { - // Ignore cleanup errors — the file may have already been removed - } - } - bwrapMountPoints.clear() - if (emptyMountSourceDir !== undefined) { - try { - // rmdirSync, not a recursive remove: it neither follows a symlink nor - // descends, so a path that is no longer our empty directory is left - // exactly as found. - fs.rmdirSync(emptyMountSourceDir) - logForDebugging( - `[Sandbox Linux] Cleaned up the empty-directory mount source: ${emptyMountSourceDir}`, - ) - emptyMountSourceDir = undefined - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code === 'ENOTEMPTY') { - logForDebugging( - `[Sandbox Linux] Left the empty-directory mount source behind, something has written into it: ${emptyMountSourceDir}`, - ) - } - // Forget the path only when there is nothing left to remove. ENOTEMPTY, - // EACCES and EBUSY (a forced cleanup while a live sandbox still pins the - // source) leave it ours to try again; the revalidation on next use - // decides whether it can be reused. - if (code === 'ENOENT' || code === 'ENOTDIR') { - emptyMountSourceDir = undefined - } - } +export function cleanupBwrapMountPoints(_opts?: { force?: boolean }): void { + const removed = collectMountPoints() + if ( + emptyMountSourceDir !== undefined && + removed.includes(emptyMountSourceDir) + ) { + emptyMountSourceDir = undefined } for (const argsFd of [...bwrapArgsFds]) { @@ -1652,10 +1590,19 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, -): Promise { +): Promise<{ args: string[]; manifest: MountPointManifest | undefined }> { const args: string[] = [] // fs already imported + // The mount points on the host this wrap relies on: the placeholders bwrap + // makes for absent deny paths, and the ones another sandbox made that this + // wrap covers again. They are named in a manifest below, and nothing removes + // them while a sandbox that named them runs (see bwrap-mount-manifests.ts). + const mountPoints: string[] = [] + // The mount points a manifest says a running sandbox relies on, read once + // per wrap: they are mount points whatever their mode on the host says. + let liveMountPointsCache: Set | undefined + // Collect normalized allowed write paths. Populated in the writeConfig // block, read again in the denyRead loop to re-bind writes under tmpfs. const allowedWritePaths: string[] = [] @@ -2375,28 +2322,35 @@ async function generateFilesystemArgs( continue } - // A mount point an earlier sandbox left on the host (see - // isStaleBwrapMountPoint) is an absent deny path in all but name. Bound - // onto itself as an existing file it would never be tracked, so never - // removed, and on the host its existence can be the whole meaning (a - // lockfile's). Cover it with /dev/null like the absent leaf below and - // track it, so cleanupBwrapMountPoints() takes it away. Same gate as - // that branch. Under a read-only denied directory nothing needs - // covering (the file is already unwritable there), but it is still - // tracked: it is no more the caller's file for being there. + // Whether anything is at the path, asked once for the two branches + // below. + const pathExists = fs.existsSync(normalizedPath) + + // A mount point another sandbox made (see isBwrapMountPoint, and the + // manifests, which name the ones a running sandbox relies on however + // they look on the host) is an absent deny path in all but name. Bound + // onto itself as an existing file it would never be named in this wrap's + // manifest, so never removed, and on the host its existence can be the + // whole meaning (a lockfile's). Cover it with /dev/null like the absent + // leaf below and name it, so a collect takes it away once no sandbox + // relies on it. Same gate as that branch. Under a read-only denied + // directory nothing needs covering (the file is already unwritable + // there), but it is still named: it is no more the caller's file for + // being there. if ( (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath)) && - isStaleBwrapMountPoint(normalizedPath) + (isBwrapMountPoint(normalizedPath) || + (pathExists && + (liveMountPointsCache ??= liveMountPoints()).has(normalizedPath))) ) { if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } - bwrapMountPoints.add(normalizedPath) - registerExitCleanupHandler() + mountPoints.push(normalizedPath) logForDebugging( - `[Sandbox Linux] Re-covering a mount point an earlier sandbox left behind: ${normalizedPath}`, + `[Sandbox Linux] Re-covering a mount point another sandbox made: ${normalizedPath}`, ) continue } @@ -2406,9 +2360,9 @@ async function generateFilesystemArgs( // doesn't exist yet, bypassing the deny rule entirely. // // bwrap creates empty files on the host as mount points for these binds. - // We track them in bwrapMountPoints so cleanupBwrapMountPoints() can - // remove them after the command exits. - if (!fs.existsSync(normalizedPath)) { + // This wrap names them in its manifest, so a collect can remove them + // once no running sandbox relies on them. + if (!pathExists) { // Fix 1 (worktree): If any existing component in the deny path is a // file (not a directory), skip the deny entirely. You can't mkdir // under a file, so the deny path can never be created. This handles @@ -2493,8 +2447,7 @@ async function generateFilesystemArgs( // below a second spelling to test, and `dest` itself is always // tested. denyWriteRawDests.set(firstNonExistent, rawPath) - bwrapMountPoints.add(firstNonExistent) - registerExitCleanupHandler() + mountPoints.push(firstNonExistent) logForDebugging( `[Sandbox Linux] Mounted ${ isIntermediate ? 'empty dir' : '/dev/null' @@ -2881,6 +2834,36 @@ async function generateFilesystemArgs( } } + // Name the mount points this wrap relies on, before the sandbox that relies + // on them can start, and have bwrap hold a lock on the manifest for exactly + // as long as that sandbox runs: the lock is what tells every process, this + // one included, that these mount points are still in use. Mount points that + // could not be recorded are left on the host, since what this process cannot + // record, no process may remove. + // + // INVARIANT: the manifest directory must never be writable from inside the + // sandbox — a command that could delete a manifest could make the mount + // points its own sandbox relies on look unused. Emitted after every allow + // and deny bind, for the same reason as the two stores below (all three are + // directories of their own, so their order among themselves does not + // matter). It is also what lets bwrap open the manifest: + // it does that inside the sandbox, after the mounts, so a profile with a + // tmpfs over the temp dir would otherwise leave nothing there to open. + const manifest = publishMountPointManifest( + mountPoints, + emptySource === undefined ? [] : [emptySource], + ) + if (manifest !== undefined) { + args.push( + '--ro-bind', + manifest.dir, + manifest.dir, + '--lock-file', + manifest.file, + ) + registerExitCleanupHandler() + } + // INVARIANT: the fake-file store directory must never be writable from // inside the sandbox. If it were, a sandboxed process could plant a // symlink at a fake path and a later host-side write() would follow it, @@ -2903,7 +2886,7 @@ async function generateFilesystemArgs( args.push('--ro-bind', emptySource, emptySource) } - return args + return { args, manifest } } /** @@ -3006,14 +2989,6 @@ export async function wrapCommandWithSandboxLinux( return command } - // Mark this sandbox invocation as active. cleanupBwrapMountPoints() will - // defer file deletion until this (and every other concurrent) invocation - // has been cleaned up. The matching decrement happens in - // cleanupBwrapMountPoints(), which the caller must invoke after the - // spawned command exits. If wrapping fails below, the catch block - // decrements so the count does not leak. - activeSandboxCount++ - // One encoded key for both carriers below (SRT_ENCODED_CMD for the seccomp // observer, the proxy username for network denies), so a violation seen // through either resolves to the same registry entry. macOS derives its log @@ -3024,6 +2999,9 @@ export async function wrapCommandWithSandboxLinux( const bwrapArgs: string[] = ['--new-session', '--die-with-parent'] let applySeccompPrefix: string | undefined + // The manifest naming the mount points this wrap relies on, dropped again if + // no command comes of it. + let manifest: MountPointManifest | undefined try { // ========== SECCOMP FILTER (Unix Socket Blocking) ========== @@ -3190,7 +3168,7 @@ export async function wrapCommandWithSandboxLinux( } // ========== FILESYSTEM RESTRICTIONS ========== - const fsArgs = await generateFilesystemArgs( + const filesystem = await generateFilesystemArgs( readConfig, writeConfig, maskedFileBinds, @@ -3200,8 +3178,11 @@ export async function wrapCommandWithSandboxLinux( allowGitConfig, abortSignal, ) + // Held for the catch below: a wrap that produces no command has to let go + // of the mount points it named. + manifest = filesystem.manifest const mountsStart = bwrapArgs.length - bwrapArgs.push(...fsArgs) + bwrapArgs.push(...filesystem.args) const mounts = { start: mountsStart, end: bwrapArgs.length } // Always bind /dev @@ -3301,10 +3282,10 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // Undo the activeSandboxCount increment — the caller won't call - // cleanupBwrapMountPoints() for a wrap that threw. - if (activeSandboxCount > 0) { - activeSandboxCount-- + // No command came of this wrap, so no sandbox can be running under its + // manifest and the mount points it named are no one's. + if (manifest !== undefined) { + discardMountPointManifest(manifest.file) } throw error } From 935350db16534e7af859d2407d4e94378c59ebfe Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 19:20:11 +0000 Subject: [PATCH 052/141] Cover what keeps a mount point, across processes and cleanups The new suite drives the cases a count in one process's memory could not answer: two concurrent processes, where the second's cleanup used to take the first's live mount point away and the survivor could then create the denied path; a cleanup that comes early and twice while the sandbox runs; a process killed before it can clean up, whose manifest and mount point the next cleanup collects; a sandboxed command that tries to delete, plant and rewrite manifests; twelve wrap-and-run rounds against a process collecting in a tight loop; a command wrapped before the cleanup that released it; and a wrap that threw letting go of the mount points it had named. Five of the seven fail on the previous behaviour. Two tests in stale-mount-point.test.ts described the counter: one expected a mount point to wait for a second wrap that had produced no running sandbox, which the kernel now answers instead, and one collected a killed process's mount point before the grace that covers a sandbox still starting. --- test/sandbox/mount-point-liveness.test.ts | 392 ++++++++++++++++++++++ test/sandbox/stale-mount-point.test.ts | 28 +- 2 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 test/sandbox/mount-point-liveness.test.ts diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts new file mode 100644 index 000000000..79887b781 --- /dev/null +++ b/test/sandbox/mount-point-liveness.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux } from '../helpers/platform.js' + +/** + * A denyWrite path that does not exist is blocked by binding /dev/null over + * it, and bwrap makes the mount point for that bind on the host. Unlinking one + * while a sandbox is still bound over it detaches the mount inside that + * sandbox: the denied path can be created there, and what is written lands on + * the host. Whether a sandbox is still running is therefore not something a + * process may decide for itself — a second srt process, a caller that crashes, + * one that cleans up early, twice or never, each had a different answer, and a + * count kept in one process's memory could not see any of them. + * + * Every wrap now names the mount points it relies on in a manifest and has + * bwrap hold a lock on it for the sandbox's lifetime, so the kernel answers + * instead. + */ +describe.if(isLinux)('A mount point a running sandbox relies on', () => { + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + let BASE: string + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let SIGNAL: string // written from inside the sandbox once it is up + let GO: string // written by the test to let the sandbox carry on + let OUT: string // where a child process leaves what its sandbox printed + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-liveness-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + OUT = join(BASE, 'out.txt') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + async function wrap(command: string, denyPaths = [LOCK]): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: denyPaths }, + }) + } + + /** Waits for the sandbox to report it is up, then tries the denied write. */ + function heldCommand(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` + } + + /** A child process that wraps `command`, runs it and writes what it said. */ + function wrapperScript( + command: string, + out: string, + after: string[] = [], + ): string { + return [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `const out = ${JSON.stringify(out)}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(out, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ...after, + ].join('\n') + } + + function writeScript(name: string, source: string): string { + const file = join(BASE, name) + writeFileSync(file, source) + return file + } + + async function waitFor(file: string, timeoutMs = 30000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${file}`) + } + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + /** What is at `file`, or '' where nothing is: a mount point holds nothing. */ + function contentAt(file: string): string { + return existsSync(file) ? readFileSync(file, 'utf8') : '' + } + + /** Where the wrap pointed bubblewrap's --lock-file. */ + function manifestOf(command: string): string { + const file = /--lock-file (\S+)/.exec(command)?.[1] + expect(file).toBeDefined() + return file! + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'survives another process cleaning up in the same directory, and the deny holds', + async () => { + const holder = spawn( + process.execPath, + [writeScript('holder.ts', wrapperScript(heldCommand(), OUT))], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + await waitFor(SIGNAL) + // The mount point bwrap made for the sandbox that is running. + expect(lstatSync(LOCK).size).toBe(0) + + // A second process wraps the same deny path, runs a command and cleans + // up. On the counter this took the running sandbox's mount point away. + const second = spawnSync( + process.execPath, + [ + writeScript( + 'second.ts', + wrapperScript('true', join(BASE, 'second-out.txt'), [ + 'cleanupBwrapMountPoints()', + ]), + ), + ], + { encoding: 'utf8', timeout: 60000 }, + ) + expect(second.status).toBe(0) + expect(existsSync(LOCK)).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await waitFor(OUT) + expect(readFileSync(OUT, 'utf8')).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // Once nothing is running under it, it goes: on the holder's own cleanup + // as it exits, or on this one. + await holderExited + await sleep(600) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'survives a cleanup, twice over, while its own process still has the sandbox running', + async () => { + const command = await wrap(heldCommand()) + const child = spawn(command, { shell: true }) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(SIGNAL) + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(LOCK)).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await exited + expect(said).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // And goes on the next cleanup, now that nothing is running under it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is collected, manifest and all, when the process that made it never cleans up', + async () => { + const killed = spawnSync( + process.execPath, + [ + writeScript( + 'killed.ts', + wrapperScript('true', OUT, [ + `process.kill(process.pid, 'SIGKILL')`, + ]), + ), + ], + { encoding: 'utf8', timeout: 60000 }, + ) + expect(killed.signal).toBe('SIGKILL') + expect(contentAt(LOCK)).toBe('') + + const directory = dirname(manifestOf(await wrap('true'))) + await sleep(600) + cleanupBwrapMountPoints() + + expect(existsSync(LOCK)).toBe(false) + const naming = readdirSync(directory).filter( + name => + name.endsWith('.json') && + readFileSync(join(directory, name), 'utf8').includes(LOCK), + ) + expect(naming).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot be disowned from inside the sandbox: the manifests are read-only there', + async () => { + // This caller allows writes to the manifest directory itself, so only + // the read-only bind the wrap emits last keeps the command out of it. + const directory = dirname(manifestOf(await wrap('true'))) + const before = readdirSync(directory).filter(n => n.endsWith('.json')) + expect(before.length).toBeGreaterThan(0) + + const attack = [ + `rm -f ${directory}/*.json; echo rm=$?`, + `echo mine > ${directory}/planted.json; echo plant=$?`, + `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, + ].join('; ') + const result = spawnSync( + await wrapCommandWithSandboxLinux({ + command: attack, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { + allowOnly: [AREA, directory], + denyWithinAllow: [LOCK], + }, + }), + { shell: true, encoding: 'utf8', timeout: 60000 }, + ) + const said = `${result.stdout}${result.stderr}` + expect(said).toMatch(/rm=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/rewrite=[1-9]/) + expect(existsSync(join(directory, 'planted.json'))).toBe(false) + for (const name of before) { + expect(existsSync(join(directory, name))).toBe(true) + expect(readFileSync(join(directory, name), 'utf8')).toContain('"paths"') + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'holds its deny while another process collects without pause', + async () => { + const loop = spawn( + process.execPath, + [ + writeScript( + 'collect-loop.ts', + [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `const until = Date.now() + 20000`, + `while (Date.now() < until) cleanupBwrapMountPoints()`, + ].join('\n'), + ), + ], + { stdio: 'ignore' }, + ) + try { + for (let i = 0; i < 12; i++) { + const result = spawnSync( + await wrap(`echo pwned > ${LOCK}; echo rc=$?`), + { shell: true, encoding: 'utf8', timeout: 60000 }, + ) + const said = `${result.stdout}${result.stderr}` + expect(said).toMatch(/rc=[1-9]/) + // Nothing was written at the deny path, on this pass or an earlier + // one whose mount point this pass may have re-covered. + expect(contentAt(LOCK)).toBe('') + cleanupBwrapMountPoints() + } + } finally { + loop.kill() + } + }, + 120000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'refuses to start a command wrapped before a cleanup that has since taken its mount point away', + async () => { + const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + // bubblewrap sets the mounts up before it opens the lock file, so the + // mount point is back on the host; the command it was made for never + // runs. + expect(result.status).not.toBe(0) + expect(`${result.stdout}${result.stderr}`).toContain( + 'Unable to open lock file', + ) + expect(lstatSync(LOCK).size).toBe(0) + }, + 90000, + ) + + it('lets go of the mount points of a wrap that produced no command', async () => { + // Left where a process killed before its cleanup would leave it, so that + // both processes below name it. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + + const wrapper = spawn( + process.execPath, + [ + writeScript( + 'throwing.ts', + [ + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `try {`, + ` await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` binShell: 'srt-no-such-shell',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + ` })`, + `} catch {`, + ` writeFileSync(${JSON.stringify(OUT)}, 'threw')`, + `}`, + // Stays alive: a manifest of a process that is still running + // counts as live, so only letting go of it lets the mount point be + // collected here. + `await new Promise(resolve => setTimeout(resolve, 30000))`, + ].join('\n'), + ), + ], + { stdio: 'ignore' }, + ) + try { + await waitFor(OUT) + expect(readFileSync(OUT, 'utf8')).toBe('threw') + // This process names the same mount point and asks for the cleanup. Had + // the wrap that threw held on to its manifest, the process behind it + // being alive would have kept the mount point here. + await wrap('true') + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + } finally { + wrapper.kill() + } + }, 90000) +}) diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index f216a8d66..657ec039d 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -44,13 +44,12 @@ function run( /** * A denyWrite path that does not exist gets `--ro-bind /dev/null `, and * bwrap creates the mount point for it on the host: an empty file, mode 0444. - * The wrapping process removes it after the command, from a set it keeps in - * memory. A process killed before that (SIGKILL, OOM) leaves the file behind, - * and it used to stay for good: every later wrap saw an existing file, bound - * it onto itself and never removed it. For a path whose existence is its - * meaning that is a lasting fault on the host: a leftover `.git/config.lock` - * makes every `git config` write outside the sandbox fail with "could not - * lock config file". + * A cleanup removes it once no sandbox relies on it. A process killed before + * it can clean up leaves the file behind, and it used to stay for good: every + * later wrap saw an existing file, bound it onto itself and never removed it. + * For a path whose existence is its meaning that is a lasting fault on the + * host: a leftover `.git/config.lock` makes every `git config` write outside + * the sandbox fail with "could not lock config file". */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() @@ -105,15 +104,17 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(existsSync(LOCK)).toBe(false) }) - it('waits for every outstanding sandbox before it is removed', async () => { + it('goes on the first cleanup when no sandbox is running under it', async () => { plantLeftover(LOCK) await wrap([LOCK]) await wrap([LOCK]) - cleanupBwrapMountPoints() - // Unlinking it now would detach the second sandbox's bind. - expect(existsSync(LOCK)).toBe(true) + // Neither command was started, so nothing relies on the mount point: what + // keeps one is a sandbox holding its manifest's lock, not a count of the + // wraps this process has handed out. A command wrapped but not started by + // then refuses to start rather than running without its mount point (see + // mount-point-liveness.test.ts). cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }) @@ -236,6 +237,11 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const after = run(command) expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) + // A manifest whose writer is gone and that no sandbox has locked counts + // as live for a moment, in case the sandbox it covers is starting: the + // killed process left one, so the cleanup that takes its mount point + // away is the first one after that. + await new Promise(resolve => setTimeout(resolve, 1000)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }, From 957d150f1e2d529b6cc155643805da3258592405 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 20 Sep 2026 19:20:28 +0000 Subject: [PATCH 053/141] Say in the README when a host mount point goes The section described the mount points as removed after the command and told embedders not to clean up after a wrap that threw. Both were the old contract: a mount point goes when no running sandbox relies on it, whichever process asks, and the cleanup is safe to call at any time. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6116ecb15..645e223f5 100644 --- a/README.md +++ b/README.md @@ -736,16 +736,16 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, in a per-user runtime directory (`$XDG_RUNTIME_DIR` when there is one, else the system temp dir), and has bubblewrap hold a lock on that manifest for exactly the sandbox's lifetime, so any srt process can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released. A string run after that refuses to start rather than running without its mount points. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already let go of what it held, so calling `cleanupAfterCommand()` after one is harmless. ### Mandatory Deny Paths (Auto-Protected Files) @@ -773,7 +773,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. +**Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` once no running sandbox relies on them — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. From 70c9868ee2608753db87cf375afad5a017500729 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:22:28 +0000 Subject: [PATCH 054/141] A git chain that reaches nothing is held by the links it went through A symlinked entry whose chain runs into a loop - `.git/hooks -> ../links/a` with `a -> b -> a` - reached nothing, so nothing was denied for it beyond the whole-directory deny the entry itself brings. The reasoning was that a chain reaching nothing has nothing behind it to protect. It is wrong: every link the walk VISITED before it gave up is a name a command able to write the directory around it can replace with a real directory, and the chain then reaches that directory. Measured against real git 2.55 (a case in git-pointer-parity): with the loop in place git commits with no hook; replace one link with a directory holding a `pre-commit`, entry and git directory untouched, and git runs it. `main` refuses such a command outright - the deny path cannot be resolved through symlinks, so the wrap fails closed and the command never runs - while this branch ran it with the loop's links free and said nothing. So: - `resolveChain` gives back the hops it visited when it gives up past the kernel's hop limit, each named once however many times the loop met it, with `resolved: false` beside them. The hop count no longer bounds the walk, since a loop's hops are one or two names; a step counter does. - Those hops are held like any other: each link's own path is a deny path (the handle where a filter matches the name a rename uses), and the directory holding it is denied whole on the backend whose denies resolve. A loop confined to the git directory names no holder at all - the whole deny the entry brings covers it. - Where a hop of such a chain CANNOT be held, its holder being the working directory or a write root, the command is refused with a reason of its own, naming the entry, the link and the holder. The warning that stands in for a bind elsewhere leans on both ends of the chain being denied, and here there is no far end. Never looser than main, and no new cost to a layout main already refuses. A pointer's VALUE that reaches nothing is the opposite case and gets the opposite answer. It used to fail closed on the deepest directory still reachable towards the value, which for `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself: every write in the project refused. A `.git` file is something a sandboxed command is deliberately allowed to create, so that was a denial of service on the session plantable in one command, and refusing instead would be the same lockout by another name. git opens nothing through such a value and does not fall back to the lexical fold (pinned against real git), so what is denied is the pointer file and the links the walk did go through, with one warning naming the file. What a command could reach by re-pointing a link it is free to write, it could reach by creating a nested repository outright, which is a recorded limit of scanning before a command rather than after it. An entry of a `.git/modules` that loops is unchanged: its links are not held either, because the only handle this backend has on them is a whole-directory deny of the `.git/modules`, which would take every submodule beside them read-only over one link - and a `.git/modules` is writable in the sandbox, so that link is no shorter a way to a planted submodule git directory than creating one outright. --- src/sandbox/mandatory-deny-paths.ts | 154 +++++++++++++++++++++------- 1 file changed, 115 insertions(+), 39 deletions(-) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 0869a4d1b..9456c957b 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -10,10 +10,11 @@ import { /** * The path is absent, as opposed to unreadable or otherwise unverifiable. - * Narrower than the shared {@link isAbsenceErrno} by two codes: ELOOP must - * read as unreadable here, so a `gitdir:` target that is a symlink loop is - * denied whole rather than passed over as nothing, and ENAMETOOLONG has its - * own answer in {@link isUnusablePathError}. + * Narrower than the shared {@link isAbsenceErrno} by two codes: an absent + * path is denied as one a command could still create, and a symlink loop is + * not that — it is walked instead, so that the links the walk went through + * before it gave up are held ({@link resolveChain}) — while ENAMETOOLONG has + * its own answer in {@link isUnusablePathError}. */ function isAbsenceError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code @@ -124,9 +125,17 @@ interface ChainHop { * {@link resolveChain}. */ interface ResolvedChain { landing: string - /** In the order the walk met them: the first is the path the caller asked - * about, the rest are what lies between it and the landing. */ + /** In the order the walk met them and each named once: the first is the + * path the caller asked about, the rest are what lies between it and the + * landing. A loop meets the same link over and over; it is one hop. */ hops: ChainHop[] + /** + * Whether the walk ran to the end of the path. False only where it gave up + * past the kernel's own hop limit — a loop, or a chain longer than the + * kernel follows — and `landing` is then just where it stopped, a path + * nothing can be reached through while the chain stands. + */ + resolved: boolean } /** @@ -150,6 +159,16 @@ export interface GitChainHop { * it differs. */ kind: 'entry' | 'pointer' + /** + * Whether the chain this hop lies on reaches anything. `unresolvable` is a + * loop, or a chain past the kernel's hop limit: nothing is behind it, both + * ends of it are held by a deny that stands on nothing, and each link the + * walk DID go through is a name that makes the chain reach a real directory + * as soon as a command puts one in its place. A backend that cannot hold + * such a link has nothing left to fall back on and refuses the command; one + * whose chain resolves has the landing's own denies behind it and warns. + */ + chain: 'resolved' | 'unresolvable' /** The git directory entry, or the metadata file, whose chain goes * through it. */ source: string @@ -387,7 +406,11 @@ export function gitDirDenies( // The first hop IS the entry: its own path is a deny path already, and the // directory holding it is this git directory, denied whole below. The rest // lie between the two ends, and neither end holds them. - const holdChain = (entryPath: string, hops: ChainHop[]): void => { + const holdChain = ( + entryPath: string, + hops: ChainHop[], + chain: GitChainHop['chain'], + ): void => { for (const hop of hops.slice(1)) { // A deny path like any other, for the backend that holds a link by its // own name; the one that resolves its denies drops it again. @@ -395,6 +418,7 @@ export function gitDirDenies( if (insideGitDir(hop.link)) { chainHops.push({ kind: 'entry', + chain, source: entryPath, link: hop.link, holder: undefined, @@ -405,6 +429,7 @@ export function gitDirDenies( escapingDenyPaths.push(hop.link) chainHops.push({ kind: 'entry', + chain, source: entryPath, link: hop.link, holder: insideGitDir(hop.holder) ? undefined : hop.holder, @@ -426,13 +451,18 @@ export function gitDirDenies( if (!insideGitDir(entry.target)) { escapingDenyPaths.push(entry.target) } - holdChain(entryPath, entry.hops) + holdChain(entryPath, entry.hops, 'resolved') break case 'unreachable': - // A loop, or a name no file can occupy: there is nothing behind it to - // deny and nothing a command can create through it. The link itself - // is the whole hazard, and the whole-directory deny holds it. + // A loop, or a name no file can occupy: nothing is behind it to deny + // and nothing a command can create through it WHILE IT REACHES + // NOTHING. The entry's own link is held by the whole-directory deny, + // and every link the walk went through before it gave up is held like + // any other hop: each is a name a command can replace with a real + // directory, which makes the chain reach one and the host's git read + // what is put there. denyWhole = true + holdChain(entryPath, entry.hops, 'unresolvable') break case 'unknown': // There and not classifiable. Fail closed both ways: the deny the @@ -443,7 +473,7 @@ export function gitDirDenies( denyWhole = true denyPaths.push(entryPath) escapingDenyPaths.push(entryPath) - holdChain(entryPath, entry.hops) + holdChain(entryPath, entry.hops, 'resolved') break } } @@ -461,8 +491,9 @@ type GitDirEntry = | { kind: 'plain' } /** A symlink, resolved or dangling, leading to `target` through `hops`. */ | { kind: 'link'; target: string; hops: ChainHop[] } - /** A symlink that reaches nothing: a loop, or a name no file can occupy. */ - | { kind: 'unreachable' } + /** A symlink that reaches nothing: a loop, or a name no file can occupy. + * `hops` is what the walk went through before it gave up. */ + | { kind: 'unreachable'; hops: ChainHop[] } /** There, and what it is could not be worked out; `hops` is as much of the * chain as the walk read before it stopped. */ | { kind: 'unknown'; hops: ChainHop[] } @@ -498,7 +529,7 @@ function gitDirEntry(entryPath: string): GitDirEntry { } catch (err) { const code = (err as NodeJS.ErrnoException | undefined)?.code if (code === 'ELOOP' || isUnusablePathError(err)) { - return { kind: 'unreachable' } + return { kind: 'unreachable', hops: chain.hops } } if (!isAbsenceErrno(err)) { logForDebugging( @@ -579,16 +610,29 @@ export function gitFileDenies( // it is held as a symlinked entry is: the file naming it is denied and the // git directory it reaches is denied, while a command that retargets a link // between the two moves where git lands without touching either end. - const holdChain = (source: string, hops: ChainHop[]): void => { - for (const hop of hops) { + // + // A value that reaches nothing names no git directory to deny, and the + // links the walk did go through are the whole of what a command can move. + // One warning says so, because a `.git` file is something a sandboxed + // command may create: refusing every later command, or denying the + // checkout whole for it, would make writing one a way to lock the session + // out of its own project. + const holdChain = (source: string, targets: GitMetadataTargets): void => { + for (const hop of targets.hops) { denyPaths.push(hop.link) chainHops.push({ kind: 'pointer', + chain: targets.resolved ? 'resolved' : 'unresolvable', source, link: hop.link, holder: hop.holder, }) } + if (targets.resolved) return + logForDebugging( + `[Sandbox] The path ${source} names cannot be walked to an end (a symlink loop, or more than the ${MAX_SYMLINK_RESOLUTION_DEPTH} hops the kernel follows), so git opens nothing through it either; denying ${source} and the ${targets.hops.length} link(s) the walk did go through, and nothing besides`, + { level: 'warn' }, + ) } try { const pointer = readGitMetadataFile(gitFile) @@ -611,7 +655,7 @@ export function gitFileDenies( return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } const targets = gitMetadataTargets(path.dirname(gitFile), target) - holdChain(gitFile, targets.hops) + holdChain(gitFile, targets) const gitDirs = targets.gitDirs.map(gitDir => ({ gitDir, kind: gitDirKind(gitDir), @@ -658,7 +702,7 @@ export function gitFileDenies( } if (commonTarget === undefined) continue const commonTargets = gitMetadataTargets(gitDir, commonTarget) - holdChain(commonFile, commonTargets.hops) + holdChain(commonFile, commonTargets) for (const commonDir of commonTargets.gitDirs) { if (commonDir === gitDir) continue targetDenies(commonDir, gitDirKind(commonDir), commonFile) @@ -904,10 +948,11 @@ function walkEntry( // The first hop is the entry, whose holder joins `linkedEntryDirs` below; // the rest lie between it and what it lands on, and that deny reaches // neither them nor the directories they sit in. - const holdChain = (): void => { + const holdChain = (chainState: GitChainHop['chain']): void => { for (const hop of chain.hops.slice(1)) { walk.chainHops.push({ kind: 'entry', + chain: chainState, source: entryPath, link: hop.link, holder: isAtOrUnder(hop.holder, holder) ? undefined : hop.holder, @@ -917,14 +962,14 @@ function walkEntry( try { if (!fs.statSync(entryPath).isDirectory()) return { kind: 'skip' } walk.linkedEntryDirs.add(holder) - holdChain() + holdChain('resolved') return { kind: 'directory' } } catch (err) { if (isAbsenceError(err)) { // Dangling. Nothing is there to walk, and a command can create it: what // the link lands on is denied as a git directory that is not there yet. walk.linkedEntryDirs.add(holder) - holdChain() + holdChain('resolved') return { kind: 'dangling', landing: chain.landing } } if (isUnusablePathError(err)) return { kind: 'skip' } @@ -936,7 +981,14 @@ function walkEntry( // resolves a mount destination, and resolving this one is what just // failed. Denying the directory that HOLDS it is the one answer that // would be worse than none - a single link a command can write would - // take every submodule beside it read-only. + // take every submodule beside it read-only. The links the loop goes + // THROUGH are left alone for a reason of their own: a `.git/modules` is + // writable in the sandbox, so putting a directory at one of them to + // make this entry reach it is no shorter a way to a planted submodule + // git directory than creating one outright, which is the recorded limit + // of scanning before a command rather than after it. A git directory's + // own entries are the other case: there, the whole-directory deny holds + // the entry and the hops are all that is left open. logForDebugging( `[Sandbox] ${entryPath} is a symlink loop, which leads to nothing to deny: ${err}`, { level: 'warn' }, @@ -952,7 +1004,7 @@ function walkEntry( deepestReachableAncestor(linkTarget(entryPath) ?? entryPath) ?? entryPath walk.unreadableDirs.add(denied) walk.linkedEntryDirs.add(holder) - holdChain() + holdChain('resolved') logForDebugging( `[Sandbox] Could not follow ${entryPath}, denying ${denied} whole: ${err}`, { level: 'warn' }, @@ -1143,12 +1195,15 @@ function gitMetadataPath(contents: Buffer, file: string): string | undefined { /** Where a `gitdir:` or `commondir` value leads, and what it goes through; * see {@link gitMetadataTargets}. */ interface GitMetadataTargets { + /** Empty where the walk reached no end: see {@link resolveChain}. */ gitDirs: string[] /** Every symlink the walk of the value went through, in the order it met * them. A link ABOVE the file holding the value is not one of them: the * base is resolved before the walk starts, so a checkout reached through a * symlink is where the repository lives rather than a hop of this chain. */ hops: ChainHop[] + /** {@link ResolvedChain.resolved} for the walk of the value. */ + resolved: boolean } /** @@ -1161,6 +1216,15 @@ interface GitMetadataTargets { * the kernel's landing and refuses when only the lexical one is a git * directory (test/sandbox/git-pointer-parity.test.ts pins that). Both are * denied when they differ: denying more than git follows costs one bind. + * + * A value whose walk reaches no end names NO git directory. git opens nothing + * through it — it gives up on the same loop, and the lexical fold is not a + * fallback it takes — while the deepest directory a fail-closed deny could + * still reach towards it is usually the checkout itself, and denying that + * whole would take the project read-only over a `.git` file a sandboxed + * command is allowed to create. The links the walk did go through come back + * as hops all the same: putting a directory in one of their places is what + * makes such a chain reach anything at all. */ function gitMetadataTargets(base: string, target: string): GitMetadataTargets { const lexical = path.resolve(base, target) @@ -1172,12 +1236,16 @@ function gitMetadataTargets(base: string, target: string): GitMetadataTargets { target, ) const hops = chain.hops - if (!target.split('/').includes('..')) return { gitDirs: [lexical], hops } + const resolved = chain.resolved + if (!resolved) return { gitDirs: [], hops, resolved } + if (!target.split('/').includes('..')) { + return { gitDirs: [lexical], hops, resolved } + } // Both sides resolved the same way, so a base that merely spells itself // differently (a /var that is a symlink to /private/var) is no difference. return chain.landing === physicalPath(root, lexical) - ? { gitDirs: [lexical], hops } - : { gitDirs: [lexical, chain.landing], hops } + ? { gitDirs: [lexical], hops, resolved } + : { gitDirs: [lexical, chain.landing], hops, resolved } } /** @@ -1199,6 +1267,8 @@ function resolveChain(base: string, target: string): ResolvedChain { let current = path.isAbsolute(target) ? path.parse(target).root : base let pending = target.split('/') const hops: ChainHop[] = [] + const named = new Set() + let followed = 0 while (pending.length > 0) { const name = pending.shift() if (name === undefined || name === '' || name === '.') continue @@ -1211,7 +1281,7 @@ function resolveChain(base: string, target: string): ResolvedChain { try { if (fs.lstatSync(next).isSymbolicLink()) link = fs.readlinkSync(next) } catch { - return { landing: path.join(next, ...pending), hops } + return { landing: path.join(next, ...pending), hops, resolved: true } } if (link === undefined) { current = next @@ -1219,23 +1289,29 @@ function resolveChain(base: string, target: string): ResolvedChain { } // `current` is where the walk really is, so the hop is recorded under // the name the kernel reached it by rather than the one it was spelled - // from: that is the name an unlink or a rename of the link uses. - hops.push({ link: next, holder: current }) - // A loop is where the walk stops, and what it hands back: the rest of - // the path folded past it would name a directory this cannot vouch for, - // while the link itself reads as unreadable and is denied whole. Past - // the kernel's own limit the chain reaches nothing — git fails on it as - // this does — so there is nothing behind it to protect and no hop of it - // worth holding, which is the answer a symlinked ENTRY that loops gets. - if (hops.length > MAX_SYMLINK_RESOLUTION_DEPTH) { - return { landing: next, hops: [] } + // from: that is the name an unlink or a rename of the link uses. A loop + // arrives at the same link over and over and it is one name, so the hop + // count is not what bounds the walk. + if (!named.has(next)) { + named.add(next) + hops.push({ link: next, holder: current }) + } + // Past the kernel's own limit the chain reaches nothing: git gives up on + // it exactly as this does, so there is nothing behind it to protect and + // the landing is only where the walk stopped. The links it went THROUGH + // are a different matter and come back — each is a name a command able to + // write the directory around it can point at a real directory, which + // makes the chain reach one and every deny worked out for it wrong — so + // the caller holds them and says so where it cannot. + if (++followed > MAX_SYMLINK_RESOLUTION_DEPTH) { + return { landing: next, hops, resolved: false } } // A link's own target is walked in its place, from the directory holding // it unless it is absolute. if (path.isAbsolute(link)) current = path.parse(link).root pending = [...link.split('/'), ...pending] } - return { landing: current, hops } + return { landing: current, hops, resolved: true } } /** {@link resolveChain} for a caller that wants only where the walk landed. */ From 52e894d5a65d66998c7d9f37c09768c907dd5dd2 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:22:28 +0000 Subject: [PATCH 055/141] Hold a `modules` directory that is a symlink `gitDirDenies` checked `hooks`, `config`, `commondir` and `config.worktree` for being links, and not `modules`. With `.git/modules/foo/modules` a link to a directory elsewhere, writes THROUGH it were refused and so were `foo`'s own hooks - but removing the link and putting a real directory of the same name in its place succeeded, and the hook written into it stayed on the host for git to run. Reported on the PR with that sequence. `modules` is where a git directory keeps its submodules' git directories, each with precise denies of its own, so it cannot be a deny path: denying it whole would take every one of them read-only. It is held the way a chain hop is instead - its own name, which is what an unlink or a rename of a link uses, and the git directory holding it denied whole for the backend whose denies resolve, where a bind at the link's own path would land on the modules tree. The chain past it is held hop by hop like any other. The top-level `.git/modules` is covered by the same check; what stays open is a `.git` that is itself a link, where the only handle above it is the work tree root. What it costs, said in the README and the PR rather than left to be found: an ordinary repository has no `.git/modules`, and an absent one is deliberately not denied, because a mount there would stop `git submodule add`. So one `ln -s nowhere .git/modules` inside the sandbox makes `.git` a git directory with a symlinked entry, and from the next wrap on `git add`, `git commit` and `git checkout -b` fail read-only in that repository, with nothing inside the sandbox able to remove the link; `git status`, `log` and `diff` keep working, and removing the link from outside puts it back. That is the same family as the other costs a command can plant, and it is the price of holding the link. --- src/sandbox/mandatory-deny-paths.ts | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 9456c957b..e3960e184 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -385,6 +385,9 @@ export interface GitDirDenies { * as well, and `chainHops` carries the directory holding each for the backend * that needs one — see {@link GitChainHop}. * + * `modules` is checked for being a link as well, though it is no deny path of + * its own: see the comment at the end of this function. + * * An ordinary git directory costs one lstat per entry and is denied by exactly * what it always was. */ @@ -477,6 +480,39 @@ export function gitDirDenies( break } } + // `modules` is not one of the deny paths - denying it whole would take + // every submodule git directory under it read-only, and each of those has + // precise denies of its own - but it is an entry like the others in the one + // respect that matters here: remove the link, put a directory of your own + // where it was, and every submodule git directory this repository keeps is + // one nothing denied. So it is held the way a chain hop is, and for the + // same reason: its own name, which is what a rename or an unlink of a link + // uses, and the directory holding it - this git directory - denied whole + // for the backend whose denies resolve, where a bind at the link's own path + // would land on the modules tree and take every submodule with it. + const modulesPath = path.join(gitDir, 'modules') + const modules = gitDirEntry(modulesPath) + switch (modules.kind) { + case 'plain': + break + case 'link': + case 'unknown': + denyWhole = true + denyPaths.push(modulesPath) + chainHops.push({ + kind: 'entry', + chain: 'resolved', + source: modulesPath, + link: modulesPath, + holder: undefined, + }) + holdChain(modulesPath, modules.hops, 'resolved') + break + case 'unreachable': + denyWhole = true + holdChain(modulesPath, modules.hops, 'unresolvable') + break + } return { denyPaths, escapingDenyPaths, From b6186b2d66a22fa0370217b751bebbef127acc0d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:22:28 +0000 Subject: [PATCH 056/141] No deny may take the work tree, and no killed scan may pass for a whole one Four things this backend does when it cannot be precise, each read off paths a sandboxed command can write, and each therefore a way to aim a deny at the project itself or to leave the tree unscanned. **A whole-directory deny that covers the work tree is never emitted.** Four routes reach one, three of them with no loop and no unresolvable value in sight: a `.git` file naming the checkout (`gitdir: .`, with one entry of the checkout a symlink, makes the checkout a git directory with a symlinked entry - denied whole); a `.git/modules` entry linked at `../..`, which the walk follows and which resolves to the checkout; a git directory entry pointing at the checkout; and a `chmod` that leaves the working directory unreadable. Each bound the working directory, or a second write root, read-only from the next wrap on, and nothing inside the sandbox could undo it. Only chain hops went through a guard of that kind; `linkedEntryDirs`, the unreadable directories and the deepest-reachable-ancestor results did not. One guard now sees every deny path of the plan, spelled where a bind would LAND (whole-directory denies enter it resolved, which is what the backend binds anyway). What stands in place of a dropped deny depends on the one thing that distinguishes the two families - whether what is beneath it can be known: - a directory that can be LISTED was denied whole to hold something inside it, and both ends of what that deny protected keep their own deny paths, so it is dropped with one warning, as an unheld chain hop already was; - a directory that cannot be listed stands in for a tree nothing else covers. Dropping it would run the command against an unscanned tree and keeping it locks the project, so the command is REFUSED, naming the directory, which one `chmod` outside the sandbox lifts. macOS makes the same call: a literal entry there is a subpath deny, and the routes above reach it through the cwd's own `.git` pointer. **A chain that reaches nothing and cannot be held refuses.** The Linux half of the previous commit: `deny_unresolvable_git_entry`, for an entry whose chain gives up and whose links sit where no bind can hold them. **A scan that was killed is refused whatever it printed.** `ripGrep` reported `timedOut`, and a kill that was not the timeout fell through to the `(os error N)` classification - so a run killed mid-listing inside its budget, whose stderr already held the one `(os error 13)` line a `chmod 000` directory produces, had every code it printed stood in for and its partial listing was used as a complete one, leaving whatever it never reached unscanned. `RipgrepError` carries `killed` and the signal now, and either refuses. **The directory that cannot be seen through is the one denied.** A directory that is readable but not SEARCHABLE (mode 0400) lists its entries and lets nothing through them, so the walk arrived here for a CHILD whose own listing failed for want of the parent's search bit - and that child is a path the host cannot stat either, so recording it asked bubblewrap for a file mount at what is really a directory: `Can't create file at : Is a directory`, every later wrap in the tree, until a `chmod` outside the sandbox. One lstat tells the two apart. Reported on the PR. **And an unreadable directory at the depth limit is accounted for.** ripgrep's walker opens a directory that sits AT `--max-depth` to list it and reports one it cannot read (up to at least 15.0.0; 15.2.0 does not), while this walk stopped a level above - so that EACCES had nothing found for it and the wrap was refused, over a layout as ordinary as a `docker/data/postgres` another user owns. Nothing below such a directory is in the scan's reach on either side, so there is nothing to deny for it and it is counted rather than searched for. --- src/sandbox/linux-sandbox-utils.ts | 284 +++++++++++++++++++++++++---- src/sandbox/macos-sandbox-utils.ts | 61 ++++++- src/sandbox/sandbox-utils.ts | 24 +++ src/utils/ripgrep.ts | 51 ++++-- 4 files changed, 363 insertions(+), 57 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index f15ca7b25..a7aababe2 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -26,6 +26,7 @@ import { isAbsenceErrno, isAtOrUnder, isStrictlyUnder, + coversWriteTree, getDangerousDirectories, MAX_SYMLINK_RESOLUTION_DEPTH, } from './sandbox-utils.js' @@ -545,6 +546,15 @@ function truncated(text: string, limit = 400): string { * aim it elsewhere, or empty it. `collectGitDirs` also gathers the `.git` * directories the walk passes. * + * `probeDepthLimit` opens, without descending into, the directories that sit + * AT `maxDepth`: ripgrep's own walker opens one to list it and reports it + * when it cannot, while this walk stops one level above, so a directory + * another user owns exactly `maxDepth` levels down would otherwise be an + * EACCES the walk finds nothing for and the wrap refuses over. Nothing below + * such a directory is in the scan's reach on either side, so there is nothing + * to deny for it: `unreadableAtDepthLimit` counts them, which is what says + * the run's EACCES is accounted for. + * * Throws {@link LinuxSandboxProfileError} when `deadline` passes: the walk * spends what is left of the scan's own time budget and no more. */ @@ -552,10 +562,16 @@ function walkScanDirectories( cwd: string, maxDepth: number, deadline: number, - collectGitDirs = false, -): { unreadableDirs: string[]; gitDirs: string[] } { + options: { collectGitDirs: boolean; probeDepthLimit: boolean }, +): { + unreadableDirs: string[] + gitDirs: string[] + unreadableAtDepthLimit: number +} { + const { collectGitDirs, probeDepthLimit } = options const unreadableDirs: string[] = [] const gitDirs: string[] = [] + let unreadableAtDepthLimit = 0 const isDirectory = ( entry: fs.Dirent, @@ -594,7 +610,23 @@ function walkScanDirectories( entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { // Absent means it went away mid-walk, which leaves nothing to deny. - if (!isAbsenceErrno(err)) unreadableDirs.push(dir) + if (isAbsenceErrno(err)) return + // Which directory cannot be seen through is not always this one. A + // directory that is readable but not SEARCHABLE (mode 0400) lists its + // entries and lets nothing through them, so the walk comes here for a + // CHILD whose own readdir failed for want of the parent's search bit - + // and that child is a path this process cannot stat either, so + // recording it would ask bubblewrap for a file mount at what is really + // a directory, failing every later wrap in the tree with an untyped + // error. One lstat tells the two apart: it needs the parent's search + // bit and nothing of this directory's own. + let denied = dir + try { + fs.lstatSync(dir) + } catch (lstatErr) { + if (!isAbsenceErrno(lstatErr)) denied = path.dirname(dir) + } + unreadableDirs.push(denied) return } for (const entry of entries) { @@ -612,12 +644,33 @@ function walkScanDirectories( // one by a file directly inside .git, one level further down. gitDirs.push(child) } - if (depth + 1 < maxDepth) walk(child, depth + 1) + if (depth + 1 < maxDepth) { + walk(child, depth + 1) + continue + } + if (!probeDepthLimit) continue + try { + fs.readdirSync(child) + } catch (err) { + if (isAbsenceErrno(err)) continue + try { + // Read as above: an lstat that fails for anything but absence says + // the search bit is missing HERE, so this directory - not the one + // below it, which nothing can reach - is what cannot be seen + // through, and every other entry of it is out of reach too. + fs.lstatSync(child) + unreadableAtDepthLimit++ + } catch (lstatErr) { + if (isAbsenceErrno(lstatErr)) continue + unreadableDirs.push(dir) + return + } + } } } walk(cwd, 0) - return { unreadableDirs, gitDirs } + return { unreadableDirs, gitDirs, unreadableAtDepthLimit } } /** @@ -655,10 +708,13 @@ export function linuxGetCwdMandatoryDenyPaths( allowWritePaths: readonly string[] = [], ): string[] { const plan = cwdMandatoryDenyPlan(allowGitConfig, undefined) - return [ - ...collapsedDenyPaths(plan, NO_COLLAPSE), - ...chainHopDenies(plan.chainHops, allowWritePaths).held, - ] + return withoutWorkTreeDenies( + [ + ...collapsedDenyPaths(plan, NO_COLLAPSE), + ...chainHopDenies(plan.chainHops, allowWritePaths).held, + ], + allowWritePaths, + ) } /** {@link linuxGetCwdMandatoryDenyPaths} with the working directory's own @@ -698,7 +754,7 @@ function cwdMandatoryDenyPlan( const pointer = gitFileDenies(dotGitPath, allowGitConfig) denyPaths.push( ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), - ...pointer.linkedEntryDirs, + ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), ) chainHops.push(...pointer.chainHops) } @@ -718,10 +774,27 @@ function cwdMandatoryDenyPlan( function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { return [ ...withoutChainHopLinks(gitDirTreeDenyPaths(denies), denies.chainHops), - ...denies.linkedEntryDirs, + ...denies.linkedEntryDirs.map(wholeDirDenyLanding), ] } +/** + * Where a whole-directory deny LANDS. A bind resolves its destination, so a + * git directory reached through a symlinked `.git/modules` entry is spelled + * inside the git directory and binds whatever the link leads to - the + * checkout itself, where the entry is `ln -s ../.. .git/modules/x`. The + * backend resolves it either way (`resolveSymlinkedDenyPath`), so spelling it + * here changes no mount; what it changes is that + * {@link withoutWorkTreeDenies} sees what the deny would really cover. + */ +function wholeDirDenyLanding(dir: string): string { + try { + return fs.realpathSync(dir) + } catch { + return dir + } +} + /** * `denyPaths` without the ones naming an intermediate hop of a chain. A bind * there lands on whatever the link leads to — for a symlinked directory @@ -757,7 +830,9 @@ function withoutChainHopLinks( * that way (`.git/hooks -> ../hooks-link`, with `hooks-link` in the * repository root) is one the caller had before this library saw it, so * refusing every command in it is the wrong answer too. That last kind comes - * back in `unheld`, for the warning that has to stand in for a bind. + * back in `unheld`, for the warning that has to stand in for a bind — or, + * where the chain it lies on reaches nothing so that there is no landing to + * deny either, for the refusal {@link guardedDenyPlan} makes of it. */ function chainHopDenies( chainHops: readonly GitChainHop[], @@ -785,17 +860,33 @@ function chainHopDenies( } /** - * `plan` with the directories holding a chain hop this backend can bind added - * to its deny paths, and one warning per wrap for the hops it cannot bind — - * the only channel a wrap has to say so. Ordinary trees have no hops at all - * and pay nothing here. + * The deny plan a wrap emits: `plan` with the directories holding a chain hop + * this backend can bind added to its deny paths, one warning per wrap for the + * hops it cannot bind - the only channel a wrap has to say so - and without + * any deny that would take the work tree whole. Ordinary trees have no hops + * at all and nothing that covers the work tree, and pay nothing here. + * + * Throws {@link LinuxSandboxProfileError} with `deny_unresolvable_git_entry` + * where an unbindable hop lies on a chain that reaches NOTHING: a warning is + * what stands in for a bind while both ends of the chain are denied, and a + * chain that reaches nothing has no far end to deny. See + * {@link withoutWorkTreeDenies} for the refusal the other guard makes. */ -function withChainHopDenies( +function guardedDenyPlan( plan: SubmoduleDenyPlan, allowWritePaths: readonly string[], ): SubmoduleDenyPlan { - if (plan.chainHops.length === 0) return plan const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) + const unresolvable = unheld.filter( + // A pointer's value is exempt: a `.git` file is something a sandboxed + // command may create, so refusing for one would let a command lock every + // later command out of the project by writing a looping pointer. What it + // could reach by re-pointing a link it is free to write, it can reach by + // creating a nested repository outright, which pre-spawn scanning has + // always missed. + hop => hop.kind === 'entry' && hop.chain === 'unresolvable', + ) + if (unresolvable.length > 0) throw unresolvableGitEntry(unresolvable) if (unheld.length > 0) { const told = unheld .map(hop => @@ -810,12 +901,101 @@ function withChainHopDenies( { level: 'warn' }, ) } - if (held.length === 0) return plan const denied = new Set(plan.denyPaths) return { ...plan, - denyPaths: [...plan.denyPaths, ...held.filter(dir => !denied.has(dir))], + denyPaths: withoutWorkTreeDenies( + [...plan.denyPaths, ...held.filter(dir => !denied.has(dir))], + allowWritePaths, + ), + } +} + +/** + * `denyPaths` without any that would deny the work tree WHOLE. + * + * A whole-directory deny is what this backend falls back on for a tree it + * cannot enumerate or a link it has no other handle on, and every producer of + * one reads paths a sandboxed command can create: a `.git` pointer whose + * value names the checkout (`gitdir: ..`, with one entry of the checkout a + * symlink, makes it a git directory denied whole), a `.git/modules` entry + * linked at `../..`, a git directory entry pointing at the checkout, a + * directory left unreadable. Any of them lands a `--ro-bind` on the working + * directory or a write root, and from the next wrap on nothing in the project + * can be written and nothing inside the sandbox can put it back: a denial of + * service on the session, planted by the very code the sandbox contains. So + * no such deny is emitted, whichever producer asked for it. + * + * What stands in its place depends on whether what is beneath it can be + * known, which is the one thing that distinguishes the two families: + * + * - A directory this process CAN list was denied whole to hold something + * inside it - a symlinked entry, a link a bind cannot be put on. Both ends + * of what that deny was protecting keep their own deny paths, so the answer + * is the one an unheld chain hop already gets: drop it, and warn once. + * - A directory it cannot list stands in for a tree nothing else covers. + * Dropping it would run the command against an unscanned tree; keeping it + * locks the project silently. The command is REFUSED instead, naming the + * directory, which a `chmod` outside the sandbox puts right. + * + * Deny paths reach here spelled where they LAND, which is what a bind + * resolves to; see `linuxGitDirTreeDenyPaths`. + */ +function withoutWorkTreeDenies( + denyPaths: readonly string[], + allowWritePaths: readonly string[], +): string[] { + const cwd = process.cwd() + const kept: string[] = [] + const dropped: string[] = [] + for (const denyPath of denyPaths) { + if (!coversWriteTree(denyPath, cwd, allowWritePaths)) { + kept.push(denyPath) + continue + } + try { + fs.readdirSync(denyPath) + } catch (err) { + const message = + `[Sandbox Linux] ${denyPath} could not be listed (${errorText(err)}), ` + + `and it holds the working directory or a path this command may ` + + `write: denying it whole is the only thing that stands in for a tree ` + + `that cannot be read, and that would leave every command after this ` + + `one unable to write the project. Refusing instead; making the ` + + `directory readable, from outside the sandbox, lifts it` + logForDebugging(message, { level: 'warn' }) + throw new LinuxSandboxProfileError('deny_scan_failed', message, err) + } + dropped.push(denyPath) + } + if (dropped.length > 0) { + logForDebugging( + `[Sandbox Linux] ${dropped.join(', ')} would have been denied whole, and each holds the working directory or a path this command may write, which a read-only bind would take the whole tree with: dropped. What that deny was holding - a symlinked git directory entry, or a link no bind can be put on - keeps the deny paths of its own two ends, and a command in this sandbox can still point the link somewhere else for a git run on the host to follow. A denyWrite entry naming the directory holding the link closes that.`, + { level: 'warn' }, + ) } + return kept +} + +/** + * The refusal for a git directory entry whose chain reaches nothing and whose + * links this backend has no bind for. Names the entry, the link and the + * directory holding it: every one of the three is somewhere the user can look, + * and each of the three ways out named at the end works on one of them. + */ +function unresolvableGitEntry( + hops: readonly GitChainHop[], +): LinuxSandboxProfileError { + const told = hops + .map( + hop => + `${hop.source} is a symlink whose chain reaches nothing and goes through ${hop.link}, which ${hop.holder} holds`, + ) + .join('; ') + return new LinuxSandboxProfileError( + 'deny_unresolvable_git_entry', + `[Sandbox Linux] ${told}. That directory is the working directory or a path this command may write, so the only thing that holds the link - a read-only bind of the directory around it - would take the whole tree with it, and the chain has no far end to deny instead: a command in this sandbox could put a directory in the link's place and leave the entry pointing at hooks or config this profile never saw, for a git run on the host to use. Refusing the command rather than running it with that open. Repairing the chain so it reaches something, moving the links inside the git directory, or a denyWrite entry naming the directory holding them, each closes it.`, + ) } /** @@ -902,7 +1082,7 @@ function monitorDotGitDenyPaths( const denies = gitDirDenies(dotGitPath, allowGitConfig) return [ ...withoutChainHopLinks(denies.denyPaths, denies.chainHops), - ...denies.linkedEntryDirs, + ...denies.linkedEntryDirs.map(wholeDirDenyLanding), ...chainHopDenies(denies.chainHops, allowWritePaths).held, ] } @@ -919,10 +1099,11 @@ function monitorDotGitDenyPaths( * tree with 150k ignored files three levels down. A scan that does not * deliver what is below the working directory aborts the wrap rather than * sandboxing with a deny list of unknown completeness: one that could not be - * run at all, one that does not finish inside {@link ripGrep}'s timeout, and - * one that failed for a reason no deny stands in for. The failures that are - * not fatal are told apart by the `(os error N)` codes on stderr and never - * by the paths printed beside them — see {@link ripgrepFailureErrnos}. + * run at all, one killed before it exited on its own — {@link ripGrep}'s + * timeout, or any other signal that reached it — and one that failed for a + * reason no deny stands in for. Only a run that ran to its own exit is told + * apart by the `(os error N)` codes on stderr, and never by the paths + * printed beside them — see {@link ripgrepFailureErrnos}. */ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, @@ -965,7 +1146,13 @@ async function linuxGetMandatoryDenyPaths( nestedGitDirs.push(gitDir) } const walkScan = (collectGitDirs: boolean) => - walkScanDirectories(cwd, maxDepth, walkDeadline(), collectGitDirs) + walkScanDirectories(cwd, maxDepth, walkDeadline(), { + collectGitDirs, + // Only the accounting for rg's own EACCES needs to know about the + // directories at the depth limit, and that is the call which does not + // collect git directories. + probeDepthLimit: !collectGitDirs, + }) // Build iglob args for all patterns in one ripgrep call const iglobArgs: string[] = [] @@ -1022,11 +1209,22 @@ async function linuxGetMandatoryDenyPaths( if ((error as { name?: unknown }).name === 'AbortError') throw error throw denyScanFailed(cwd, 'could not be run', error) } - if (error.timedOut) { - // The command that runs next is the one that could have made the tree - // slow to walk, so a truncated listing is not something to sandbox on: - // an unreached nested repository would be one with writable hooks. - throw denyScanFailed(cwd, 'did not finish', error) + if (error.killed) { + // A run that did not exit on its own stops wherever the kill arrived, + // so its listing is a prefix of an unknown whole and not something to + // sandbox on: an unreached nested repository would be one with + // writable hooks. The codes on stderr do not redeem it — a run that + // already reported one unreadable directory and was then killed inside + // its budget has every code it printed stood in for and still never + // reached the rest of the tree. The command that runs next is the one + // that could have made the tree slow to walk, whichever kill it was. + throw denyScanFailed( + cwd, + error.timedOut + ? 'did not finish' + : `was killed by ${error.killedBy ?? 'a signal'} before it finished`, + error, + ) } // rg lists the rest of the tree and exits non-zero when an entry gets in // its way, so the matches still count — but only where every code it @@ -1050,8 +1248,8 @@ async function linuxGetMandatoryDenyPaths( // reading rg's stderr: a name in there is chosen by whoever made the // directory. A walk finding none means the run failed over something // this cannot see, so there is nothing to stand in for it. - const { unreadableDirs } = walkScan(false) - if (unreadableDirs.length === 0) { + const { unreadableDirs, unreadableAtDepthLimit } = walkScan(false) + if (unreadableDirs.length === 0 && unreadableAtDepthLimit === 0) { throw denyScanFailed( cwd, `reported a directory it could not read, and none is there now: ${truncated(error.stderr)}`, @@ -1060,7 +1258,7 @@ async function linuxGetMandatoryDenyPaths( } denyPaths.push(...unreadableDirs) logForDebugging( - `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadableDirs.length} of the directories under it, which are denied whole; the ${matches.length} paths it did list still count: ${error}`, + `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadableDirs.length} of the directories under it, which are denied whole, and ${unreadableAtDepthLimit} sitting at the depth the scan stops at, which have nothing to deny below them; the ${matches.length} paths it did list still count: ${error}`, { level: 'warn' }, ) } else { @@ -1105,7 +1303,7 @@ async function linuxGetMandatoryDenyPaths( ) denyPaths.push( ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), - ...pointer.linkedEntryDirs, + ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), ) plan.chainHops.push(...pointer.chainHops) } @@ -1569,6 +1767,20 @@ export type LinuxSandboxProfileErrorCode = * message names it: the refusal is lifted only from outside the sandbox. */ | 'deny_git_metadata_unreadable' + /** + * An entry of a git directory under the working directory is a symlink + * whose chain reaches nothing - a loop, or a name no file can occupy - and + * one of the links it goes through sits in the working directory or a path + * this command may write. Such a link is held only by a read-only bind of + * the directory around it, which there would take the whole tree; leaving + * it free lets a command in the sandbox put a directory in its place, which + * makes the chain reach hooks or config this profile never saw and a git + * run on the host afterwards uses. Neither end of the chain stands in for + * it: the entry's own deny is a directory this one denies whole, and the + * landing does not exist. So the command is refused, which is what a + * repository laid out this way got before any of these denies existed. + */ + | 'deny_unresolvable_git_entry' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the @@ -2504,7 +2716,7 @@ async function generateFilesystemArgs( const plan = writeConfig === undefined ? undefined - : withChainHopDenies( + : guardedDenyPlan( await linuxGetMandatoryDenyPaths( ripgrepConfig, mandatoryDenySearchDepth, diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4047c956c..1a0ea2500 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -19,6 +19,7 @@ import { DANGEROUS_FILES, getDangerousDirectories, isAbsenceErrno, + coversWriteTree, } from './sandbox-utils.js' import { gitDirDenyPaths, @@ -109,6 +110,7 @@ export interface MacOSSandboxParams { */ export function macGetMandatoryDenyEntries( allowGitConfig = false, + allowWritePaths: readonly string[] = [], ): PathEntry[] { const cwd = normalizePathForSandbox(process.cwd(), { literal: true }) const entries: PathEntry[] = [] @@ -161,27 +163,47 @@ export function macGetMandatoryDenyEntries( // literal entry is a subpath deny, so this denies it whole, as the Linux // backend's bind of the same path does. if (!isAbsenceErrno(err)) { - return [...entries, ...gitDiskDenyEntries([dotGit], [])] + return [ + ...entries, + ...gitDiskDenyEntries([dotGit], [], cwd, allowWritePaths), + ] } } if (dotGitStat?.isDirectory()) { const tree = gitDirTreeDenies(dotGit, allowGitConfig) entries.push( - ...gitDiskDenyEntries(gitDirTreeDenyPaths(tree), tree.chainHops), + ...gitDiskDenyEntries( + gitDirTreeDenyPaths(tree), + tree.chainHops, + cwd, + allowWritePaths, + ), ) } else { // Absent, or a pointer file: the repository's own hooks and config are // denied either way, so neither can be created under a .git that is not // there yet. entries.push( - ...gitDiskDenyEntries(gitDirDenyPaths(dotGit, allowGitConfig), []), + ...gitDiskDenyEntries( + gitDirDenyPaths(dotGit, allowGitConfig), + [], + cwd, + allowWritePaths, + ), ) if (dotGitStat?.isFile()) { // cwd checked out as a linked worktree or submodule: .git is a pointer // file. Nested pointer files are matched by vnode type instead // (gitPointerFilter), which cannot follow them. const pointer = gitFileDenies(dotGit, allowGitConfig) - entries.push(...gitDiskDenyEntries(pointer.denyPaths, pointer.chainHops)) + entries.push( + ...gitDiskDenyEntries( + pointer.denyPaths, + pointer.chainHops, + cwd, + allowWritePaths, + ), + ) } } @@ -231,14 +253,32 @@ export function macGetMandatoryDenyEntries( function gitDiskDenyEntries( denyPaths: readonly string[], chainHops: readonly GitChainHop[], + cwd: string, + allowWritePaths: readonly string[], ): PathEntry[] { const hopLinks = new Set(chainHops.map(hop => hop.link)) const entries: PathEntry[] = [] const named = new Set() + const dropped: string[] = [] for (const denyPath of denyPaths) { const spellings = hopLinks.has(denyPath) ? [denyPath] : [denyPath, physicalDenyPath(denyPath)] + // A literal entry is a subpath deny, so one naming the working directory + // or a write root - by either spelling - takes the whole tree read-only + // for every command after the one that arranged it, and a `.git` pointer + // naming the checkout or an entry linked at `..` is one write. Neither + // end of what such a deny was holding loses its own entry; see + // `withoutWorkTreeDenies` in src/sandbox/linux-sandbox-utils.ts, which + // makes the same call for the same reason. + if ( + spellings.some(spelling => + coversWriteTree(spelling, cwd, allowWritePaths), + ) + ) { + dropped.push(denyPath) + continue + } for (const spelling of spellings) { const entry = toLiteralPathEntry(spelling) if (named.has(entry.path)) continue @@ -246,6 +286,12 @@ function gitDiskDenyEntries( entries.push(entry) } } + if (dropped.length > 0) { + logForDebugging( + `[Sandbox macOS] ${dropped.join(', ')} would have been denied whole, and each is the working directory or a path this command may write: dropped, since denying one leaves every later command unable to write the project. What it was holding keeps the entries of its own two ends.`, + { level: 'warn' }, + ) + } return entries } @@ -1023,7 +1069,12 @@ function generateWriteRules( // the mandatory entries carry their own literal/glob split. const denyEntries = [ ...(config.denyWithinAllow || []).map(toPathEntry), - ...macGetMandatoryDenyEntries(allowGitConfig), + ...macGetMandatoryDenyEntries( + allowGitConfig, + (config.allowOnly || []).map(allowed => + normalizePathForSandbox(allowed, { literal: true }), + ), + ), ] const { groups, rest: ungrouped } = groupLiteralDenyPaths(denyEntries) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 851173247..9c3280461 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -60,6 +60,30 @@ export function isAtOrUnder(p: string, dir: string): boolean { return p === dir || p.startsWith(dir === '/' ? '/' : dir + '/') } +/** + * Whether denying `dir` WHOLE would take the tree a sandbox exists to let the + * command write: `dir` is the working directory, an ancestor of it, a path + * the caller allows writes to, or a directory holding one. + * + * A whole-directory deny is what both backends fall back on for a tree they + * cannot enumerate, or a link they have no other handle on. Every producer of + * one reads paths a sandboxed command can create - a `.git` pointer naming + * the checkout, a `.git/modules` entry linked at `../..`, a directory left + * unreadable - so one that lands here is a command leaving every command + * after it unable to write the project. `dir` must be where a deny would + * LAND, symlinks resolved, since that is what the deny covers. + */ +export function coversWriteTree( + dir: string, + cwd: string, + allowWritePaths: readonly string[], +): boolean { + return ( + isAtOrUnder(cwd, dir) || + allowWritePaths.some(allowed => isAtOrUnder(allowed, dir)) + ) +} + /** `p` lies strictly beneath `dir` (isAtOrUnder, excluding `dir` itself). */ export function isStrictlyUnder(p: string, dir: string): boolean { return p !== dir && isAtOrUnder(p, dir) diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index a51319506..7e1741ec2 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -19,26 +19,39 @@ export const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 * ripgrep exited with an error status. `partialMatches` is what it listed * before that: rg reports an unreadable directory with exit code 2 after * printing every match it could reach, and names each one in `stderr`. - * `timedOut` says the run lasted as long as it was given and was killed for - * it, so what it listed is a prefix of an unknown whole rather than - * everything it could reach. A run killed by anything else says so in its - * message and is not a timeout. + * + * `killed` says the run did not exit on its own — the timeout, an OOM kill, a + * signal to the process group — so what it listed is a prefix of an unknown + * whole rather than everything it could reach, whatever its stderr already + * held. `timedOut` is the one such kill this can name, the run having lasted + * as long as it was given; `killedBy` is the signal, where the platform + * reported one. A caller deciding whether a listing can be used at all wants + * `killed`: a run cut off inside its budget is as incomplete as one cut off + * at the end of it. */ export class RipgrepError extends Error { readonly partialMatches: string[] readonly stderr: string readonly timedOut: boolean + readonly killed: boolean + readonly killedBy: NodeJS.Signals | undefined constructor( message: string, - partialMatches: string[], - stderr: string, - timedOut: boolean, + details: { + partialMatches: string[] + stderr: string + timedOut: boolean + killed: boolean + killedBy: NodeJS.Signals | undefined + }, ) { super(message) - this.partialMatches = partialMatches - this.stderr = stderr - this.timedOut = timedOut + this.partialMatches = details.partialMatches + this.stderr = details.stderr + this.timedOut = details.timedOut + this.killed = details.killed + this.killedBy = details.killedBy } } @@ -108,18 +121,24 @@ export async function ripGrep( // A null exit code means the child was killed rather than exiting. The // timeout is one reason for that and not the only one — a Ctrl-C to the // process group, an OOM kill — so it is the one this claims only where the - // run actually lasted as long as it was given. The rest are reported as - // what they were; upstream refuses either way, with an accurate message. + // run actually lasted as long as it was given. Either way the listing stops + // wherever the kill arrived, which is what `killed` carries and what a + // caller has to refuse on; the rest are reported as what they were. const killed = exit.code === null const elapsedMs = Date.now() - startedAt const timedOut = killed && elapsedMs >= timeoutMs - const killedBy = exit.signal ?? 'a signal' const failure = timedOut - ? `ripgrep was killed by ${killedBy} after ${elapsedMs} ms, the ${timeoutMs} ms it was given` + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${elapsedMs} ms, the ${timeoutMs} ms it was given` : killed - ? `ripgrep was killed by ${killedBy} after ${elapsedMs} ms, inside the ${timeoutMs} ms it was given` + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${elapsedMs} ms, inside the ${timeoutMs} ms it was given` : `ripgrep failed with exit code ${exit.code}` - throw new RipgrepError(`${failure}: ${stderr}`, matches, stderr, timedOut) + throw new RipgrepError(`${failure}: ${stderr}`, { + partialMatches: matches, + stderr, + timedOut, + killed, + killedBy: exit.signal ?? undefined, + }) } /** NUL-terminated records, dropping an unterminated (truncated) last one. */ From 3fe83342f33a390e80e053e3492d3eb1abc2adcd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:22:28 +0000 Subject: [PATCH 057/141] Tests: chains that reach nothing, denies aimed at the project, and the scan Chains that reach nothing: the hops a two-link loop, a loop entered after two ordinary hops and a name too long to exist give back; a loop confined to the git directory, whose hops name no holder; the macOS entries, which carry every visited link and never the directory holding them; a pointer value that loops, which denies the pointer file and the links and never the checkout. Linux plans: a loop a subdirectory holds gains one read-only bind and no refusal, a loop the working directory holds is refused, a looping pointer value leaves the working directory unbound. Under bubblewrap: every way to make such a loop reach a directory is refused while project writes elsewhere succeed, and two wraps in a row where the first plants a looping `.git` pointer and the second still writes the project. A deny aimed at the project: unit cases for the working directory, an ancestor of it, a second write root and a directory holding a write root (none denied), and for an ordinary directory (still denied whole). Live two-command arms for the two ways to plant one - a `.git` naming the checkout, and a `.git/modules` entry linked at `../..` - where the second command still writes the project. A working directory that cannot be listed refuses with the directory in the message. A directory that is readable but not searchable keeps the tree wrapping, with nothing under it writable and no `Is a directory` on stderr. An unreadable directory at the scan's depth limit keeps it wrapping too, pinned with a fake rg reporting the EACCES, which is decisive whatever the local ripgrep version does. A `modules` that is a symlink: unit cases for a git directory's own and for a submodule's, with the chain past it; the reported sequence under bubblewrap, refused, with nothing on the host; and the cost, live - a command plants `ln -s nowhere .git/modules`, and the next command finds `git status` working, `git commit` read-only and the hook unplantable. Against real git: what it does with a looping hooks link, and with a looping pointer value. The first needed the parity runner to stop forcing `core.hooksPath=/dev/null` for that one case. And the scan: a fake rg that prints a match and an `(os error 13)` line and is then killed by SIGKILL well inside its budget is refused rather than read as a complete listing. --- test/sandbox/git-pointer-parity.test.ts | 90 +- test/sandbox/mandatory-deny-paths.test.ts | 1149 ++++++++++++++++++++- test/utils/ripgrep.test.ts | 17 + 3 files changed, 1242 insertions(+), 14 deletions(-) diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 529e5d048..518b30156 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { spawnSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -196,7 +197,11 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { * end in a space or hold a newline of its own — or undefined when it * failed, which for `rev-parse` here means it did not follow the pointer. */ - function runGit(cwd: string, args: string[]): string | undefined { + function runGit( + cwd: string, + args: string[], + options: { runHooks?: boolean } = {}, + ): string | undefined { const env: NodeJS.ProcessEnv = { ...process.env, HOME: root, @@ -222,7 +227,17 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { } const result = spawnSync( 'git', - ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=/dev/null', ...args], + [ + '-c', + 'core.fsmonitor=', + // Nothing here is about running hooks, and a target these cases + // write is not a directory to let git execute out of — except the + // one case that IS about a hooks link, which asks for them. + ...(options.runHooks === true + ? [] + : ['-c', 'core.hooksPath=/dev/null']), + ...args, + ], { cwd, env, encoding: 'utf8' }, ) if (result.status !== 0) return undefined @@ -692,6 +707,77 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect(gitFileDenyPaths(pointer, false)).toContain(join(lexical, 'hooks')) }) + it('opens nothing through a pointer value that runs into a loop', () => { + // What git does with one, recorded: it follows the value the way the + // kernel does, gives up on the loop, and does NOT fall back to anything - + // not the lexical fold, and not the checkout. So there is no directory + // whose hooks a deny has to cover, and the deny list says so: the pointer + // file, and the links the walk went through, which are what a command + // replaces to make the value reach a directory after all. The checkout + // itself is not denied, or a command that wrote this file would leave + // every command after it unable to write the project. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + makeGitDir(join(checkout, 'evil')) + const first = join(checkout, 'loopA') + const second = join(checkout, 'loopB') + symlinkSync('loopB', first) + symlinkSync('loopA', second) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: loopA/../evil\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() + + const denyPaths = gitFileDenyPaths(pointer, false) + expect(denyPaths).toEqual([pointer, first, second]) + expect(denyPaths).not.toContain(checkout) + }) + + it('runs a hook again as soon as a looping hooks link reaches a directory', () => { + // The other half of what a loop means, against real git: while + // `.git/hooks` runs into one, git finds no hook there and commits + // without it - and the moment one of the LINKS is replaced by a + // directory, with the entry and the git directory untouched, the hook + // inside it is what git runs. That is why every link a looping chain + // went through is denied: they are the whole of what makes it reach + // anything. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + expect(runGit(checkout, ['init', '-q', '-b', 'main', '.'])).toBeDefined() + runGit(checkout, ['config', 'user.email', 'parity@example.invalid']) + runGit(checkout, ['config', 'user.name', 'Parity']) + const links = join(checkout, 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + rmSync(join(checkout, '.git', 'hooks'), { recursive: true, force: true }) + symlinkSync('../links/a', join(checkout, '.git', 'hooks')) + const ran = join(caseDir, 'hook-ran') + writeFileSync(join(checkout, 'f.txt'), 'one\n') + runGit(checkout, ['add', 'f.txt']) + + expect( + runGit(checkout, ['commit', '-q', '-m', 'first'], { runHooks: true }), + ).toBeDefined() + expect(existsSync(ran)).toBe(false) + + // One of the two links, replaced by a directory holding a hook. + rmSync(join(links, 'a')) + mkdirSync(join(links, 'a'), { recursive: true }) + writeFileSync(join(links, 'a', 'pre-commit'), `#!/bin/sh\ntouch ${ran}\n`, { + mode: 0o755, + }) + writeFileSync(join(checkout, 'f.txt'), 'two\n') + runGit(checkout, ['add', 'f.txt']) + + expect( + runGit(checkout, ['commit', '-q', '-m', 'second'], { runHooks: true }), + ).toBeDefined() + expect(existsSync(ran)).toBe(true) + }, 120_000) + // Linux only: the target has to exist for git to follow it, and macOS // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The // refusal itself is platform-independent and covered everywhere by the diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 1c634561c..bc6da7207 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -20,6 +20,7 @@ import { symlinkSync, readlinkSync, existsSync, + lstatSync, statSync, realpathSync, } from 'node:fs' @@ -1029,6 +1030,69 @@ describe.if(isSupportedPlatform)( locked.release() } }) + + it('accounts for an unreadable directory at the depth the scan stops at', async () => { + // ripgrep opens a directory that sits AT its --max-depth to + // list it, and reports one it cannot read. This walk stops a + // level above, so it found nothing for that EACCES and the + // wrap was refused - over a layout as ordinary as a + // `docker/data/postgres` another user owns. Nothing below such + // a directory is in the scan's reach on either side, so it is + // accounted for with nothing to deny. + const locked = lockDirectory(join('docker', 'data', 'postgres')) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + const denies = denySectionOf(command) + expect(denies).not.toContain(denyBindOf(locked.path)) + // And nothing widened to stand in for it. + expect(denies).not.toContain( + denyBindOf(join(process.cwd(), 'docker', 'data')), + ) + expect(denies).toContain(denyBindOf(nestedConfig())) + } finally { + locked.release() + } + }) + + it('refuses a run killed inside its budget, whatever it reported', async () => { + // Every code this run printed is one a deny stands in for, and + // the directory it named is really there - so the listing + // would be used, if the run had finished. It did not: a kill + // that is not the timeout (an OOM kill, a signal to the + // process group) stops the listing wherever it arrived, and + // what lies past that is unknown however complete the stderr + // looks. An unreadable directory is one `chmod 000` a command + // can leave behind, so taking the two together would be a + // fail-open with a plantable half. + const locked = lockDirectory('killed-mid-listing') + try { + const error = await wrapWith({ + command: '/bin/sh', + args: [ + '-c', + `printf '%s\\0' ${quote([nestedConfig()])}; ` + + `printf '%s' ${quote([locked.stderr])} >&2; ` + + 'kill -KILL $$', + ], + timeoutMs: 30000, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch( + /was killed by SIGKILL before it finished/, + ) + // Named as what it was: nothing here ran out of time. + expect((error as Error).message).not.toMatch(/did not finish/) + } finally { + locked.release() + } + }) }, ) @@ -2361,6 +2425,40 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { } }) + it.if(!isWindows)('names every link a looping entry went through', () => { + // A filter matches the name an unlink or a rename uses, so each link of + // the loop is held by its own name here and nothing else is needed: the + // command that would make `.git/hooks` reach a directory has to remove + // one of them first. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-loop-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const links = join(dir, 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + symlinkSync('../links/a', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(links, 'a')) + expect(paths).toContain(join(links, 'b')) + // What a hop leads to is never denied, and a loop leads nowhere to + // deny in the first place: the directory holding the links stays out. + expect(paths).not.toContain(links) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + it.if(!isWindows)( 'names a submodule reached through a symlinked modules entry both ways', () => { @@ -2618,6 +2716,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(gitDir, 'hooks'), link: middle, holder: join(dir, 'repo'), @@ -2652,6 +2751,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo'), @@ -2680,6 +2780,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo'), @@ -2710,6 +2811,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(gitDir, 'hooks'), link: hop, holder: undefined, @@ -2743,6 +2845,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(gitDir, 'hooks'), link: hop, holder: join(dir, 'repo'), @@ -2773,24 +2876,240 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) expect(denies.linkedEntryDirs).toEqual([gitDir]) expect(denies.escapingDenyPaths).toEqual([]) - // A chain past the kernel's hop limit reaches nothing, so there is no - // hop to hold either: the whole deny is the whole answer. + // A link onto itself is the one link of its own chain, and the whole + // deny of the directory holding it covers that: no hop is left over. + expect(denies.chainHops).toEqual([]) + }, + ) + + it.if(!isWindows)( + 'holds the links a looping entry goes through, which are what makes it loop', + () => { + // hooks -> ../links/a, a -> b, b -> a. The entry reaches nothing while + // that stands, and the whole-directory deny holds the entry, but the + // two links are ordinary work-tree names: a command that puts a + // directory at either one makes the chain reach it, and the host's git + // runs what is inside. Every link the walk visited before it gave up is + // therefore a hop, named once however many times the loop met it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const links = join(dir, 'repo', 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../links/a', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(links, 'a')) + expect(denies.denyPaths).toContain(join(links, 'b')) + // Neither is under the git directory, so a degrade to one bind of it + // must not drop them. + expect(denies.escapingDenyPaths).toEqual([ + join(links, 'a'), + join(links, 'b'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'a'), + holder: links, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'b'), + holder: links, + }, + ]) + }, + ) + + it.if(!isWindows)('holds the hops before a loop it runs into', () => { + // Two ordinary hops and then the loop: the chain gives up in the middle, + // and the links it walked to get there are held like any others. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const links = join(dir, 'repo', 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('second', join(links, 'first')) + symlinkSync('loopA', join(links, 'second')) + symlinkSync('loopB', join(links, 'loopA')) + symlinkSync('loopA', join(links, 'loopB')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../links/first', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.chainHops.map(hop => hop.link)).toEqual([ + join(links, 'first'), + join(links, 'second'), + join(links, 'loopA'), + join(links, 'loopB'), + ]) + expect(denies.chainHops.every(hop => hop.chain === 'unresolvable')).toBe( + true, + ) + }) + + it.if(!isWindows)( + 'leaves a loop confined to the git directory to the whole-directory deny', + () => { + // Both links live under the git directory, which is denied whole for + // the entry anyway: each hop names no holder of its own, and nothing a + // command can write is left over. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync('inner-b', join(gitDir, 'inner-a')) + symlinkSync('inner-a', join(gitDir, 'inner-b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('inner-a', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-a'), + holder: undefined, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-b'), + holder: undefined, + }, + ]) + }, + ) + + it.if(!isWindows)('holds a modules directory that is a symlink', () => { + // `modules` is no deny path of its own - denying it whole would take + // every submodule git directory under it read-only - but removing the + // link and putting a directory in its place aims every submodule this + // repository has at hooks nothing denied. So it is held the way a hop is: + // its own name for the backend that matches a rename, and this git + // directory denied whole for the one whose denies resolve. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const elsewhere = join(dir, 'nm') + mkdirSync(elsewhere, { recursive: true }) + symlinkSync('../nm', join(gitDir, 'modules')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(gitDir, 'modules')) + // Never what it leads to: that is a tree of submodule git directories, + // each with precise denies of its own. + expect(denies.denyPaths).not.toContain(elsewhere) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'modules'), + link: join(gitDir, 'modules'), + holder: undefined, + }, + ]) + }) + + it.if(!isWindows)( + 'leaves an ordinary modules directory out of the denies entirely', + () => { + // The other half: a real `modules` is where every submodule git + // directory lives, and each of those is denied precisely. Naming it + // would take them all read-only. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'modules', 'lib'), { recursive: true }) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + expect(denies.linkedEntryDirs).toEqual([]) expect(denies.chainHops).toEqual([]) }, ) + it.if(!isWindows)( + "holds a submodule's own modules directory, and the chain past it", + () => { + // The shape the whole of this exists for: a submodule git directory + // whose own `modules` leads out of the repository through a link. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) + const links = join(dir, 'repo', 'links') + mkdirSync(join(dir, 'repo', 'nm', 'bar'), { recursive: true }) + mkdirSync(links, { recursive: true }) + symlinkSync('../nm', join(links, 'hop')) + symlinkSync('../../../links/hop', join(submodule, 'modules')) + + const denies = gitDirDenies(submodule, false) + + expect(denies.denyPaths).toContain(join(submodule, 'modules')) + expect(denies.linkedEntryDirs).toEqual([submodule]) + // The link in the middle is a hop like any other, held by the + // directory it sits in. + expect(denies.chainHops.map(hop => hop.link)).toEqual([ + join(submodule, 'modules'), + join(links, 'hop'), + ]) + expect(denies.chainHops.map(hop => hop.holder)).toEqual([ + undefined, + links, + ]) + }, + ) + + it.if(!isWindows)( + 'holds the hops of an entry whose chain names something too long to exist', + () => { + // ENAMETOOLONG, not a loop: the chain reaches nothing all the same, and + // the link it went through is a name a command can point elsewhere. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const hop = join(dir, 'repo', 'hop') + symlinkSync(`../${'n'.repeat(300)}`, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../hop', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }, + ) + it.if(!isWindows && process.getuid?.() !== 0)( 'takes an entry it cannot look at for one that needs both denies', () => { // lstat refused: whether it is a link is unknown, so the entry keeps // the deny it has (and keeps it through a degrade, in case it is one) - // and the git directory is denied whole as a link would need. + // and the git directory is denied whole as a link would need. `modules` + // is in the same position, and named for the backend that holds a link + // by its own name. const gitDir = makeGitDir(join(dir, 'repo', '.git')) chmodSync(gitDir, 0o000) try { const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + expect(denies.denyPaths).toEqual([ + ...gitDirDenyPaths(gitDir, false), + join(gitDir, 'modules'), + ]) expect(denies.linkedEntryDirs).toEqual([gitDir]) expect(denies.escapingDenyPaths).toEqual(gitDirDenyPaths(gitDir, false)) } finally { @@ -2865,6 +3184,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(scan.chainHops).toEqual([ { kind: 'entry', + chain: 'resolved', source: join(modules, 'lib'), link: hop, holder: join(dir, 'repo'), @@ -3105,6 +3425,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: pointer, link: hop, holder: join(dir, 'checkout'), @@ -3178,6 +3499,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: pointer, link: hop, holder: join(dir, 'checkout'), @@ -3191,21 +3513,62 @@ describe('Git metadata deny paths - Unit Tests', () => { symlinkSync(join(dir, 'checkout', 'loopB'), join(dir, 'checkout', 'loopA')) symlinkSync(join(dir, 'checkout', 'loopA'), join(dir, 'checkout', 'loopB')) - // Past the hop limit the walk stops on the loop itself, which is where - // the deny goes: the whole directory that still reads. + // A value the kernel cannot walk to an end names no git directory: git + // opens nothing through it, and the lexical fold is not a fallback git + // takes. The links it walked are the whole of what a command can move, + // and the checkout - which a fail-closed deny of the deepest directory + // still reachable would take whole, over a file a sandboxed command may + // create - stays writable. const denies = gitFileDenies(pointer, false) expect(denies.denyPaths).toEqual([ pointer, - ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), - join(dir, 'checkout'), + join(dir, 'checkout', 'loopA'), + join(dir, 'checkout', 'loopB'), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'unresolvable', + source: pointer, + link: join(dir, 'checkout', 'loopA'), + holder: join(dir, 'checkout'), + }, + { + kind: 'pointer', + chain: 'unresolvable', + source: pointer, + link: join(dir, 'checkout', 'loopB'), + holder: join(dir, 'checkout'), + }, ]) - // A chain the kernel cannot walk either reaches nothing, so there is no - // hop to hold: the whole-directory deny is the whole answer, as it is - // for a symlinked entry that loops. - expect(denies.chainHops).toEqual([]) }) + it.if(!isWindows)( + 'never widens a looping pointer value to a write root', + () => { + // The shape a sandboxed command can plant in one write: a .git file + // whose value runs into a loop in the checkout itself. Nothing here + // names the checkout or any directory above it, so the commands after + // it still write the project; the pointer file and the loop's links + // are what they cannot touch. + const pointer = writePointer('checkout', 'gitdir: loop/gd\n') + symlinkSync('loop2', join(dir, 'checkout', 'loop')) + symlinkSync('loop', join(dir, 'checkout', 'loop2')) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + join(dir, 'checkout', 'loop'), + join(dir, 'checkout', 'loop2'), + ]) + expect(denies.denyPaths).not.toContain(join(dir, 'checkout')) + expect(denies.denyPaths).not.toContain(dir) + expect(denies.linkedEntryDirs).toEqual([]) + }, + ) + it.if(!isWindows)('resolves a commondir the same way', () => { const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) const physical = makeGitDir(join(dir, 'real', 'common')) @@ -3229,6 +3592,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: join(worktreeGitDir, 'commondir'), link: hop, holder: worktreeGitDir, @@ -3284,6 +3648,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: pointer, link: hop, holder: join(dir, 'checkout'), @@ -3310,6 +3675,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: pointer, link: hop, holder: join(dir, 'checkout'), @@ -3336,6 +3702,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.chainHops).toEqual([ { kind: 'pointer', + chain: 'resolved', source: pointer, link: hop, holder: join(dir, 'checkout'), @@ -3360,6 +3727,127 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) }) + /** + * A whole-directory deny is what a backend falls back on for a tree it + * cannot enumerate or a link it has no other handle on, and every producer + * of one reads paths a sandboxed command can write. One that lands on the + * working directory or a write root takes the project read-only for every + * command after the one that arranged it, and nothing inside the sandbox + * can undo it: a denial of service planted by the code the sandbox + * contains. None is emitted, whichever producer asked. + */ + describe.if(isLinux)('a deny that would take the work tree whole', () => { + /** A directory git accepts and whose `hooks` is a link, which is what + * makes whatever holds it a git directory denied WHOLE. */ + function makeLinkedGitDir(gitDir: string): string { + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + symlinkSync('nowhere', join(gitDir, 'hooks')) + return gitDir + } + + /** `linuxGetCwdMandatoryDenyPaths` from `checkout`. */ + function denyPathsIn(checkout: string, allowWritePaths: string[]) { + const originalCwd = process.cwd() + process.chdir(checkout) + try { + return linuxGetCwdMandatoryDenyPaths(false, allowWritePaths) + } finally { + process.chdir(originalCwd) + } + } + + it('never denies the working directory a pointer of its own names', () => { + // One write plants it: `.git` naming the checkout, whose `hooks` is a + // link. The checkout is then a git directory with a symlinked entry, + // which is denied whole - and that is the project. + const checkout = makeLinkedGitDir(join(dir, 'repo')) + writeFileSync(join(checkout, '.git'), 'gitdir: .\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).not.toContain(checkout) + // What the deny was holding keeps its own two ends. + expect(denyPaths).toContain(join(checkout, 'hooks')) + }) + + it('never denies an ancestor of the working directory', () => { + const parent = makeLinkedGitDir(join(dir, 'outer')) + const checkout = join(parent, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, '.git'), 'gitdir: ..\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).not.toContain(parent) + }) + + it('never denies a second write root', () => { + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const other = makeLinkedGitDir(join(dir, 'other')) + writeFileSync(join(checkout, '.git'), `gitdir: ${other}\n`) + + const denyPaths = denyPathsIn(checkout, [checkout, other]) + + expect(denyPaths).not.toContain(other) + }) + + it('never denies a directory that holds a write root', () => { + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const container = makeLinkedGitDir(join(dir, 'container')) + const secondRoot = join(container, 'inner') + mkdirSync(secondRoot, { recursive: true }) + writeFileSync(join(checkout, '.git'), `gitdir: ${container}\n`) + + const denyPaths = denyPathsIn(checkout, [checkout, secondRoot]) + + expect(denyPaths).not.toContain(container) + }) + + it('still denies an ordinary directory whole', () => { + // The guard is about the work tree and nothing else: a git directory + // beside it keeps the deny that holds its symlinked entry. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const gitDir = makeLinkedGitDir(join(checkout, 'gitdir-real')) + writeFileSync(join(checkout, '.git'), 'gitdir: gitdir-real\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).toContain(gitDir) + }) + + it('never denies the working directory a modules entry is linked to', () => { + // The same end state through the `.git/modules` walk, and with no + // pointer file: the entry is followed, the checkout has an entry named + // `hooks` so the walk takes it for a git directory, and its `hooks` is + // a link - so the entry's own git directory, which resolves to the + // checkout, is denied whole. + const checkout = join(dir, 'repo') + const gitDir = join(checkout, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + symlinkSync('nowhere', join(checkout, 'hooks')) + symlinkSync('../..', join(gitDir, 'modules', 'x')) + + const denyPaths = denyPathsIn(checkout, [checkout]) + + // By where each lands, not by how it is spelled: a bind resolves its + // destination, so `.git/modules/x` IS the checkout here. + expect( + denyPaths.map(denyPath => { + try { + return realpathSync(denyPath) + } catch { + return denyPath + } + }), + ).not.toContain(checkout) + }) + }) + it.if(isLinux)( 'hands the monitor plain deny paths for a repository it cannot read', () => { @@ -5248,6 +5736,643 @@ describe.if(isSupportedPlatform)( 300000, ) }) + + /** + * A chain that reaches NOTHING - a loop, or a name no file can occupy - + * has no landing to deny, so the links it goes through are the whole of + * what holds it. Each is a name a command replaces with a directory to + * make the chain reach one, which is why a hop this backend cannot bind + * is a refusal here rather than the warning a resolved chain gets. + */ + describe('a chain that reaches nothing', () => { + /** A repository whose `hooks` runs into a two-link loop in `holder`. */ + function makeLoop(holder: string): { + checkout: string + gitDir: string + first: string + second: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + mkdirSync(holder, { recursive: true }) + const first = join(holder, 'a') + const second = join(holder, 'b') + symlinkSync('b', first) + symlinkSync('a', second) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(first, join(gitDir, 'hooks')) + return { checkout, gitDir, first, second } + } + + it.if(isLinux)( + 'binds the directory holding a loop read-only where it is a subdirectory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir } = makeLoop(holder) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The entry keeps what it had: the git directory denied whole. + expect( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) + + it.if(isLinux)( + 'refuses the command where the working directory holds the loop', + async () => { + // There is no bind for the link and no far end to fall back on, so + // the answer a resolved chain gets - warn, and rely on both ends - + // holds nothing here. This is what such a repository got before any + // of these denies existed: the deny path could not be resolved and + // the wrap failed closed. + const checkout = join(dir, 'repo') + const { gitDir, first } = makeLoop(checkout) + process.chdir(checkout) + + const error = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_unresolvable_git_entry', + ) + // The entry, the link and the directory holding it: each of the + // three ways out the message names works on one of them. + expect((error as Error).message).toContain(join(gitDir, 'hooks')) + expect((error as Error).message).toContain(first) + expect((error as Error).message).toContain(checkout) + }, + 60000, + ) + + it.if(isLinux)( + 'never denies the working directory for a pointer value that loops', + async () => { + // A `.git` FILE is something a sandboxed command may write, so + // failing closed on the deepest directory still reachable towards + // its value would let one command lock every later one out of the + // project. The pointer file and the loop's links are denied; the + // checkout is not, and nothing is refused. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const first = join(checkout, 'a') + symlinkSync('b', first) + symlinkSync('a', join(checkout, 'b')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: a/gd\n') + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', pointer, pointer), + ).toBeGreaterThan(writeRootBind) + expect(warnings.join('\n')).toContain(pointer) + }, + 60000, + ) + + it.if(LIVE)( + 'refuses every way to make a loop a subdirectory holds reach a directory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir, first, second } = makeLoop(holder) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! rm -f ${first} || echo LINK_UNLINKABLE; ` + + `! mv ${second} ${second}.aside || echo LINK_RENAMABLE; ` + + `! ln -sfn ${evil} ${first} || echo LINK_RETARGETABLE; ` + + `! mkdir -p ${join(holder, 'a', 'x')} || echo LINK_REPLACEABLE_BY_DIR; ` + + `! echo x > ${join(holder, 'a')} || echo LINK_REPLACEABLE_BY_FILE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('LINK_UNLINKABLE') + expect(result.stdout).not.toContain('LINK_RENAMABLE') + expect(result.stdout).not.toContain('LINK_RETARGETABLE') + expect(result.stdout).not.toContain('LINK_REPLACEABLE_BY_DIR') + expect(result.stdout).not.toContain('LINK_REPLACEABLE_BY_FILE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The loop is where it was: `.git/hooks` still reaches nothing. + expect(readlinkSync(first)).toBe('b') + expect(readlinkSync(second)).toBe('a') + }, + 300000, + ) + + it.if(LIVE)( + 'leaves the project writable for the command after one that plants a looping pointer', + async () => { + // Two wraps, as a session runs them. The first writes a `.git` file + // whose value runs into a loop in the working directory - an + // ordinary write inside the write root, and one creating a `.git` + // is deliberately allowed to make. The second still writes the + // project: what it cannot touch is the pointer file itself, which + // is removed from outside the sandbox. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + const pointer = join(checkout, '.git') + + const plant = await wrapCommandWithSandboxLinux({ + command: + `ln -sfn b ${join(checkout, 'a')} && ` + + `ln -sfn a ${join(checkout, 'b')} && ` + + `printf 'gitdir: a/gd\\n' > ${pointer} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const planted = spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + expect(planted.stdout).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const { result: after, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `mkdir -p ${join(checkout, 'sub')} || echo SUBDIR_UNCREATABLE; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! rm -f ${pointer} || echo POINTER_REMOVABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + // The project is the command's, as it was before the plant. + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('SUBDIR_UNCREATABLE') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The pointer file is not: it stays until it is removed from + // outside the sandbox, and the wrap says which file that is. + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('POINTER_REMOVABLE') + expect(warnings.join('\n')).toContain(pointer) + }, + 300000, + ) + }) + + /** + * Every whole-directory deny is a stand-in read off paths a sandboxed + * command can write, so each is a way to aim one at the project itself. + * None of them may: the deny is dropped where what is beneath it is + * known, and the command refused where it is not. + */ + describe('a deny aimed at the project', () => { + const NOT_ROOT = isLinux && process.getuid?.() !== 0 + + it.if(LIVE)( + 'leaves the project writable after a pointer that names the checkout', + async () => { + // One command: a `.git` file naming the checkout, and an entry + // named `hooks` that is a link, which makes the checkout a git + // directory with a symlinked entry - denied WHOLE before this. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: + `printf 'ref: refs/heads/main\\n' > ${join(checkout, 'HEAD')} && ` + + `ln -sfn nowhere ${join(checkout, 'hooks')} && ` + + `printf 'gitdir: .\\n' > ${join(checkout, '.git')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `mkdir -p ${join(checkout, 'sub')} || echo SUBDIR_UNCREATABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('SUBDIR_UNCREATABLE') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) + + it.if(LIVE)( + 'leaves the project writable after a modules entry linked at the checkout', + async () => { + // The same end state with no pointer file at all: the walk follows + // `.git/modules/x` to the checkout, which has an entry named + // `hooks`, so it reads as a git directory whose entry is a link. + const checkout = join(dir, 'repo') + const gitDir = join(checkout, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: + `ln -sfn nowhere ${join(checkout, 'hooks')} && ` + + `ln -sfn ../.. ${join(gitDir, 'modules', 'x')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) + + it.if(NOT_ROOT)( + 'refuses rather than deny a working directory it cannot list', + async () => { + // The one whole-directory deny nothing stands in for: what is + // beneath an unlistable directory is unknown, so dropping it would + // run the command against an unscanned tree and keeping it locks + // the project. Refusing names the directory, and one chmod outside + // the sandbox lifts it. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + process.chdir(checkout) + // Searchable, so chdir and the walk's own lstat still work, and + // unreadable, so neither rg nor the walk can list it. + chmodSync(checkout, 0o100) + try { + const error = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain(checkout) + } finally { + chmodSync(checkout, 0o755) + } + }, + 60000, + ) + + it.if(LIVE && NOT_ROOT)( + 'keeps wrapping a tree holding a directory that is readable but not searchable', + async () => { + // mode 0400: its entries list, and nothing in it can be reached. + // The walk comes here for the CHILD whose own listing failed, and + // recording that child - a path the host cannot stat either - asks + // bubblewrap for a file mount at what is really a directory, which + // fails every later wrap with an untyped error. + const checkout = join(dir, 'repo') + const readable = join(checkout, 'a') + mkdirSync(join(readable, 'nested', '.git'), { recursive: true }) + writeFileSync( + join(readable, 'nested', '.git', 'HEAD'), + 'ref: refs/heads/main\n', + ) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + chmodSync(readable, 0o400) + try { + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `! mkdir ${join(readable, 'x')} || echo UNSEARCHABLE_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stderr).not.toContain('Is a directory') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + // The directory that cannot be seen through is denied whole. + expect(result.stdout).not.toContain('UNSEARCHABLE_WRITABLE') + } finally { + chmodSync(readable, 0o755) + } + }, + 300000, + ) + + it.if(NOT_ROOT)( + 'wraps a project holding an unreadable directory at the scan depth limit', + async () => { + // ripgrep opens a directory that sits AT its --max-depth to list + // it, and reports one it cannot read; this walk stops a level + // above, so a directory another user owns exactly that far down - + // a `docker/data/postgres`, mode 0700 - was an EACCES the walk + // found nothing for, and every command in the project refused. + const checkout = join(dir, 'repo') + const deep = join(checkout, 'docker', 'data', 'postgres') + mkdirSync(deep, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + chmodSync(deep, 0o000) + try { + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + expect(command).toContain('bwrap') + } finally { + chmodSync(deep, 0o755) + } + }, + 60000, + ) + }) + + /** + * `modules` is where a git directory keeps the git directories of its + * submodules. It is no deny path of its own - that would take every one + * of them read-only - but it is an entry a command can replace, and + * replacing it aims every submodule this repository has at hooks nothing + * denied. It is held the way a chain hop is. + */ + describe('a modules directory that is a symlink', () => { + it.if(LIVE)( + "refuses a directory put in place of a submodule's modules link", + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) + const outside = join(checkout, 'nm') + makeGitDir(join(outside, 'bar')) + symlinkSync('../../../nm', join(submodule, 'modules')) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(outside, 'bar', 'hooks', 'post-checkout')} || echo THROUGH_LINK_WRITABLE; ` + + `! echo x > ${join(submodule, 'hooks', 'post-checkout')} || echo SUBMODULE_HOOKS_WRITABLE; ` + + `! rm ${join(submodule, 'modules')} || echo MODULES_LINK_REMOVABLE; ` + + // The reviewer's own sequence: replace the link with a real + // directory of the same name and write the hook into it. + `! { rm ${join(submodule, 'modules')} && ` + + `mkdir -p ${join(submodule, 'modules', 'bar', 'hooks')} && ` + + `echo x > ${join(submodule, 'modules', 'bar', 'hooks', 'post-checkout')}; } || echo MODULES_REPLACED; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('SUBMODULE_HOOKS_WRITABLE') + expect(result.stdout).not.toContain('MODULES_LINK_REMOVABLE') + expect(result.stdout).not.toContain('MODULES_REPLACED') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + // Nothing of it reached the host: the link is where it was, and + // there is no hook at either end of it. + expect(lstatSync(join(submodule, 'modules')).isSymbolicLink()).toBe( + true, + ) + expect( + existsSync(join(outside, 'bar', 'hooks', 'post-checkout')), + ).toBe(false) + }, + 300000, + ) + + it.if(LIVE)( + 'costs the repository its sandboxed git writes when a command plants the link', + async () => { + // The price of holding the link, recorded rather than left to be + // found: an ordinary repository has no `.git/modules`, and an + // absent one is deliberately not denied (a mount there would stop + // `git submodule add`). So one `ln -s` makes `.git` a git + // directory with a symlinked entry, which is denied whole from the + // next wrap on - and nothing inside the sandbox can remove the + // link, because `.git` is read-only by then. + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + expect( + spawnSync( + git, + ['-c', 'init.defaultBranch=main', 'init', '-q', checkout], + { encoding: 'utf8', timeout: 60000 }, + ).status, + ).toBe(0) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: `ln -sfn nowhere ${join(checkout, '.git', 'modules')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + `echo two > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} add f.txt && ${git} -C ${checkout} -c user.email=a@b -c user.name=n commit -q -m x && echo GIT_COMMIT_WORKED; ` + + `! rm ${join(checkout, '.git', 'modules')} || echo LINK_REMOVABLE; ` + + `! echo x > ${join(checkout, '.git', 'hooks', 'post-commit')} || echo HOOK_PLANTABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + // The work tree is the command's, and reading the repository works. + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + // What it costs: everything that writes the git directory. + expect(result.stdout).not.toContain('GIT_COMMIT_WORKED') + expect(result.stdout).not.toContain('LINK_REMOVABLE') + expect(result.stdout).not.toContain('HOOK_PLANTABLE') + }, + 300000, + ) + }) }, ) diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 046b12866..52cd46ce2 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -171,11 +171,28 @@ describe('ripGrep', () => { expect(error).toBeInstanceOf(RipgrepError) expect((error as RipgrepError).timedOut).toBe(false) + // A caller deciding whether the listing can be used at all asks this, + // not `timedOut`: the run stopped where the kill arrived either way. + expect((error as RipgrepError).killed).toBe(true) + expect((error as RipgrepError).killedBy).toBe('SIGTERM') expect((error as Error).message).toContain('SIGTERM') expect((error as Error).message).toContain('inside the 60000 ms') }, ) + it.if(!isWindows)( + 'says a run that exited on its own was not killed', + async () => { + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'printf "/found/a\\0"; exit 2'], + }).catch((e: unknown) => e) + + expect((error as RipgrepError).killed).toBe(false) + expect((error as RipgrepError).killedBy).toBeUndefined() + }, + ) + it.if(!isWindows)( 'keeps a path containing a newline in one piece', async () => { From 38190cdd6f143894a8e11460fa5dfcbb6c210b1f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:22:28 +0000 Subject: [PATCH 058/141] README: what a deny may not take, and what a killed scan is worth Statements that were true of the code before this round and are not now: a chain past the kernel's hop limit has no hop to hold; `modules` is not among the entries checked for being a link, and a `.git/modules` that is one cannot be held; a run killed other than by the timeout is told apart by the codes on its stderr; a held hop's holder bind is only ever the cost of a layout the caller already had. The chain bullet gains what holds a chain that reaches nothing and when Linux refuses instead; a bullet on `modules` says how it is held and what a command can make it cost; the pointer bullet says that a value reaching nothing is never denied whole anywhere, and why; the refusal list gains the new reason and the rule that no whole-directory deny may cover the work tree, with the two answers that replace one; the scan's failure rules say that they read a run which exited on its own, that a killed one is refused whatever it printed, which directory is denied when one is readable but not searchable, and what happens at the depth the scan stops at; and the list of costs a command can plant says plainly that a command can create the layout whose hop makes a directory read-only. --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e75be71f6..d6f36e1cc 100644 --- a/README.md +++ b/README.md @@ -778,10 +778,10 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. -- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. +- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. A pointer or a `commondir` whose VALUE reaches nothing is not denied whole anywhere either — neither the directory it would fold to nor the deepest one still reachable towards it, which for a value like `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself. git opens nothing through such a value, and a `.git` file is something a sandboxed command is deliberately allowed to create, so failing closed there would let one command leave every command after it unable to write the project. What is denied is the pointer file and the links the walk did go through, with one `SRT_DEBUG` warning naming the file. - **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. -- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. A chain past the kernel's own hop limit reaches nothing — git fails on it too — so it has no hop to hold and the whole-directory deny its landing gets is the whole answer. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. -- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. +- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. **A chain that reaches NOTHING is held by its hops alone.** A loop, or a chain longer than the kernel follows, has no landing: git gives up on it exactly as this does, so the deny the landing would get stands on nothing, and every link the walk visited before it gave up is the whole of what a command can move — put a directory in one of their places and the chain reaches it, and the host's git reads the hooks or config inside (tested against real git). Each is therefore denied like any other hop, named once however many times the loop met it. A loop that stays inside the git directory needs nothing more: the whole-directory deny that a symlinked entry already brings covers its links. One that runs out through the work tree is held by the directories holding those links — and where THAT cannot be done, because such a directory is the working directory or a write root, Linux **refuses the command** (`deny_unresolvable_git_entry`), since the warning that stands in for a bind elsewhere leans on both ends of the chain being denied and here there is no far end. That refusal is what such a repository got before any of this existed, when the deny path simply could not be resolved. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. +- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either — as long as the chain reaches something, which is what leaves both its ends denied; a chain that reaches nothing through such a hop is refused instead (see above), unless it is a pointer's value, where the refusal would be the lockout it exists to avoid and the warning is the whole answer. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: @@ -804,12 +804,13 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: - `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. +- `deny_unresolvable_git_entry` — an entry of a git directory under the working directory is a symlink whose chain reaches nothing (a loop, or a name no file can occupy), and one of the links it goes through sits in the working directory or a path this command may write, where no read-only bind can hold it. There is no landing to deny instead, so the command is refused rather than run with the link free; the message names the entry, the link and the directory holding it. Repairing the chain, moving the links inside the git directory, or a `denyWrite` entry naming the directory holding them, each lifts it. A pointer's value that reaches nothing is NOT this: see the bullet above. - `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. - `deny_scan_failed` where a `.git/modules` tree takes longer to walk than the scan's own budget: a tree wide or slow enough is one a command can make, and the walk refuses rather than hand back a listing that stops somewhere unknown. Removing what makes it slow lifts it. On macOS the same condition is a `SubmoduleWalkBudgetError`, exported from the package root. - `too_many_arguments` where a command plants enough CHECKED-OUT submodules — a `.git` pointer file and the git directory it names — that the profile does not fit with every submodule deny degraded, measured at about 1475 of them. -A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. +A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. **A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: @@ -835,6 +836,8 @@ A directory the command makes unreadable does **not** refuse anything: it is den - any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and inside the scan's own time budget, not by reading the paths off stderr. - anything else, no code at all, or an `EACCES` the walk cannot find a directory for: the command is refused, with the raw stderr (truncated) in the message. +Those three read a run that exited on its own. A run that was KILLED is refused whatever its stderr held — the timeout, an OOM kill, a signal to the process group — because its listing stops wherever the kill arrived and nothing on stderr says what lay past that; the message names the signal, and says whether the run had used the time it was given. + A scan that could not be run at all and one that does not finish in time are refused too. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. From 4750e50f5aa44e3905782923877890744eca9541 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 10:25:42 +0000 Subject: [PATCH 059/141] README: the rest of what this round changed Three paragraphs the last commit should have carried and did not: `modules` among the entries checked for being a link, with what a command can make that cost; the rule that no whole-directory deny may cover the work tree, with the two answers that replace one; and, for the scan, which directory is denied when one is readable but not searchable, and what happens at the depth the scan stops at. The `.git/modules` half of the "a link that IS the git directory" limit goes with them: that one is held now. --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d6f36e1cc..62064f961 100644 --- a/README.md +++ b/README.md @@ -779,7 +779,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. A pointer or a `commondir` whose VALUE reaches nothing is not denied whole anywhere either — neither the directory it would fold to nor the deepest one still reachable towards it, which for a value like `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself. git opens nothing through such a value, and a `.git` file is something a sandboxed command is deliberately allowed to create, so failing closed there would let one command leave every command after it unable to write the project. What is denied is the pointer file and the links the walk did go through, with one `SRT_DEBUG` warning naming the file. -- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. +- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree`, `modules` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. +- **`modules` is held as a link, and is no deny path of its own.** A git directory keeps its submodules' git directories under `modules`, each with precise denies of its own, so denying that directory whole would take every one of them read-only. But it is an entry like the others in the one respect that matters: remove the link, put a directory of your own where it was, and every submodule git directory the repository keeps is one nothing denied - which is a hook the host's git runs. So it is held the way a chain hop is, by its own name where a filter matches the name a rename uses, and by the directory holding it - that git directory, denied whole - where a deny resolves. This applies to the top-level `.git/modules` as much as to a submodule's own. **What it costs, and that a command can arrange it:** an ordinary repository has no `.git/modules` at all, and an absent one is deliberately not denied (a mount there would stop `git submodule add`), so one `ln -s` inside the sandbox makes `.git` a git directory with a symlinked entry - and from the next wrap on `git add`, `git commit` and `git checkout -b` fail read-only in that repository, with nothing inside the sandbox able to remove the link. `git status`, `log` and `diff` keep working, and removing the link from outside the sandbox puts it back. That is the same family as the other costs a command can plant, and it is the price of holding the link at all. - **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. **A chain that reaches NOTHING is held by its hops alone.** A loop, or a chain longer than the kernel follows, has no landing: git gives up on it exactly as this does, so the deny the landing would get stands on nothing, and every link the walk visited before it gave up is the whole of what a command can move — put a directory in one of their places and the chain reaches it, and the host's git reads the hooks or config inside (tested against real git). Each is therefore denied like any other hop, named once however many times the loop met it. A loop that stays inside the git directory needs nothing more: the whole-directory deny that a symlinked entry already brings covers its links. One that runs out through the work tree is held by the directories holding those links — and where THAT cannot be done, because such a directory is the working directory or a write root, Linux **refuses the command** (`deny_unresolvable_git_entry`), since the warning that stands in for a bind elsewhere leans on both ends of the chain being denied and here there is no far end. That refusal is what such a repository got before any of this existed, when the deny path simply could not be resolved. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. - **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either — as long as the chain reaches something, which is what leaves both its ends denied; a chain that reaches nothing through such a hop is refused instead (see above), unless it is a pointer's value, where the refusal would be the lockout it exists to avoid and the warning is the whole answer. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. @@ -810,7 +811,11 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - `too_many_arguments` where a command plants enough CHECKED-OUT submodules — a `.git` pointer file and the git directory it names — that the profile does not fit with every submodule deny degraded, measured at about 1475 of them. -A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. **A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. +A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. + +**No whole-directory deny is ever emitted for the work tree.** Every one of them is a stand-in read off paths a sandboxed command can write - a `.git` pointer naming the checkout (`gitdir: .`, with one entry of the checkout a symlink, makes the checkout a git directory denied whole), a `.git/modules` entry linked at `../..`, a git directory entry pointing at the checkout, a directory left unreadable - and one that lands on the working directory, an ancestor of it, a write root or a directory holding one leaves every later command unable to write the project, with nothing inside the sandbox able to undo it. So: a directory that can be LISTED is dropped with a warning, since what the deny was holding keeps the deny paths of its own two ends; a directory that cannot be listed stands in for a tree nothing else covers, so the command is REFUSED instead (`deny_scan_failed`, naming the directory), which one `chmod` outside the sandbox lifts. The same call is made on macOS, where a literal entry is a subpath deny. + +**A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: @@ -822,7 +827,7 @@ A directory the command makes unreadable does **not** refuse anything: it is den - on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**; - on Linux, every write inside a directory that HOLDS a hop of such a chain — an entry's or a `gitdir:` pointer value's — for the same reason: with `hooks -> ../links/hop`, or with `gitdir: links/hop/gd`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. -**Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`. A `.git` that is itself a symlink to a git directory elsewhere, or a `.git/modules` that is, is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. +**Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`, `modules` itself included. A `.git` that is itself a symlink to a git directory elsewhere is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. **Known limit: a hook FILE that is a symlink into the work tree.** The install idiom `ln -s ../../scripts/pre-commit .git/hooks/pre-commit`, with `hooks` itself a real directory, is not one of the entries above: the hooks directory is denied, so no name can be added there and none re-linked, but the script the link leads to is ordinary project content, and its CONTENT is writable — directly and through the link — on every build. That is the sandbox doing its job: a tracked script in the work tree is exactly what a command is there to edit, and the sandbox has no way to tell the script the user chose to run as a hook from any other file in the repository. A `denyWrite` entry naming the script is the remedy where that matters. @@ -833,9 +838,11 @@ A directory the command makes unreadable does **not** refuse anything: it is den **Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: - every code `ENOENT`: entries that went away while the scan walked. Nothing is at those paths to deny, and the rest of the listing still counts. -- any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and inside the scan's own time budget, not by reading the paths off stderr. +- any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and inside the scan's own time budget, not by reading the paths off stderr. A directory that is readable but not SEARCHABLE (mode 0400) lists its entries and lets nothing through them, so it is that directory, not the child whose own listing failed, that is denied: naming the child would ask bubblewrap for a file mount at what is really a directory and fail every later wrap in the tree. - anything else, no code at all, or an `EACCES` the walk cannot find a directory for: the command is refused, with the raw stderr (truncated) in the message. +A directory that sits at the very depth the scan stops at is a case of its own: ripgrep's walker opens one to list it and reports it when it cannot, while this walk stops a level above, so such an EACCES is accounted for with nothing to deny - nothing below it is in the scan's reach on either side. Without that, a project holding a directory another user owns exactly that far down (a `docker/data/postgres`, mode 0700) had every command refused. + Those three read a run that exited on its own. A run that was KILLED is refused whatever its stderr held — the timeout, an OOM kill, a signal to the process group — because its listing stops wherever the kill arrived and nothing on stderr says what lay past that; the message names the signal, and says whether the run had used the time it was given. A scan that could not be run at all and one that does not finish in time are refused too. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. From bbb87cb79020da4cd264cc633b314a02193a2f74 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 15:54:49 +0000 Subject: [PATCH 060/141] linux: count wraps before releasing mount points, and harden the manifests Commands in parallel: cleanupBwrapMountPoints() released every manifest of the process, so the first of two overlapping commands to finish took the mount point from under the other's running sandbox. The process now counts its wraps and releases nothing of its own until cleanup has been called once per wrap, as on main. cleanupAfterCommand({ commandId }) releases that one command's manifest at once for callers that have an id per command. Also in this commit: - The per-user manifest directory is created and checked through a descriptor opened without following links, and its mode is set on the descriptor, so a link planted at the name is not followed or chmodded. - A manifest is written under a temporary name and linked into place, so a collector never reads half of one; leftovers are swept. - The collector reads the directory again after taking its lock, and the lock is created atomically and given up when its holder dies. - A manifest records its PID namespace; one from another namespace is always treated as live, because its pid means nothing here. - A mount point is removed only when it is still on the device it was created on and still looks like bubblewrap's work. - A removal that is refused keeps the manifest, so a later pass retries. - The manifest directory is bound read-only into the sandbox under both of its names, with --ro-bind-try so a missing one is not an error. - Every entry point returns at once off Linux. --- src/sandbox/bwrap-mount-manifests.ts | 1058 +++++++++++++++++++++----- src/sandbox/linux-sandbox-utils.ts | 258 +++++-- src/sandbox/sandbox-manager.ts | 24 +- 3 files changed, 1063 insertions(+), 277 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 3cdfd9dc2..a4733d2ed 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -25,9 +25,21 @@ * The lock belongs to the sandbox's init process, not to the command: bwrap * opens the manifest with O_CLOEXEC, so the descriptor does not survive the * exec into the command, and a POSIX lock is only dropped by the process that - * took it. The directory is bound read-only into every sandbox whose - * invocation names a manifest in it, so a sandboxed command can neither - * delete nor rewrite one. + * took it. The directory is bound read-only into every sandbox that + * restricts writes at all, whether or not its own invocation names a manifest + * in it, and so is the other name a process of the same user would keep its + * manifests under (see {@link mountPointManifestDirectories}), so a sandboxed + * command can neither delete nor rewrite one, its own or another sandbox's. + * What that leaves out is a sandbox this library did not start, or an older + * release of it did, with the directory writable, and a process that looks + * for its runtime or temp directory somewhere this one does not. + * + * Both of the kernel's answers are relative to a PID namespace: /proc/locks + * lists a lock only when its holder has a pid in the namespace of the /proc + * being read, and /proc/PID is local to it. A manifest therefore says which + * PID namespace wrote it, and a process in another one does not judge it at + * all: to that process it is live, and only a process in the namespace that + * wrote it, which can see both answers, ever collects what it names. */ import { randomBytes } from 'node:crypto' @@ -61,11 +73,43 @@ const DIRECTORY_LOCK_WAIT_MS = 2_000 const DIRECTORY_LOCK_RETRY_MS = 25 /** - * A directory lock this old is broken, for a process that died between taking - * it and the unlink. Far past the milliseconds a collect takes. + * A directory lock this old is broken whoever holds it, a holder that is + * still running included: far past the milliseconds a collect takes. It is + * the only way out for a holder that cannot be asked after, one in another + * PID namespace or one whose lock says nothing this process can read. A + * holder in this namespace is asked after directly, and the lock of one that + * is gone is broken at once, whatever its age. */ const DIRECTORY_LOCK_STALE_MS = 60_000 +/** + * How many mount points a pass removes on one look at /proc/locks, and for how + * long one look is good. Each look reads all of /proc/locks, whose size is the + * host's and not this library's, so one per removal made a pass over many + * mount points on a busy host outlast the wait above. A removal takes some ten + * microseconds, and a sandbox takes several milliseconds, about four on a fast + * machine, to get from its manifest being published to its binds being in + * place (the wrap returns, the caller spawns a shell, bubblewrap sets up its + * namespaces), so a look shared by this many removals, under a millisecond's + * worth, is as good as one each. The time limit is for a pass that is held up + * between two removals, which then looks again before the next, and is kept + * under that start-up time. + */ +const REMOVALS_PER_LOOK = 64 +const LOCKS_GOOD_FOR_MS = 2 + +/** + * For how long one listing of the manifest directory is good. A manifest + * published for a sandbox that is about to start is the usual way for a pass + * to be out of date, and listing a directory of a few manifests costs less + * than one removal, so this is kept an order of magnitude under the time that + * sandbox needs to get to its binds, and not at the two milliseconds above. It + * is a time and not "before every removal" so that a directory of thousands of + * manifests, which takes milliseconds to list, is not listed thousands of + * times. + */ +const LISTING_GOOD_FOR_MS = 0.25 + /** * A manifest whose contents cannot be read is dropped once it is this old and * no sandbox holds its lock — long past anything that could still be using @@ -74,11 +118,25 @@ const DIRECTORY_LOCK_STALE_MS = 60_000 */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 +/** + * What a manifest, and the directory lock, is written as before it is moved + * into place. A process killed between the two leaves the file behind, and a + * pass removes one that is as old as an unreadable manifest has to be. + */ +const TEMPORARY_SUFFIX = '.tmp' + const ManifestSchema = z.object({ version: z.literal(MANIFEST_VERSION), /** The process that wrapped, and its start time, to tell a recycled pid. */ pid: z.number().int().nonnegative(), start: z.string(), + /** + * The PID namespace the wrapping process was in, as `readlink + * /proc/self/ns/pid` names it. `pid` and the lock bubblewrap takes can only + * be asked after from there. Absent from a manifest written before the field + * existed, which is then from a namespace nobody can name. + */ + ns: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ created: z.number(), /** The mount points bwrap makes on the host for that wrap's deny paths. */ @@ -95,22 +153,59 @@ const ManifestSchema = z.object({ type Manifest = z.infer & { file: string - /** The inode /proc/locks reports a lock on. */ - inode: number + /** The inode /proc/locks reports a lock on, and the device it is on. */ + inode: string + device: string /** - * Whether this process has taken the manifest away: it wrote it, the caller - * says the command is done, and nothing held the lock. Only a lock can still - * make it live, and only for the moment between that reading and the unlink - * — the path is gone, so no further sandbox can open it. + * Whether this process has given the manifest up: it wrote it, the caller + * says the command is done, and nothing held the lock when the pass began. + * Its writer is running and it may be young, and neither keeps it: only a + * lock can still make it live. The file stays where it is until the end of + * the pass, like any other finished manifest's. */ released: boolean } -/** Manifests this process wrote and has not released. */ -const ownManifests = new Set() +/** + * Manifests this process wrote and that are still on disk as far as it knows, + * each with the key of the command it was wrapped for where the caller gave + * the wrap one, and whether the caller has said that command is over. That is + * remembered, not acted on at once: a pass can turn back half way, a removal + * can be refused, a sandbox can still hold the lock, and in each case the + * manifest has to stay on disk, since it is all that names the mount points, + * and go at a later pass whatever that pass is told to release. + */ +const ownManifests = new Map< + string, + { commandKey: string | undefined; over: boolean } +>() + +/** + * Which of this process's own manifests a collect may release. Releasing one + * says "the command this was wrapped for is over", which only the caller + * knows, so it is the caller that says which: + * - `all`: no wrap of this process is outstanding, or the process is ending; + * - `none`: some are, and nothing says which of them is over, so only what + * OTHER processes have finished with is taken away; + * - one command: that command is over, whatever else is still running. + * Releasing the manifest of a wrap whose command has not started yet is what + * must never happen: bubblewrap opens the manifest to lock it, and a command + * whose manifest is gone refuses to start, or, if it got its lock in the + * moment between the pass's last look at /proc/locks and the unlink, runs on + * with nothing on disk naming its mount points. + */ +export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } let manifestDirectory: string | undefined +// Whether that is the last resort below, which no other process knows of. +let manifestDirectoryIsPrivate = false let directoryFailureLogged = false +// Set once the last resort has been tried and found wanting, so a process on a +// temp dir that keeps no modes does not make a fresh directory on every call. +let manifestDirectoryUnavailable = false + +/** Everything here is for bubblewrap, which is Linux's. */ +const onLinux = (): boolean => process.platform === 'linux' /** * Field 22 of /proc/PID/stat, the process's start time in clock ticks. The @@ -148,19 +243,95 @@ function writerIsRunning(pid: number, start: string): boolean { } } +let pidNamespace: string | undefined | null = null + +/** + * The PID namespace this process is in, as the kernel names it + * (`pid:[4026531836]`), or `undefined` where it cannot be told. + */ +function ownPidNamespace(): string | undefined { + if (pidNamespace === null) { + try { + pidNamespace = fs.readlinkSync('/proc/self/ns/pid') + } catch { + pidNamespace = undefined + } + } + return pidNamespace +} + +/** + * The device half of what /proc/locks prints for a file, from the number stat + * gives for it: `major:minor`, in decimal. + */ +function deviceOf(dev: bigint): string { + const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) + const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) + return `${major}:${minor}` +} + +/** + * The filesystems on which the device stat reports for a file is the one + * /proc/locks prints for a lock on it: both are the superblock's. It is not so + * everywhere - a btrfs subvolume and some overlay arrangements give stat a + * device of their own - and there only the inode number can be compared. + */ +const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ + 0x01021994, // tmpfs, which is what $XDG_RUNTIME_DIR is + 0xef53, // ext2, ext3, ext4 + 0x58465342, // xfs +]) + +function reportsTheLockedDevice(dir: string): boolean { + try { + return FILESYSTEMS_WITH_ONE_DEVICE.has(Number(fs.statfsSync(dir).type)) + } catch { + return false + } +} + +/** The locks /proc/locks lists, of any kind, by what they are on. */ +type Locks = { + /** Every inode number with a lock on it, whatever filesystem it is on. */ + inodes: Set + /** The same locks as `major:minor:inode`, in decimal. */ + files: Set + /** Whether a manifest's device can be held against `files`. */ + comparesDevices: boolean +} + +/** + * Whether /proc/locks lists a lock on this file. Any lock on a manifest is the + * one bubblewrap's sandbox init holds: nothing else opens one to lock it. + * + * The device is compared where the manifests' filesystem reports the one + * /proc/locks prints, and only there. Inode numbers are per filesystem, and on + * a tmpfs they are small and handed out in order, so a manifest in a young + * runtime directory can share its number with some unrelated locked file on + * another filesystem; it then read as live for as long as that lock was held, + * and every path it named as a mount point for every later wrap. + * Where the two devices cannot be compared the inode number alone decides, + * which errs towards "locked": the other way round would hide a live + * sandbox's lock. + */ +function holdsLock( + locks: Locks, + file: { inode: string; device: string }, +): boolean { + return locks.comparesDevices + ? locks.files.has(`${file.device}:${file.inode}`) + : locks.inodes.has(file.inode) +} + /** - * The inodes /proc/locks reports a lock on, of any kind. Any lock on a - * manifest is the one bubblewrap's sandbox init holds — nothing else opens one - * to lock it — and the device is deliberately not compared: a filesystem whose - * /proc/locks device differs from the one stat reports (an overlay, a bind of - * a subvolume) would otherwise hide a live sandbox's lock, while an inode - * number colliding with an unrelated locked file's only keeps a mount point - * around longer. + * What /proc/locks lists, for the manifests in `dir`. Only the locks whose + * holder has a pid in the PID namespace of the /proc being read are listed, + * which is why a manifest from another namespace is never judged by this. * * `undefined` when the list could not be read, which every caller reads as * "every manifest is locked". */ -function lockedInodes(): Set | undefined { +function readLocks(dir: string): Locks | undefined { let text: string try { text = fs.readFileSync('/proc/locks', 'utf8') @@ -171,55 +342,121 @@ function lockedInodes(): Set | undefined { ) return undefined } - const inodes = new Set() + const inodes = new Set() + const files = new Set() for (const line of text.split('\n')) { // The major:minor:inode word, which sits one field later on the lines that // describe a blocked request ("2: -> POSIX ADVISORY WRITE ..."). for (const word of line.split(/\s+/)) { - const match = /^[0-9a-f]+:[0-9a-f]+:(\d+)$/.exec(word) - if (match?.[1] !== undefined) { - inodes.add(Number(match[1])) + const match = /^([0-9a-f]+):([0-9a-f]+):(\d+)$/.exec(word) + if (match !== null) { + const inode = String(BigInt(match[3]!)) + inodes.add(inode) + files.add( + `${parseInt(match[1]!, 16)}:${parseInt(match[2]!, 16)}:${inode}`, + ) break } } } - return inodes + return { inodes, files, comparesDevices: reportsTheLockedDevice(dir) } } -/** Whether `dir` is a directory of ours that nobody else can write. */ +/** Whether `dir` is a directory of ours that nobody else can write, and we can. */ function isOurPrivateDirectory(dir: string): boolean { try { const stat = fs.lstatSync(dir) - return ( - stat.isDirectory() && - stat.uid === process.getuid?.() && - (stat.mode & 0o077) === 0 - ) + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 + ) { + return false + } + // Ours, and no use if it cannot be written: a directory an outer sandbox + // binds read-only reads as ours in every other respect. + fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) + return true } catch { return false } } +/** + * Make `dir` a directory of ours alone, or say it cannot be. + * + * The name is predictable and sits where sandboxed commands commonly write, + * so what is found there is looked at before it is touched: through a + * descriptor opened without following a link, so that a symlink planted at + * the name is refused rather than having its target's mode changed, and the + * mode is set on that descriptor, never on the path. + */ function makeOurPrivateDirectory(dir: string): boolean { try { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + fs.mkdirSync(dir, { mode: 0o700 }) + } catch { + // There already, or cannot be made: the look below decides. + } + let fd: number | undefined + try { + fd = fs.openSync( + dir, + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW, + ) + const stat = fs.fstatSync(fd) + if (!stat.isDirectory() || stat.uid !== process.getuid?.()) { + return false + } // mkdir asks for 0700 but the umask applies, and a directory an earlier - // run left keeps the mode it was made with, so set it here rather than let - // the check below reject a directory that is ours. - fs.chmodSync(dir, 0o700) + // run left keeps the mode it was made with. + if ((stat.mode & 0o777) !== 0o700) { + fs.fchmodSync(fd, 0o700) + } } catch { - // Someone else's directory at that name, or a filesystem that will not - // take one: the check below decides. + return false + } finally { + if (fd !== undefined) fs.closeSync(fd) } return isOurPrivateDirectory(dir) } +/** + * Whether a manifest kept under `dir` would be out of bubblewrap's reach: the + * wrap mounts a fresh /dev and /proc after every bind, which buries whatever + * was bound beneath them, and bubblewrap opens the manifest after its mounts. + */ +function isBuriedBySandboxMounts(dir: string): boolean { + return ['/dev', '/proc', '/sys'].some( + root => dir === root || dir.startsWith(`${root}/`), + ) +} + +/** + * The names under which the processes of this user keep their manifests where + * several can find them, in the order they are tried: under $XDG_RUNTIME_DIR + * when there is one, else under the system temp dir. One that a sandbox's own + * mounts would bury is left out. + */ +function sharedManifestDirectoryNames(): string[] { + const runtimeDir = process.env['XDG_RUNTIME_DIR'] + const shared = + runtimeDir !== undefined && path.isAbsolute(runtimeDir) + ? [path.join(runtimeDir, 'srt-mount-points')] + : [] + shared.push( + path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), + ) + return shared.filter(candidate => !isBuriedBySandboxMounts(candidate)) +} + /** * The directory manifests live in: under $XDG_RUNTIME_DIR when there is one, - * else a per-user directory under the system temp dir, else — when neither can - * be made ours alone, as in a sandbox that binds them read-only — a private - * directory only this process knows, which keeps the guarantee within this - * process and loses only what other processes would have read. + * else a per-user directory under the system temp dir, else - when neither can + * be made ours alone and written, as in a sandbox that binds them read-only - + * a private directory only this process knows, which keeps the guarantee + * within this process and loses only what other processes would have read. * * Revalidated on every use: the temp dir is somewhere sandboxed commands * commonly write, and a directory swapped for a symlink between two wraps @@ -232,31 +469,38 @@ function ensureManifestDirectory(): string | undefined { ) { return manifestDirectory } - const runtimeDir = process.env['XDG_RUNTIME_DIR'] - const shared = - runtimeDir !== undefined && path.isAbsolute(runtimeDir) - ? [path.join(runtimeDir, 'srt-mount-points')] - : [] - shared.push( - path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), - ) - for (const candidate of shared) { + manifestDirectoryIsPrivate = false + for (const candidate of sharedManifestDirectoryNames()) { if (makeOurPrivateDirectory(candidate)) { manifestDirectory = candidate return candidate } } + manifestDirectory = undefined + if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(tmpdir())) { + return undefined + } try { const private_ = fs.mkdtempSync(path.join(tmpdir(), 'srt-mount-points-')) - fs.chmodSync(private_, 0o700) + if (!makeOurPrivateDirectory(private_)) { + // A temp dir that keeps no modes or ownership: nothing made in it can be + // told from somebody else's, now or on the next call. + try { + fs.rmdirSync(private_) + } catch { + // Left; it is empty. + } + throw new Error(`${private_} cannot be made private`) + } manifestDirectory = private_ + manifestDirectoryIsPrivate = true logForDebugging( `[Sandbox Linux] No shared directory for mount point manifests, using one only this process knows: ${private_}`, { level: 'warn' }, ) return private_ } catch (e) { - manifestDirectory = undefined + manifestDirectoryUnavailable = true if (!directoryFailureLogged) { directoryFailureLogged = true logForDebugging( @@ -268,103 +512,268 @@ function ensureManifestDirectory(): string | undefined { } } +/** Whether `dir` is a real directory, not a link to one, and the user's. */ +function isOurDirectory(dir: string): boolean { + try { + const stat = fs.lstatSync(dir) + return stat.isDirectory() && stat.uid === process.getuid?.() + } catch { + return false + } +} + +/** + * Every directory a sandbox must not be able to write, because some process + * of this user keeps manifests in it and a collect on the host believes what + * it finds there: both shared names, as this process sees them, and the one + * this process has settled on where that is neither. For a wrap to bind + * read-only, whether or not it has a manifest of its own. + * + * Both names, and not only the one this process uses: a process started with + * $XDG_RUNTIME_DIR uses the first and one started without it (from cron, over + * a plain ssh, as a service) the second, and a sandbox of the first kind with + * the temp dir writable could otherwise delete and forge the manifests of the + * second. Each is made if it can be, so that a sandboxed command cannot make + * it first and fill it before the process that will believe it comes along; + * one that is there and the user's is listed even when it cannot be written + * from here. The last resort is not made for this: there is nothing to keep + * out of reach in a directory nobody has made. + */ +export function mountPointManifestDirectories(): string[] { + if (!onLinux()) { + return [] + } + const dirs = new Set() + for (const candidate of sharedManifestDirectoryNames()) { + makeOurPrivateDirectory(candidate) + if (isOurDirectory(candidate)) { + dirs.add(candidate) + } + } + if (manifestDirectory !== undefined && isOurDirectory(manifestDirectory)) { + dirs.add(manifestDirectory) + } + return [...dirs] +} + +/** + * Take away the directory only this process knows, once nothing is left in + * it, when the process or its session is ending: nobody else would. With the + * manifest of a sandbox that is still running in it, it stays. + */ +export function removePrivateManifestDirectory(): void { + if (manifestDirectory === undefined || !manifestDirectoryIsPrivate) { + return + } + try { + fs.rmdirSync(manifestDirectory) + } catch { + // Not empty, or gone already. + return + } + manifestDirectory = undefined + manifestDirectoryIsPrivate = false +} + /** Block this thread, the only sleep available to a synchronous cleanup. */ function sleep(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -/** Break a directory lock whose holder is gone, or that is simply too old. */ -function breakStaleDirectoryLock(lockFile: string): boolean { +/** + * What a process leaves in the directory lock: who it is, and the PID + * namespace in which that can be asked after. + */ +function directoryLockContent(): string { + return `${process.pid} ${processStartTime(process.pid) ?? '?'} ${ownPidNamespace() ?? '?'}\n` +} + +/** + * Take the directory lock, and say what it now holds, or why it was not taken: + * `held` when something is at its name already, `unavailable` when this + * process cannot make one there at all. + * + * The lock comes into being whole. Made with O_EXCL and written to afterwards + * it stood empty for a moment, and a waiter that read it then found no holder + * in it and removed it at once, so two processes were inside together. It is + * written under another name and linked into place instead: link fails when + * the name is taken, exactly as O_EXCL does, and what it puts there is never + * seen half made. A filesystem without hard links gets no lock, and no pass. + */ +function takeDirectoryLock( + dir: string, + lockFile: string, +): { content: string } | 'held' | 'unavailable' { + const content = directoryLockContent() + const temporary = path.join( + dir, + `directory.lock.${process.pid}.${randomBytes(8).toString('hex')}${TEMPORARY_SUFFIX}`, + ) + try { + fs.writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' }) + } catch { + return 'unavailable' + } + try { + fs.linkSync(temporary, lockFile) + return { content } + } catch (e) { + return (e as NodeJS.ErrnoException).code === 'EEXIST' + ? 'held' + : 'unavailable' + } finally { + try { + fs.unlinkSync(temporary) + } catch { + // Gone with the directory. + } + } +} + +/** + * Give the directory lock up, if it is still this process's. One broken for + * its age while this process was held up belongs to whoever took it next, and + * removing that one by name let a third process in beside it. + */ +function releaseDirectoryLock(lockFile: string, content: string): void { + try { + if (fs.readFileSync(lockFile, 'utf8') === content) { + fs.unlinkSync(lockFile) + } + } catch { + // Broken by another process while we held it: nothing to undo. + } +} + +/** + * What stands at the directory lock's name, once taking it has failed: + * - `free`: nothing any more, or the lock of a holder that is gone, removed; + * - `held`: somebody's lock, to be waited for; + * - `stuck`: something this process can neither read as a lock nor remove, + * which no amount of waiting changes. + * + * Breaking a lock is three steps - read who holds it, ask after them, unlink - + * and not one, so two waiters can both find the same holder gone and the + * slower one's unlink can land on the lock a third process has taken since. + * Nothing here prevents that. It is why nothing rests on this lock: see + * {@link withDirectoryLock}. + */ +function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { let holder: string let age: number try { - const fd = fs.openSync(lockFile, 'r') + // Looked at before it is opened, and opened without blocking or following + // a link: opening a FIFO to read waits for a writer, and nothing but a + // regular file is a lock this library made. + if (!fs.lstatSync(lockFile).isFile()) { + return 'stuck' + } + const fd = fs.openSync( + lockFile, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW, + ) try { - age = Date.now() - fs.fstatSync(fd).mtimeMs + const stat = fs.fstatSync(fd) + if (!stat.isFile()) { + return 'stuck' + } + age = Date.now() - stat.mtimeMs holder = fs.readFileSync(fd, 'utf8') } finally { fs.closeSync(fd) } - } catch { - // Gone between the failed create and this read: the next attempt takes it. - return true + } catch (e) { + // Gone between the failed attempt and this look: the next attempt takes + // it. Anything else is a file that is there and cannot be read. + return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } - const [pid, start] = holder.trim().split(' ') + // A holder can be asked after only in the PID namespace it named, when that + // is this one: elsewhere its pid means nothing, or means some other + // process. One that cannot be asked after is believed until the lock is + // older than any pass, as is one that did not say who it is. + const who = /^(\d+) (\d+) (\S+)\n$/.exec(holder) const held = - pid !== undefined && - start !== undefined && - writerIsRunning(Number(pid), start) && - age < DIRECTORY_LOCK_STALE_MS + age < DIRECTORY_LOCK_STALE_MS && + (who === null || + who[3] !== ownPidNamespace() || + writerIsRunning(Number(who[1]), who[2]!)) if (held) { - return false + return 'held' } try { fs.unlinkSync(lockFile) - } catch { - // Another process broke it first. + } catch (e) { + // Another process broke it first; or it cannot be removed from here, in + // which case it is still there and the next attempt would find it so. + return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } - return true + return 'free' } /** - * Run `body` while holding the manifest directory's lock, so a collect works - * from a set of manifests no other srt process is changing under it, and - * report whether it ran. + * Run `body` while holding the manifest directory's lock, and report whether + * it ran, and if not, whether that was for somebody `held` it all the while. + * It gives up, without running `body`, after {@link DIRECTORY_LOCK_WAIT_MS} of + * finding the lock held, and at once when what is at the lock's name is + * nothing it could ever take: the wait is this thread's, in the wrap, in the + * clean-up after every command and in the exit handler, and nothing about a + * lock is worth more of it than that. * - * The lock serialises srt processes; it is not what makes a mount point safe - * to remove. That is the kernel's answer about each manifest, re-read - * immediately before every unlink, which is also what covers the one actor - * outside this lock: the bubblewrap a wrap has already handed to its caller. + * The lock keeps srt processes out of each other's way most of the time; it + * does not exclude, and nothing may rest on it. Its holder can be held up past + * the age at which it is broken, the break itself is not atomic (see + * {@link examineDirectoryLock}), and a publish that cannot have the lock goes + * ahead without it. What makes a mount point safe to remove is looked at + * again immediately before the removals and at short intervals during them: + * the directory, for a manifest that was not there when the pass began, and + * the kernel's answer about every manifest the pass knows of (see + * {@link collectUnderLock}). */ function withDirectoryLock( dir: string, body: () => T, -): { ran: true; value: T } | { ran: false } { +): { ran: true; value: T } | { ran: false; held: boolean } { const lockFile = path.join(dir, 'directory.lock') const deadline = Date.now() + DIRECTORY_LOCK_WAIT_MS for (;;) { - let fd: number - try { - fd = fs.openSync(lockFile, 'wx', 0o600) - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'EEXIST') { - return { ran: false } - } - if (!breakStaleDirectoryLock(lockFile) && Date.now() >= deadline) { - return { ran: false } + const taken = takeDirectoryLock(dir, lockFile) + if (taken === 'unavailable') { + return { ran: false, held: false } + } + if (taken !== 'held') { + try { + return { ran: true, value: body() } + } finally { + releaseDirectoryLock(lockFile, taken.content) } - sleep(DIRECTORY_LOCK_RETRY_MS) - continue } - try { - fs.writeSync( - fd, - `${process.pid} ${processStartTime(process.pid) ?? ''}\n`, + if (examineDirectoryLock(lockFile) === 'stuck') { + logForDebugging( + `[Sandbox Linux] ${lockFile} is not a lock this process can take or break - going on without it`, + { level: 'warn' }, ) - } catch { - // A holder that cannot be identified is broken by age alone. - } finally { - fs.closeSync(fd) + return { ran: false, held: false } } - try { - return { ran: true, value: body() } - } finally { - try { - fs.unlinkSync(lockFile) - } catch { - // Broken by another process while we held it: nothing to undo. - } + // On every round, whatever the look above found: a lock that is broken + // and taken again by others for as long as this process keeps asking is + // as good as held. + if (Date.now() >= deadline) { + return { ran: false, held: true } } + sleep(DIRECTORY_LOCK_RETRY_MS) } } function readManifest(file: string): Manifest | undefined { - let inode: number + let inode: string + let device: string let text: string try { const fd = fs.openSync(file, 'r') try { - inode = fs.fstatSync(fd).ino + const stat = fs.fstatSync(fd, { bigint: true }) + inode = String(stat.ino) + device = deviceOf(stat.dev) text = fs.readFileSync(fd, 'utf8') } finally { fs.closeSync(fd) @@ -380,24 +789,62 @@ function readManifest(file: string): Manifest | undefined { } const parsed = ManifestSchema.safeParse(json) return parsed.success - ? { ...parsed.data, file, inode, released: false } + ? { ...parsed.data, file, inode, device, released: false } : undefined } -/** Whether a sandbox that named this manifest may still be running. */ -function isLive(manifest: Manifest, locked: Set): boolean { - if (locked.has(manifest.inode)) { +/** + * Whether a sandbox that named this manifest may still be running, as far as + * THIS process can tell. + * + * It can tell only about a manifest written in its own PID namespace: the + * writer's pid and the sandbox's lock are both invisible from any other. So a + * manifest from another namespace is live here whatever /proc says, for good: + * what it names is collected by a process in the namespace that wrote it, and + * by nobody if that namespace has gone, which leaves empty files behind and + * opens nothing. One that does not say where it is from is live while a + * process with its writer's pid and start time can be seen, and otherwise + * until it is as old as an unreadable manifest has to be to be dropped. + */ +function isLive(manifest: Manifest, locks: Locks): boolean { + if (holdsLock(locks, manifest)) { return true } if (manifest.released) { return false } + const here = ownPidNamespace() + if (manifest.ns === undefined || here === undefined) { + return ( + writerIsRunning(manifest.pid, manifest.start) || + Date.now() - manifest.created < UNREADABLE_MANIFEST_MAX_AGE_MS + ) + } + if (manifest.ns !== here) { + return true + } return ( Date.now() - manifest.created < MANIFEST_GRACE_MS || writerIsRunning(manifest.pid, manifest.start) ) } +/** + * What became of a path a pass set out to remove: `left` when there is + * nothing more to do about it - it is gone, or is no longer what bubblewrap or + * this library made - and `failed` when it is still that and could not be + * removed from here, which a later pass, or another process, may yet manage. + */ +type Removal = 'removed' | 'left' | 'failed' + +/** Whether a failed removal means the path is no longer ours to remove. */ +function meansNothingToRemove(e: unknown): boolean { + const code = (e as NodeJS.ErrnoException | undefined)?.code + // Not empty after all: something was written into it between the look and + // the rmdir. + return isAbsenceErrno(e) || code === 'ENOTEMPTY' || code === 'EEXIST' +} + /** * Remove an empty directory a placeholder bound from, if it is still our own. * It sits under the system temp dir, which sandboxed commands commonly can @@ -405,7 +852,7 @@ function isLive(manifest: Manifest, locked: Set): boolean { * symlink, a directory whose mode has been widened, one with something in it — * is somebody else's and is left exactly as found. */ -function removeMountSource(source: string): boolean { +function removeMountSource(source: string): Removal { try { const stat = fs.lstatSync(source) if ( @@ -417,40 +864,85 @@ function removeMountSource(source: string): boolean { logForDebugging( `[Sandbox Linux] Left the empty-directory mount source behind, it is no longer our own empty directory: ${source}`, ) - return false + return 'left' } fs.rmdirSync(source) logForDebugging( `[Sandbox Linux] Cleaned up the empty-directory mount source: ${source}`, ) - return true + return 'removed' + } catch (e) { + if (meansNothingToRemove(e)) { + return 'left' + } + logForDebugging( + `[Sandbox Linux] Could not remove the empty-directory mount source (${String(e)}): ${source}`, + ) + return 'failed' + } +} + +/** + * Whether `p` still has the shape of a file bubblewrap made to bind onto: a + * regular file (not a link to one), empty, with no write bit, under one name, + * and ours. A manifest is only a claim about a path, and the directory it + * sits in is only as private as its host, so nothing is removed on a + * manifest's word that does not also look like what bubblewrap leaves. + */ +export function isBwrapFileMountPoint(p: string): boolean { + try { + const stat = fs.lstatSync(p) + return ( + stat.isFile() && + stat.size === 0 && + (stat.mode & 0o222) === 0 && + stat.nlink === 1 && + stat.uid === process.getuid?.() + ) } catch { - // Gone, or no longer ours to look at. + return false } - return false } /** - * Remove a mount point, if it is still the empty file or directory bwrap made. - * Anything with content, or a directory something has written into, is left - * where it is. + * What kind of mount point the path a live manifest names still is, by what + * is there now: an empty regular file, a directory, or neither any more. A + * manifest says a path was a mount point when it was written; a file that has + * been written to since, or a link put in its place, is somebody's own. */ -function removeMountPoint(mountPoint: string): boolean { +export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { try { - const stat = fs.statSync(mountPoint) + const stat = fs.lstatSync(p) if (stat.isFile() && stat.size === 0) { + return 'file' + } + return stat.isDirectory() ? 'directory' : undefined + } catch { + return undefined + } +} + +/** + * Remove a mount point, if it is still the empty file or directory bwrap made. + * Anything else - a file with content or a write bit, a link, a directory + * something has written into or that is not ours - is left where it is. + */ +function removeMountPoint(mountPoint: string): Removal { + try { + if (isBwrapFileMountPoint(mountPoint)) { fs.unlinkSync(mountPoint) logForDebugging( `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, ) - return true + return 'removed' } - if (stat.isDirectory()) { + const stat = fs.lstatSync(mountPoint) + if (stat.isDirectory() && stat.uid === process.getuid?.()) { if (fs.readdirSync(mountPoint).length > 0) { logForDebugging( `[Sandbox Linux] Left a bwrap mount point directory behind, something has written into it: ${mountPoint}`, ) - return false + return 'left' } // rmdir, not a recursive remove: it neither follows a symlink nor // descends, so a path that is no longer ours is left exactly as found. @@ -458,12 +950,21 @@ function removeMountPoint(mountPoint: string): boolean { logForDebugging( `[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`, ) - return true + return 'removed' } - } catch { - // Already gone, or no longer ours to look at. + logForDebugging( + `[Sandbox Linux] Left a path a manifest names where it is, it no longer looks like a bwrap mount point: ${mountPoint}`, + ) + return 'left' + } catch (e) { + if (meansNothingToRemove(e)) { + return 'left' + } + logForDebugging( + `[Sandbox Linux] Could not remove a bwrap mount point (${String(e)}): ${mountPoint}`, + ) + return 'failed' } - return false } /** What {@link publishMountPointManifest} wrote, for the bwrap invocation. */ @@ -480,14 +981,19 @@ export type MountPointManifest = { * to point bubblewrap's --lock-file. * * `undefined` when there is nothing to record, or when no manifest could be - * written — in which case the caller must not track those mount points at all: - * what this process cannot record, no process may remove. + * written, in which case the caller must not track those mount points at all: + * what this process cannot record it does not remove. That is as far as it + * goes. Nothing on disk then says a sandbox relies on them, so a wrap in + * another process that denies the same path takes the file for a leftover, + * covers it, names it, and removes it after its own command, whether or not + * the sandbox that could not be recorded is still running. */ export function publishMountPointManifest( mountPoints: readonly string[], sources: readonly string[], + commandKey?: string, ): MountPointManifest | undefined { - if (mountPoints.length === 0 && sources.length === 0) { + if (!onLinux() || (mountPoints.length === 0 && sources.length === 0)) { return undefined } const dir = ensureManifestDirectory() @@ -498,20 +1004,24 @@ export function publishMountPointManifest( dir, `${process.pid}-${randomBytes(8).toString('hex')}${MANIFEST_SUFFIX}`, ) - const temporary = `${file}.tmp` + const temporary = `${file}${TEMPORARY_SUFFIX}` const body: z.infer = { version: MANIFEST_VERSION, pid: process.pid, start: processStartTime(process.pid) ?? '', + ns: ownPidNamespace(), created: Date.now(), paths: [...new Set(mountPoints)], sources: [...new Set(sources)], } // Written beside the manifest and renamed onto it, so a collect in another - // process reads it whole or not at all. Taking the directory lock for the - // rename keeps a collect from running between the manifests it listed and - // its unlinks; publishing without the lock is still sound, because the - // rename is atomic and the sandbox this manifest covers cannot have started. + // process reads it whole or not at all. The rename is made under the + // directory lock where that can be had, which keeps it out of the middle of + // most passes, and without it where it cannot: a wrap is not refused for + // want of a lock. Either way a pass may already have listed the directory, + // so what keeps this wrap's mount points is that every pass lists it again + // before it removes anything (see collectUnderLock), and that the sandbox + // this manifest covers has not started yet. try { fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) const publish = (): void => fs.renameSync(temporary, file) @@ -530,7 +1040,7 @@ export function publishMountPointManifest( ) return undefined } - ownManifests.add(file) + ownManifests.set(file, { commandKey, over: false }) return { dir, file } } @@ -556,9 +1066,12 @@ export function discardMountPointManifest(file: string): void { */ export function liveMountPoints(): Set { const live = new Set() + if (!onLinux()) { + return live + } const dir = ensureManifestDirectory() - const locked = dir === undefined ? undefined : lockedInodes() - if (dir === undefined || locked === undefined) { + const locks = dir === undefined ? undefined : readLocks(dir) + if (dir === undefined || locks === undefined) { return live } let names: string[] @@ -570,7 +1083,7 @@ export function liveMountPoints(): Set { for (const name of names) { if (!name.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(path.join(dir, name)) - if (manifest !== undefined && isLive(manifest, locked)) { + if (manifest !== undefined && isLive(manifest, locks)) { for (const mountPoint of manifest.paths) { live.add(mountPoint) } @@ -580,11 +1093,29 @@ export function liveMountPoints(): Set { } /** - * Release the manifests of this process's finished wraps and remove every - * mount point no live manifest names, from any process, at any time, any - * number of times. Returns the mount points removed. + * Release the manifests of the wraps of this process that `release` says are + * over, and remove every mount point no live manifest names, from any process, + * at any time, any number of times. Returns the mount points removed. Does + * nothing anywhere but on Linux. */ -export function collectMountPoints(): string[] { +export function collectMountPoints( + release: OwnManifestRelease = 'all', +): string[] { + if (!onLinux()) { + return [] + } + // What the caller says about its own commands is taken down first and kept, + // whatever becomes of this pass: one that cannot have the lock, or turns + // back half way, leaves the manifests for the next, which has to know they + // are done with even when it is told to release nothing. + for (const own of ownManifests.values()) { + if ( + release === 'all' || + (release !== 'none' && own.commandKey === release.commandKey) + ) { + own.over = true + } + } const dir = ensureManifestDirectory() if (dir === undefined) { return [] @@ -592,13 +1123,44 @@ export function collectMountPoints(): string[] { const collected = withDirectoryLock(dir, () => collectUnderLock(dir)) if (!collected.ran) { logForDebugging( - `[Sandbox Linux] Another process holds the mount point directory, leaving this pass to it: ${dir}`, + collected.held + ? `[Sandbox Linux] The lock on the mount point directory could not be had, leaving this pass to whoever has it: ${dir}` + : `[Sandbox Linux] No lock can be taken on the mount point directory from here, so nothing is collected from it until that is put right: ${dir}`, ) return [] } return collected.value } +/** + * One pass, which the caller makes under the directory lock where it can. + * + * Nothing is taken off the disk until the end. A manifest is all that names + * its mount points, so it goes last, after them, and only when every one of + * them is gone or is no longer a mount point: a pass that turns back half way, + * or a removal that is refused, leaves the manifest for a later pass to go by. + * That holds for the manifests of this process as for anybody's. The ones the + * caller has said are over are judged as if their writer were gone, and that + * is all that sets them apart. + * + * The lock does not exclude (see {@link withDirectoryLock}), so the pass does + * not rely on the directory staying as first listed. Immediately before it + * removes anything, and again whenever its last listing is more than + * {@link LISTING_GOOD_FOR_MS} old, it lists the directory again, where a + * manifest that was not there at first keeps everything it names whatever + * else is true of it: that is how a sandbox about to start on the same path + * shows. And before the first removal, and again every + * {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS}, it reads + * /proc/locks again, where a manifest that has been locked since keeps what it + * names. That one covers the actor no lock could: the bubblewrap a wrap has + * already handed to its caller, which takes its lock whenever the caller + * starts it. What is left is the time between a look and the removal made on + * it, a quarter of a millisecond for a newly published manifest and two + * milliseconds for one that was there all along, against the four or more a + * sandbox takes to get from its start to its binds. A pass that is held up for + * longer than that between looking and removing, with a sandbox starting on + * the same path in that time, still takes that sandbox's mount point away. + */ function collectUnderLock(dir: string): string[] { let names: string[] try { @@ -622,28 +1184,19 @@ function collectUnderLock(dir: string): string[] { manifests.push(manifest) } } - const locked = lockedInodes() - if (locked === undefined) { + const locks = readLocks(dir) + if (locks === undefined) { return [] } - - // Release this process's own wraps: the caller says their commands are done. - // One whose manifest is still locked has a sandbox running under it, and - // unlinking the manifest would take the kernel's answer about it away. What - // a released manifest named is still collected below — it is the usual way a - // mount point goes, right after the command that relied on it. - const outstanding: Manifest[] = [] - for (const manifest of manifests) { - if (ownManifests.has(manifest.file) && !locked.has(manifest.inode)) { - ownManifests.delete(manifest.file) - manifest.released = true - try { - fs.unlinkSync(manifest.file) - } catch { - // Collected by another process already. - } + // What the pass knows of: the first listing, so that everything found + // later is a newcomer. + const known = new Set(names) + // A manifest of this process that something else has taken off the disk is + // nothing to remember. + for (const file of ownManifests.keys()) { + if (path.dirname(file) === dir && !known.has(path.basename(file))) { + ownManifests.delete(file) } - outstanding.push(manifest) } // A manifest whose contents cannot be read — a file another version of this @@ -654,14 +1207,20 @@ function collectUnderLock(dir: string): string[] { // use and it stands in the way of nothing. let unreadableIsLive = false for (const file of unreadable) { - let stat: fs.Stats + let stat: fs.BigIntStats try { - stat = fs.statSync(file) + stat = fs.statSync(file, { bigint: true }) } catch { continue } - const age = Date.now() - stat.mtimeMs - if (locked.has(stat.ino) || age < MANIFEST_GRACE_MS) { + const age = Date.now() - Number(stat.mtimeMs) + if ( + holdsLock(locks, { + inode: String(stat.ino), + device: deviceOf(stat.dev), + }) || + age < MANIFEST_GRACE_MS + ) { unreadableIsLive = true } else if (age > UNREADABLE_MANIFEST_MAX_AGE_MS) { try { @@ -679,13 +1238,42 @@ function collectUnderLock(dir: string): string[] { return [] } + // What a process killed between writing a file and moving it into place + // left. Nothing reads these, and nothing else removes them. + for (const name of names) { + if (!name.endsWith(TEMPORARY_SUFFIX)) continue + const file = path.join(dir, name) + try { + if ( + Date.now() - fs.lstatSync(file).mtimeMs > + UNREADABLE_MANIFEST_MAX_AGE_MS + ) { + fs.unlinkSync(file) + } + } catch { + // Moved into place, or removed by its writer, in the meantime. + } + } + + // The wraps of this process the caller has said are over, except that one + // whose manifest is locked has a sandbox running under it all the same, and + // is anybody's live manifest until the lock goes. + for (const manifest of manifests) { + manifest.released = + ownManifests.get(manifest.file)?.over === true && + !holdsLock(locks, manifest) + } + const finished: Manifest[] = [] const claimed = new Set() - for (const manifest of outstanding) { - if (isLive(manifest, locked)) { - for (const path of [...manifest.paths, ...manifest.sources]) { - claimed.add(path) - } + const claim = (manifest: Manifest): void => { + for (const named of [...manifest.paths, ...manifest.sources]) { + claimed.add(named) + } + } + for (const manifest of manifests) { + if (isLive(manifest, locks)) { + claim(manifest) } else { finished.push(manifest) } @@ -705,39 +1293,107 @@ function collectUnderLock(dir: string): string[] { } } + // When the pass last made sure, and what it found then: `revived` are the + // manifests of the first listing that read as finished then and as locked on + // a later look. A finished manifest's writer is gone and its grace is over, + // and neither comes back, so the lock is all that is asked after again. + const revived = new Set() + let removalsOnThisLook = 0 + let lookedAt: number | undefined + let listedAt: number | undefined + const locksAreFresh = (): boolean => + lookedAt !== undefined && + removalsOnThisLook < REMOVALS_PER_LOOK && + performance.now() - lookedAt < LOCKS_GOOD_FOR_MS + const listingIsFresh = (): boolean => + listedAt !== undefined && performance.now() - listedAt < LISTING_GOOD_FOR_MS + const readTheLocksAgain = (): boolean => { + const locksNow = readLocks(dir) + if (locksNow === undefined) { + return false + } + for (const manifest of finished) { + if (!revived.has(manifest) && holdsLock(locksNow, manifest)) { + revived.add(manifest) + claim(manifest) + } + } + removalsOnThisLook = 0 + lookedAt = performance.now() + return true + } + const listTheDirectoryAgain = (): boolean => { + let namesNow: string[] + try { + namesNow = fs.readdirSync(dir) + } catch { + return false + } + for (const name of namesNow) { + if (!name.endsWith(MANIFEST_SUFFIX) || known.has(name)) continue + known.add(name) + const file = path.join(dir, name) + const arrived = readManifest(file) + if (arrived !== undefined) { + // Published since the pass began: a sandbox is about to start under + // it, or has. Whatever it names stays, with no further question. + claim(arrived) + } else if (fs.existsSync(file)) { + // There, and not to be read: what it names cannot be kept path by + // path, so everything is. + logForDebugging( + `[Sandbox Linux] A mount point manifest that appeared during the pass could not be read - leaving the rest where it is: ${file}`, + { level: 'warn' }, + ) + return false + } + } + listedAt = performance.now() + return true + } + // The kernel's list is read first and the directory listed last. Reading + // the list takes as long as the host has locks, milliseconds on a busy one, + // after which a listing made before it is out of date as well. + const mayStillRemove = (): boolean => + (locksAreFresh() || readTheLocksAgain()) && + (listingIsFresh() || listTheDirectoryAgain()) + const removed: string[] = [] + const notRemoved = new Set() for (const [candidate, isSource] of candidates) { - // The kernel is asked again immediately before the unlink. A manifest - // cannot appear mid-pass — publishing takes this same directory lock — but - // the bubblewrap a wrap has already handed to its caller takes its lock - // outside it, so a manifest that was unlocked when this pass started can - // be locked by the time its mount point is reached. - const lockedNow = lockedInodes() - if (lockedNow === undefined) { + if (!mayStillRemove()) { return removed } - const live = finished.some( - manifest => - (manifest.paths.includes(candidate) || - manifest.sources.includes(candidate)) && - isLive(manifest, lockedNow), - ) - if (live) { + if (claimed.has(candidate)) { continue } - if (isSource ? removeMountSource(candidate) : removeMountPoint(candidate)) { + const outcome = isSource + ? removeMountSource(candidate) + : removeMountPoint(candidate) + removalsOnThisLook++ + if (outcome === 'removed') { removed.push(candidate) + } else if (outcome === 'failed') { + notRemoved.add(candidate) } } + // The manifests themselves, last. Only a lock taken since can speak for one + // of these; a manifest somebody else has published in the meantime cannot. for (const manifest of finished) { - if (manifest.released) { - continue - } - const lockedNow = lockedInodes() - if (lockedNow === undefined) { + if (!locksAreFresh() && !readTheLocksAgain()) { return removed } - if (isLive(manifest, lockedNow)) { + if (revived.has(manifest)) { + continue + } + // A mount point that is still there, still what bubblewrap made, and could + // not be removed from here - a directory this process sees read-only, say + // - is still somebody's to remove, and the manifest is all that says so. + if ( + [...manifest.paths, ...manifest.sources].some(named => + notRemoved.has(named), + ) + ) { continue } try { @@ -745,6 +1401,22 @@ function collectUnderLock(dir: string): string[] { } catch { // Collected by another process already. } + ownManifests.delete(manifest.file) + removalsOnThisLook++ } return removed } + +/** + * Forget which directory the manifests are kept in, so that the next use + * works it out again from the environment. Test seam: a test gives itself a + * runtime directory of its own, so that what it lists, attacks and collects + * is never another process's, and this module may have settled on the real + * one before the test was loaded. + */ +export function forgetMountPointManifestDirectory(): void { + manifestDirectory = undefined + manifestDirectoryIsPrivate = false + manifestDirectoryUnavailable = false + directoryFailureLogged = false +} diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 20b3a3860..9ec6424b9 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -11,9 +11,14 @@ import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, + isBwrapFileMountPoint, + kindOfMountPoint, liveMountPoints, + mountPointManifestDirectories, publishMountPointManifest, + removePrivateManifestDirectory, type MountPointManifest, + type OwnManifestRelease, } from './bwrap-mount-manifests.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { @@ -458,33 +463,6 @@ function ensureEmptyMountSourceDir(): string { return emptyMountSourceDir } -/** - * Is `p` a mount point some sandbox made? bwrap makes the mount point for - * `--ro-bind /dev/null ` with ensure_file(dest, 0444): an empty - * regular file with no write bits. Files that only look similar are written by - * their creators with write bits (a lockfile, an empty file materialised on - * purpose: 0666 & ~umask) or have content or a second link. An empty read-only - * DIRECTORY left the same way is not recognisable: it looks like anyone's - * empty directory. - * - * Whether the sandbox that made it is still running is not asked here and is - * not the caller's business: this wrap covers the path and names it in its own - * manifest either way, and the manifests decide who may remove it. - */ -function isBwrapMountPoint(p: string): boolean { - try { - const stat = fs.lstatSync(p) - return ( - stat.isFile() && - stat.size === 0 && - (stat.mode & 0o222) === 0 && - stat.nlink === 1 - ) - } catch { - return false - } -} - export const CAP_SETFCAP = 31 // This process's bounding set unioned with its inheritable set, read once — @@ -875,6 +853,16 @@ function renderBwrapInvocation( let exitHandlerRegistered = false +/** + * Wraps handed to the caller and not yet cleaned up after. The caller is told + * to clean up once per command, when that command is over, so while this is + * above zero some command of this process may not have started yet, and what + * it relies on - its manifest, its --args profile - must still be there when + * it does. Nothing of this process's own is given up until the count is back + * at zero, unless the caller says which command it is cleaning up after. + */ +let activeSandboxCount = 0 + /** * Register cleanup handler for bwrap mount points */ @@ -897,25 +885,62 @@ function registerExitCleanupHandler(): void { * host filesystem as mount points for --ro-bind. These files persist after * bwrap exits. This function removes them. * - * This should be called after each sandboxed command completes to prevent + * Call it once for each wrapped command, when that command is over, to keep * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working - * directory. It is also called automatically on process exit as a safety net. + * directory. It is also called on process exit as a safety net. * - * A mount point is removed only when no running sandbox relies on it, which - * the kernel answers rather than this process (see bwrap-mount-manifests.ts). - * So the call is a garbage collect that any process may make at any time — - * early, twice, or not at all — and it also takes away what other srt - * processes have finished with. `force` is kept for callers that pass it and - * makes no difference to what is removed. + * What it may take away, of what THIS process made: + * - with no `commandId`, nothing until it has been called once for every wrap + * handed out: a command wrapped and not yet started still needs its + * manifest and its --args profile, and nothing here can tell which command + * the call is for. So one long-running command holds back the clean-up of + * the ones that finished beside it; + * - with the `commandId` its wrap was given, what that one command relied on, + * at once, whatever else is still running; + * - with `force`, everything: the process is ending, or the session is. That + * includes the manifest directory itself where it is one only this process + * knows, once nothing is left in it. * - * It does not wait for a wrap whose command this process has not run yet: the - * `--args` profiles below are closed, and a manifest this process wrote and - * has no sandbox running under is released, so a command wrapped but not yet - * started refuses to start (bwrap exits with "Unable to open lock file") - * rather than running with a mount point another call has removed. + * In every case a mount point goes only when no running sandbox relies on it, + * which the kernel answers and not this process (see bwrap-mount-manifests.ts), + * and what other srt processes have finished with goes too. A call too many + * takes nothing from a running sandbox; it does count for some other wrap of + * this process, and if that wrap's command has not started by the time the + * count is back at zero it then refuses to start. Does nothing except on + * Linux. */ -export function cleanupBwrapMountPoints(_opts?: { force?: boolean }): void { - const removed = collectMountPoints() +export function cleanupBwrapMountPoints(opts?: { + force?: boolean + commandId?: string +}): void { + if (process.platform !== 'linux') { + return + } + let release: OwnManifestRelease + if (opts?.force) { + activeSandboxCount = 0 + release = 'all' + } else { + if (activeSandboxCount > 0) { + activeSandboxCount-- + } + release = + activeSandboxCount === 0 + ? 'all' + : opts?.commandId !== undefined + ? { commandKey: opts.commandId } + : 'none' + if (release !== 'all') { + logForDebugging( + `[Sandbox Linux] ${activeSandboxCount} sandbox(es) of this process still outstanding - ` + + (release === 'none' + ? 'keeping what this process made' + : `releasing only what ${opts?.commandId} relied on`), + ) + } + } + + const removed = collectMountPoints(release) if ( emptyMountSourceDir !== undefined && removed.includes(emptyMountSourceDir) @@ -923,8 +948,15 @@ export function cleanupBwrapMountPoints(_opts?: { force?: boolean }): void { emptyMountSourceDir = undefined } - for (const argsFd of [...bwrapArgsFds]) { - closeBwrapArgsProfile(argsFd) + // An --args profile belongs to one wrap and nothing maps it back to a + // command, so they go together, when nothing is outstanding. + if (release === 'all') { + for (const argsFd of [...bwrapArgsFds]) { + closeBwrapArgsProfile(argsFd) + } + } + if (opts?.force) { + removePrivateManifestDirectory() } } @@ -1590,6 +1622,7 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, + commandKey?: string, ): Promise<{ args: string[]; manifest: MountPointManifest | undefined }> { const args: string[] = [] // fs already imported @@ -2322,37 +2355,69 @@ async function generateFilesystemArgs( continue } - // Whether anything is at the path, asked once for the two branches - // below. - const pathExists = fs.existsSync(normalizedPath) - - // A mount point another sandbox made (see isBwrapMountPoint, and the - // manifests, which name the ones a running sandbox relies on however - // they look on the host) is an absent deny path in all but name. Bound - // onto itself as an existing file it would never be named in this wrap's - // manifest, so never removed, and on the host its existence can be the - // whole meaning (a lockfile's). Cover it with /dev/null like the absent - // leaf below and name it, so a collect takes it away once no sandbox - // relies on it. Same gate as that branch. Under a read-only denied - // directory nothing needs covering (the file is already unwritable - // there), but it is still named: it is no more the caller's file for - // being there. + // A mount point another sandbox made is an absent deny path in all but + // name. Bound onto itself as an existing path it would never be named in + // this wrap's manifest, so it would go, with this sandbox still bound + // over it, as soon as the sandbox that made it was cleaned up after; + // and a leftover nobody names would never go at all, where on the host + // its existence can be the whole meaning (a lockfile's). There are two + // ways to know one, and what is done with it depends on its kind: + // - a FILE, by its shape (see isBwrapFileMountPoint), or because a live + // manifest names the path and it is still an empty regular file. It is + // covered with /dev/null like the absent leaf below, and named. Under + // a read-only denied directory nothing needs covering (the file is + // already unwritable there), but it is still named: it is no more the + // caller's file for being there; + // - a DIRECTORY, only because a live manifest names it: an empty + // directory looks like anyone's. It is the first missing component of + // some other sandbox's deny. /dev/null cannot be bound over a + // directory (bubblewrap refuses to start), so it is named and then + // treated as the existing directory it is, further down: bound onto + // itself read-only, which is also what the pre-pass above recorded it + // as; + // - anything else a live manifest names (a file that has been written + // to since, a link) is the caller's by now, and is neither. + // The shape is asked before the existence: another process's collect can + // take a leftover away at any moment, and a path found to exist and then + // found not to be a mount point because it has just gone would be bound + // onto itself as a file that is not there. And once more after it, when + // the two disagree: another sandbox starting on the same path makes its + // mount point at any moment too, and one made between the two questions + // would be bound onto itself as the caller's own file, named by this + // wrap nowhere, and gone from under it at that sandbox's clean-up. + let isFileMountPoint = isBwrapFileMountPoint(normalizedPath) + // Whether anything is at the path, asked once for the branches below. + const pathExists = isFileMountPoint || fs.existsSync(normalizedPath) + if (pathExists && !isFileMountPoint) { + isFileMountPoint = isBwrapFileMountPoint(normalizedPath) + } if ( - (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || - isWithinAnyAllowedWritePath(normalizedPath)) && - (isBwrapMountPoint(normalizedPath) || - (pathExists && - (liveMountPointsCache ??= liveMountPoints()).has(normalizedPath))) + isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || + isWithinAnyAllowedWritePath(normalizedPath) ) { - if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { - denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) - denyWriteRawDests.set(normalizedPath, rawPath) + const kind = isFileMountPoint + ? 'file' + : pathExists && + (liveMountPointsCache ??= liveMountPoints()).has(normalizedPath) + ? kindOfMountPoint(normalizedPath) + : undefined + if (kind === 'file') { + if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { + denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) + denyWriteRawDests.set(normalizedPath, rawPath) + } + mountPoints.push(normalizedPath) + logForDebugging( + `[Sandbox Linux] Re-covering a mount point another sandbox made: ${normalizedPath}`, + ) + continue + } + if (kind === 'directory') { + mountPoints.push(normalizedPath) + logForDebugging( + `[Sandbox Linux] Keeping a directory mount point another sandbox made for as long as this one runs: ${normalizedPath}`, + ) } - mountPoints.push(normalizedPath) - logForDebugging( - `[Sandbox Linux] Re-covering a mount point another sandbox made: ${normalizedPath}`, - ) - continue } // Handle non-existent paths by mounting /dev/null to block creation. @@ -2841,19 +2906,31 @@ async function generateFilesystemArgs( // could not be recorded are left on the host, since what this process cannot // record, no process may remove. // - // INVARIANT: the manifest directory must never be writable from inside the + // INVARIANT: a manifest directory must never be writable from inside a // sandbox — a command that could delete a manifest could make the mount - // points its own sandbox relies on look unused. Emitted after every allow - // and deny bind, for the same reason as the two stores below (all three are - // directories of their own, so their order among themselves does not - // matter). It is also what lets bwrap open the manifest: - // it does that inside the sandbox, after the mounts, so a profile with a - // tmpfs over the temp dir would otherwise leave nothing there to open. + // points a running sandbox relies on look unused, and one that could write + // a manifest could name any path at all for a collect on the host to + // remove. That holds for a sandbox whose own wrap names no manifest as much + // as for one that does, and for the directory some other process of this + // user keeps its manifests in as much as for this one's: the directory sits + // under the system temp dir wherever there is no $XDG_RUNTIME_DIR, which a + // caller's allowWrite commonly covers, and what is in it then belongs to + // OTHER sandboxes. So every wrap that restricts writes binds every one of + // them read-only (see mountPointManifestDirectories), and a wrap with mount + // points of its own adds the lock. Emitted after every allow and deny bind, + // for the same reason as the two stores below (they are all directories of + // their own, so their order among themselves does not matter). It is also + // what lets bwrap open the manifest: it does that inside the sandbox, after + // the mounts, so a profile with a tmpfs over the temp dir would otherwise + // leave nothing there to open. const manifest = publishMountPointManifest( mountPoints, emptySource === undefined ? [] : [emptySource], + commandKey, ) if (manifest !== undefined) { + // This wrap needs the directory there: bubblewrap opens the manifest in + // it, after the mounts. args.push( '--ro-bind', manifest.dir, @@ -2863,6 +2940,16 @@ async function generateFilesystemArgs( ) registerExitCleanupHandler() } + if (writeConfig !== undefined) { + // The others need only be kept out of the command's reach, so one that has + // gone by the time the command starts - a temp dir whose parent was + // replaced - is not an error. + for (const manifestDir of mountPointManifestDirectories()) { + if (manifestDir !== manifest?.dir) { + args.push('--ro-bind-try', manifestDir, manifestDir) + } + } + } // INVARIANT: the fake-file store directory must never be writable from // inside the sandbox. If it were, a sandboxed process could plant a @@ -2997,6 +3084,10 @@ export async function wrapCommandWithSandboxLinux( attributionKeyFor(command, commandId), ) + // Counted from here, for the clean-up the caller makes when the command is + // over; the catch below takes it back if no command comes of this wrap. + activeSandboxCount++ + const bwrapArgs: string[] = ['--new-session', '--die-with-parent'] let applySeccompPrefix: string | undefined // The manifest naming the mount points this wrap relies on, dropped again if @@ -3177,6 +3268,9 @@ export async function wrapCommandWithSandboxLinux( mandatoryDenySearchDepth, allowGitConfig, abortSignal, + // Only an id the caller chose: the attribution key falls back to the + // command text, which two wraps in flight may share. + commandId, ) // Held for the catch below: a wrap that produces no command has to let go // of the mount points it named. @@ -3283,10 +3377,14 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { // No command came of this wrap, so no sandbox can be running under its - // manifest and the mount points it named are no one's. + // manifest and the mount points it named are no one's, and the caller has + // nothing to clean up after. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } + if (activeSandboxCount > 0) { + activeSandboxCount-- + } throw error } } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 91daa448a..3ac05bc34 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1620,6 +1620,13 @@ export type WrapWithSandboxOptions = { * command text: keys are compared on their first 100 characters, so two * long commands sharing a prefix would otherwise cross-attribute, and a * rerun of the same text would inherit the earlier run's events. + * + * On Linux it is also the name under which the wrap records the mount + * points its command relies on: pass the same value to + * `cleanupAfterCommand({ commandId })` to have them removed as soon as that + * command is over, whatever other commands are still in flight. Only a + * value passed here counts for that; the `command` default does not, since + * two wraps in flight may share their text. */ commandId?: string /** @@ -2070,11 +2077,20 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Safe to call on any platform — it's a no-op on macOS. + * Call it once for each wrapped command, when that command is over. Two + * commands of one process may be in flight at once, and a call cannot tell + * which of them it is for, so with no argument nothing this process made is + * taken away until it has been called once for every wrap handed out: the + * command that has not started yet still needs what its wrap prepared. Pass + * the `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to + * have that one command's mount points go at once, whatever else is running, + * which matters when some command runs for a long time. + * + * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. */ -function cleanupAfterCommand(): void { - cleanupBwrapMountPoints() +function cleanupAfterCommand(options?: { commandId?: string }): void { + cleanupBwrapMountPoints({ commandId: options?.commandId }) } /** @@ -2469,7 +2485,7 @@ export interface ISandboxManager { getAwsPairRegistry(): AwsPairRegistry getMaskedFileStore(): MaskedFileStore updateConfig(newConfig: SandboxRuntimeConfig): void - cleanupAfterCommand(): void + cleanupAfterCommand(options?: { commandId?: string }): void reset(): Promise } From 2805ea83fa246a67086642176017ff3d404f32b5 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 15:54:49 +0000 Subject: [PATCH 061/141] test: cover the manifest collector, the wrap count and the directory checks A new suite drives the collector directly: overlapping commands with and without a commandId, a planted link at the manifest directory, a lock held by a dead process, a manifest from another PID namespace, a mount point replaced by something on another device, and a refused removal. The three existing suites are updated for the wrap count and for --ro-bind-try on the manifest directory. --- test/helpers/private-manifest-directory.ts | 60 + test/sandbox/linux-bwrap-args-file.test.ts | 48 +- test/sandbox/mount-point-liveness.test.ts | 1260 +++++++++++++++++++- test/sandbox/mount-point-manifests.test.ts | 978 +++++++++++++++ test/sandbox/stale-mount-point.test.ts | 27 +- 5 files changed, 2321 insertions(+), 52 deletions(-) create mode 100644 test/helpers/private-manifest-directory.ts create mode 100644 test/sandbox/mount-point-manifests.test.ts diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts new file mode 100644 index 000000000..897287435 --- /dev/null +++ b/test/helpers/private-manifest-directory.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll } from 'bun:test' +import { chmodSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' +import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' + +/** + * Gives the enclosing `describe` a runtime directory of its own for the mount + * point manifests, for as long as its tests run. + * + * The manifests of every srt process of a user live in one directory, and a + * collect reads, judges and removes all of them. A test that lists that + * directory, attacks it from inside a sandbox or asserts on what is left in it + * would otherwise be doing so to whatever else that user is running on the + * machine, and be failed by it in turn. `$XDG_RUNTIME_DIR` is where the library + * looks first, and child processes inherit it, so pointing it at a fresh + * directory moves this process and everything it spawns at once. + * + * It also starts the `describe` with no wrap of this process outstanding, and + * leaves it so. The library counts the wraps it has handed out and releases + * nothing of this process's until each has been cleaned up after; the count is + * the module's, shared by every test file of one run, and other suites wrap + * without cleaning up. A test that expects one clean-up to take its mount point + * away would otherwise pass or fail by which file ran before it. + * + * Call it inside the `describe` callback. Returns where the manifests go. + */ +export function usePrivateManifestDirectory(): { manifestDir(): string } { + const saved = process.env.XDG_RUNTIME_DIR + let runtimeDir: string | undefined + + beforeAll(() => { + // In the directory the earlier suites used, before it is left behind. + cleanupBwrapMountPoints({ force: true }) + runtimeDir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-runtime-'))) + chmodSync(runtimeDir, 0o700) + process.env.XDG_RUNTIME_DIR = runtimeDir + forgetMountPointManifestDirectory() + }) + + afterAll(() => { + cleanupBwrapMountPoints({ force: true }) + if (saved === undefined) delete process.env.XDG_RUNTIME_DIR + else process.env.XDG_RUNTIME_DIR = saved + forgetMountPointManifestDirectory() + if (runtimeDir !== undefined) { + rmSync(runtimeDir, { recursive: true, force: true }) + } + }) + + return { + manifestDir: () => { + if (runtimeDir === undefined) { + throw new Error('asked for before the tests began') + } + return join(runtimeDir, 'srt-mount-points') + }, + } +} diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index d840d28db..98fa867d0 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -20,6 +20,7 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/index.js' import { isLinux } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' describe('the bwrap profile error at the package root', () => { it('carries a name, a code, and a cause only when one is given', () => { @@ -51,6 +52,7 @@ describe('the bwrap profile error at the package root', () => { * command line. */ describe.if(isLinux)('bwrap --args for over-long profiles', () => { + usePrivateManifestDirectory() const MAX_ARG_STRLEN = 32 * Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) // The largest rendering kept on the command line: the kernel's limit less @@ -195,13 +197,21 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // fit one argument itself. const scriptFile = join(BASE, 'isolated.ts') writeFileSync(scriptFile, script) - // A tmpdir of its own, so what a scenario leaves there goes with BASE. + // A tmpdir of its own, so what a scenario leaves there goes with BASE, + // and no runtime directory: the mount point manifests are then kept under + // that tmpdir, which is the arrangement several scenarios are about. mkdirSync(join(BASE, 'tmp'), { recursive: true }) + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TMPDIR: join(BASE, 'tmp'), + ...env, + } + delete childEnv.XDG_RUNTIME_DIR const argv = [...launcher, process.execPath, 'run', scriptFile] const run = spawnSync(argv[0]!, argv.slice(1), { cwd: BASE, encoding: 'utf8', - env: { ...process.env, TMPDIR: join(BASE, 'tmp'), ...env }, + env: childEnv, timeout: 60000, }) expect(run.stderr).toBe('') @@ -238,7 +248,8 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // environment is in it. expect( mounts.filter( - w => w.startsWith('--') && !/^--(ro-bind|bind|tmpfs)$/.test(w), + w => + w.startsWith('--') && !/^--(ro-bind|ro-bind-try|bind|tmpfs)$/.test(w), ), ).toEqual([]) expect(before).toContain( @@ -377,6 +388,27 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { }) }) + it('keeps a pending profile through the clean-up after another command, and gives it back with the last', () => { + // A profile belongs to one wrap and nothing maps it back to a command, so + // none is closed while any wrap of this process is outstanding: the one + // that has not started yet reopens its profile through /proc when it does. + const seen = isolated(` + const openFds = () => fs.readdirSync('/proc/self/fd').length + // The runtime opens event-loop fds of its own on the first wrap. + await wrap(small) + cleanupBwrapMountPoints() + const baseline = openFds() + await wrap(overLong) + await wrap(overLong) + const held = openFds() - baseline + cleanupBwrapMountPoints() + const afterFirst = openFds() - baseline + cleanupBwrapMountPoints() + console.log(JSON.stringify({ held, afterFirst, afterSecond: openFds() - baseline })) + `) + expect(seen).toEqual({ held: 2, afterFirst: 2, afterSecond: 0 }) + }) + it('refuses at wrap time, with the reason, when no directory takes an unnamed file', () => { // Both candidates read-only: tmpdir and /dev/shm. A profile that fits // needs neither and is unaffected. @@ -448,7 +480,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { const { spawnSync } = await import('node:child_process') const wrapped = await wrap(overLong, 'touch ${marker} 2>/dev/null && echo WROTE || echo DENIED') const argsPath = argsPathOf(wrapped) - // The runtime leaves a cache of its own there; nothing of ours. + // The runtime leaves a cache of its own there. Of ours there is one + // thing, and it is not the profile: the directory mount point + // manifests are kept in, which this wrap has none of and binds + // read-only only to keep other sandboxes' out of the command's reach. const namedUnderTmpdir = fs.readdirSync(process.env.TMPDIR, { recursive: true }) .filter(entry => /srt|args|bwrap/.test(entry)) // What a sandbox with the parent writable, or another process of @@ -476,7 +511,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { ) expect(seen).toEqual({ argsPath: expect.stringMatching(/^\/proc\/\d+\/fd\/\d+$/), - namedUnderTmpdir: [], + // Empty, and bound with --ro-bind-try: it went aside with its parent + // before the command started, and status 0 below is that not + // mattering. + namedUnderTmpdir: [`srt-mount-points-${process.getuid?.() ?? 0}`], planted: null, status: 0, // The command ran under the profile that was wrapped, not one diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 79887b781..e0c60058a 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' -import { spawn, spawnSync } from 'node:child_process' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' import { chmodSync, existsSync, @@ -18,8 +19,10 @@ import { cleanupBwrapMountPoints, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' /** * A denyWrite path that does not exist is blocked by binding /dev/null over @@ -36,6 +39,9 @@ import { isLinux } from '../helpers/platform.js' * instead. */ describe.if(isLinux)('A mount point a running sandbox relies on', () => { + // These tests list the manifests, attack them from inside a sandbox and + // assert on what is left: in a directory of their own. + usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), @@ -192,8 +198,13 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { await waitFor(SIGNAL) cleanupBwrapMountPoints() cleanupBwrapMountPoints() + // `force` is what the exit handler and reset() pass: everything this + // process made may go. A sandbox that is running under a manifest is + // not this process's to end, so that manifest and what it names stay, + // for a later cleanup here or in any other process. cleanupBwrapMountPoints({ force: true }) - expect(existsSync(LOCK)).toBe(true) + expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(manifestOf(command))).toBe(true) } finally { writeFileSync(GO, '') } @@ -205,6 +216,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // And goes on the next cleanup, now that nothing is running under it. cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) }, 90000, ) @@ -225,7 +237,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { { encoding: 'utf8', timeout: 60000 }, ) expect(killed.signal).toBe('SIGKILL') - expect(contentAt(LOCK)).toBe('') + // Its sandbox ran, and left what bubblewrap made for it: without this + // everything below holds of a path nothing was ever made at. + expect(readFileSync(OUT, 'utf8')).not.toContain('bwrap:') + expect(lstatSync(LOCK).size).toBe(0) const directory = dirname(manifestOf(await wrap('true'))) await sleep(600) @@ -282,48 +297,125 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it.if(BWRAP_CAN_NAMESPACE)( - 'holds its deny while another process collects without pause', + 'are read-only in a sandbox that names no manifest of its own', async () => { - const loop = spawn( - process.execPath, - [ - writeScript( - 'collect-loop.ts', - [ - `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - `const until = Date.now() + 20000`, - `while (Date.now() < until) cleanupBwrapMountPoints()`, - ].join('\n'), - ), - ], - { stdio: 'ignore' }, - ) - try { - for (let i = 0; i < 12; i++) { - const result = spawnSync( - await wrap(`echo pwned > ${LOCK}; echo rc=$?`), - { shell: true, encoding: 'utf8', timeout: 60000 }, - ) - const said = `${result.stdout}${result.stderr}` - expect(said).toMatch(/rc=[1-9]/) - // Nothing was written at the deny path, on this pass or an earlier - // one whose mount point this pass may have re-covered. - expect(contentAt(LOCK)).toBe('') - cleanupBwrapMountPoints() - } - } finally { - loop.kill() + // A wrap with no absent deny path has no mount points and no manifest, + // and the manifests in the directory are then other sandboxes'. The + // collect that runs on the host believes them, so a command able to + // write one could have any empty file removed, and one able to delete + // one could make a running sandbox's mount points look unused. + const directory = dirname(manifestOf(await wrap('true'))) + const before = readdirSync(directory).filter(n => n.endsWith('.json')) + expect(before.length).toBeGreaterThan(0) + + const attack = [ + `rm -f ${directory}/*.json; echo rm=$?`, + `echo mine > ${directory}/planted.json; echo plant=$?`, + `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, + ].join('; ') + const command = await wrapCommandWithSandboxLinux({ + command: attack, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA, directory], denyWithinAllow: [] }, + }) + // The point of the case: this wrap relies on no mount point. + expect(command).not.toContain('--lock-file') + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + const said = `${result.stdout}${result.stderr}` + expect(said).toMatch(/rm=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/rewrite=[1-9]/) + expect(existsSync(join(directory, 'planted.json'))).toBe(false) + for (const name of before) { + expect(readFileSync(join(directory, name), 'utf8')).toContain('"paths"') } }, - 120000, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'are read-only, too, where a process that was started without a runtime directory keeps them', + async () => { + // A process started with $XDG_RUNTIME_DIR keeps its manifests under it, + // and one started without (from cron, over a plain ssh) under the temp + // dir. A sandbox of the first kind that may write the temp dir could + // delete and forge the manifests of the second, which a collect on the + // host then believed. So every wrap binds both names read-only, and + // makes the one that is not there so that the command cannot make it + // first. + const RUN = join(BASE, 'run') + const TMP = join(BASE, 'tmp') + mkdirSync(RUN, { mode: 0o700 }) + chmodSync(RUN, 0o700) + mkdirSync(TMP) + const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + const attacker = (script: string, attack: string): string => + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(attack)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(TMP)}, ${JSON.stringify(AREA)}], denyWithinAllow: [] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const withARuntimeDirectory = (script: string) => + spawnSync(process.execPath, [script], { + env: { ...process.env, XDG_RUNTIME_DIR: RUN, TMPDIR: TMP }, + encoding: 'utf8', + timeout: 60000, + }) + + // Nobody has made the other directory yet: the command may not either. + const early = withARuntimeDirectory( + attacker( + 'early.ts', + `mkdir -m 700 ${theirs} 2>/dev/null; echo forged > ${theirs}/planted.json; echo plant=$?`, + ), + ) + expect(early.stdout).toMatch(/plant=[1-9]/) + expect(lstatSync(theirs).isDirectory()).toBe(true) + expect(readdirSync(theirs)).toEqual([]) + + // What a process without a runtime directory has recorded there. + const manifest = join(theirs, '4242-0123456789abcdef.json') + writeFileSync(manifest, '{"version":1,"paths":[]}', { mode: 0o600 }) + const late = withARuntimeDirectory( + attacker( + 'late.ts', + `rm -f ${theirs}/*.json; echo rm=$?; echo forged > ${theirs}/planted.json; echo plant=$?`, + ), + ) + expect(late.stdout).toMatch(/rm=[1-9]/) + expect(late.stdout).toMatch(/plant=[1-9]/) + expect(readdirSync(theirs)).toEqual(['4242-0123456789abcdef.json']) + expect(readFileSync(manifest, 'utf8')).toContain('"paths"') + }, + 90000, ) it.if(BWRAP_CAN_NAMESPACE)( - 'refuses to start a command wrapped before a cleanup that has since taken its mount point away', + 'refuses to start a command wrapped before a cleanup that has since let go of its manifest', async () => { const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) + const manifest = manifestOf(command) + expect(existsSync(manifest)).toBe(true) + // The one clean-up this process owes, made before the command is run: + // nothing is outstanding any more, so what the wrap prepared is let go. cleanupBwrapMountPoints() - expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifest)).toBe(false) const result = spawnSync(command, { shell: true, @@ -342,6 +434,334 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + // ---- two commands of one process in flight ----------------------------- + // + // The caller cleans up once per command, when that command is over, and a + // call cannot tell which command it is for. So while a wrap is outstanding + // nothing this process made may go: the command that has not started yet + // still needs its manifest (bubblewrap opens it to take the lock) and its + // mount point. + + it.if(BWRAP_CAN_NAMESPACE)( + 'a command wrapped and not yet started survives the clean-up after the one that finished first', + async () => { + const first = await wrap('echo first-ran') + const second = await wrap( + `echo second-ran; echo pwned > ${LOCK}; echo rc=$?`, + ) + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 }) + + expect(run(first).stdout).toContain('first-ran') + cleanupBwrapMountPoints() // after the first; the second is not started + // The call could have been for either, so what the first left stays. + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(manifestOf(second))).toBe(true) + + const result = run(second) + const said = `${result.stdout}${result.stderr}` + expect(said).not.toContain('Unable to open lock file') + expect(said).not.toContain('bwrap:') + expect(result.status).toBe(0) + expect(result.stdout).toContain('second-ran') + expect(result.stdout).toMatch(/rc=[1-9]/) // and its deny held + expect(contentAt(LOCK)).toBe('') + + cleanupBwrapMountPoints() // after the second: nothing outstanding now + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'a sandbox that takes its lock while another command is being cleaned up after keeps its manifest and its deny', + async () => { + // The interleaving that chance produces when one command ends a few + // milliseconds after another was spawned, made certain: the collect + // looks at /proc/locks, where the second command's manifest is not yet + // locked, and before it goes on that command's sandbox comes up and + // takes the lock. + const first = await wrap('true') + const second = await wrap(heldCommand()) + const manifest = manifestOf(second) + expect(spawnSync(first, { shell: true, timeout: 60000 }).status).toBe(0) + + const readFile = fs.readFileSync + let started = false + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const value = (readFile as (f: unknown, o: unknown) => unknown)( + file, + options, + ) + if (file === '/proc/locks' && !started) { + started = true + // `{ ...; true; }` keeps a shell as bubblewrap's parent. + spawnSync('sh', ['-c', `{ ${second}; true; } > ${OUT} 2>&1 &`], { + stdio: 'ignore', + }) + const deadline = Date.now() + 20000 + while (!existsSync(SIGNAL)) { + if (Date.now() > deadline) throw new Error('sandbox never came up') + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + } + return value + }) as never) + try { + cleanupBwrapMountPoints() // after the first + } finally { + spy.mockRestore() + } + expect(started).toBe(true) + // The second is running, and what it relies on is still on record. + expect(existsSync(manifest)).toBe(true) + + // A later command in the same repository names the same mount point. + const third = await wrap('true') + expect(spawnSync(third, { shell: true, timeout: 60000 }).status).toBe(0) + cleanupBwrapMountPoints() // after the third + expect(existsSync(LOCK)).toBe(true) + + writeFileSync(GO, '') + const deadline = Date.now() + 20000 + while (!/rc=\d/.test(contentAt(OUT))) { + if (Date.now() > deadline) throw new Error('the second never finished') + await sleep(25) + } + expect(contentAt(OUT)).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'with the id its wrap was given, a finished command lets go of its mount point while another is outstanding', + async () => { + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + const wrapAs = (commandId: string, command: string, deny: string) => + wrapCommandWithSandboxLinux({ + command, + commandId, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [deny] }, + }) + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 }) + + const first = await wrapAs('first-0123456789abcdef', 'true', lockOfFirst) + const second = await wrapAs( + 'second-0123456789abcdef', + `echo pwned > ${LOCK}; echo rc=$?`, + LOCK, + ) + expect(run(first).status).toBe(0) + expect(existsSync(lockOfFirst)).toBe(true) // bubblewrap leaves it + + // One call per command, as always. This one says which command it is + // for, so that command's mount point goes although the second is still + // outstanding, and the second's manifest is untouched. + cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(second))).toBe(true) + + const result = run(second) + expect(`${result.stdout}${result.stderr}`).not.toContain('bwrap:') + expect(result.stdout).toMatch(/rc=[1-9]/) + cleanupBwrapMountPoints({ commandId: 'second-0123456789abcdef' }) + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'a wrap that produced no command is not waited for by the clean-up after the next', + async () => { + // It gave its count back when it threw: the caller has no command to + // clean up after, and one clean-up after the command that did run is + // all that is owed. + const refused = wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'srt-no-such-shell', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + }) + expect(refused).rejects.toThrow() + await refused.catch(() => undefined) + + const command = await wrap('true') + expect(spawnSync(command, { shell: true, timeout: 60000 }).status).toBe(0) + expect(existsSync(LOCK)).toBe(true) // bubblewrap leaves it + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { + // Live: written by this process, which has not let go of it. The path + // was a mount point when that manifest was written; a file with content + // is nothing bubblewrap made, and /dev/null over it would hide it from + // the command it belongs to. + const first = await wrap('true') + expect(first).toContain(`--ro-bind /dev/null ${LOCK}`) + writeFileSync(LOCK, 'somebody wrote this\n') + const second = await wrap('true') + expect(second).not.toContain(`--ro-bind /dev/null ${LOCK}`) + expect(second).toContain(`--ro-bind ${LOCK} ${LOCK}`) + // Nor does the second wrap name it: it is not its to remove. + expect(second).not.toContain('--lock-file') + }) + + it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { + // Absent when the wrap asked what shape it has, there when it asked + // whether anything is: another sandbox started on the same path in + // between. Bound onto itself as the caller's own file it was named by + // this wrap nowhere, and went from under its sandbox at the other's + // clean-up. + const exists = fs.existsSync + let made = false + const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { + if (file === LOCK && !made) { + made = true + expect(exists(LOCK)).toBe(false) + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + } + return exists(file as string) + }) as never) + let command: string + try { + command = await wrap('true') + } finally { + spy.mockRestore() + } + expect(made).toBe(true) + expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) + expect(command).not.toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'goes at once through the public clean-up, given the id its wrap was given', + async () => { + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + const config = (deny: string) => ({ + filesystem: { denyRead: [], allowWrite: [AREA], denyWrite: [deny] }, + }) + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + ...config(lockOfFirst), + }) + try { + const first = await SandboxManager.wrapWithSandbox( + 'true', + undefined, + undefined, + undefined, + { commandId: 'first-0123456789abcdef' }, + ) + const second = await SandboxManager.wrapWithSandbox( + 'true', + undefined, + config(LOCK), + undefined, + { commandId: 'second-0123456789abcdef' }, + ) + expect(spawnSync(first, { shell: true, timeout: 60000 }).status).toBe(0) + expect(existsSync(lockOfFirst)).toBe(true) + + SandboxManager.cleanupAfterCommand({ + commandId: 'first-0123456789abcdef', + }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(first))).toBe(false) + // The other command has not started, and still can. + expect(existsSync(manifestOf(second))).toBe(true) + expect(spawnSync(second, { shell: true, timeout: 60000 }).status).toBe( + 0, + ) + } finally { + await SandboxManager.reset() + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", + async () => { + // Neither shared name will do: no runtime directory, and the name under + // the temp dir is somebody's file. + const TMP = join(BASE, 'tmp') + mkdirSync(TMP) + const squatted = join(TMP, `srt-mount-points-${process.getuid!()}`) + writeFileSync(squatted, '') + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR + const inThatPlace = (script: string, deny: string[]) => + spawnSync( + process.execPath, + [ + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { readdirSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: ${JSON.stringify(deny)} },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, status: r.status, said: String(r.stdout ?? '') + String(r.stderr ?? ''), made: readdirSync(${JSON.stringify(TMP)}) }))`, + ].join('\n'), + ), + ], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + const manifestDirectories = (names: string[]): string[] => + names.filter(name => /^srt-mount-points-[A-Za-z0-9]{6}$/.test(name)) + + // With no mount point there is nothing to record and nothing to keep + // out of the command's reach, and no directory is made for it. + const nothingToRecord = JSON.parse( + inThatPlace('nothing-to-record.ts', []).stdout, + ) as { wrapped: string; status: number; made: string[] } + expect(nothingToRecord.status).toBe(0) + expect(nothingToRecord.wrapped).not.toContain('--lock-file') + expect(manifestDirectories(nothingToRecord.made)).toEqual([]) + expect(manifestDirectories(readdirSync(TMP))).toEqual([]) + + // With one, it is made, used, and gone again when the process ends. + const recorded = JSON.parse( + inThatPlace('recorded.ts', [LOCK]).stdout, + ) as { wrapped: string; status: number; said: string; made: string[] } + expect(recorded.said).not.toContain('bwrap:') + expect(recorded.status).toBe(0) + expect(manifestDirectories(recorded.made).length).toBe(1) + expect(dirname(manifestOf(recorded.wrapped))).toBe( + join(TMP, manifestDirectories(recorded.made)[0]!), + ) + expect(manifestDirectories(readdirSync(TMP))).toEqual([]) + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + it('lets go of the mount points of a wrap that produced no command', async () => { // Left where a process killed before its cleanup would leave it, so that // both processes below name it. @@ -390,3 +810,769 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { } }, 90000) }) + +/** + * The one case where nothing but the kernel's lock keeps a mount point: the + * process that wrapped the command is GONE, and the sandbox it started is + * STILL RUNNING. The manifest's writer no longer vouches for it, the grace + * period has run out, and no count in anyone's memory knows about it; only + * bubblewrap's --lock-file, seen through /proc/locks, says the sandbox is + * there. Every test above has the wrapping process alive, which alone keeps + * the manifest live. + * + * The wrap always asks for --die-with-parent, so a sandbox outlives the + * process that wrapped it only when something else is bubblewrap's parent: + * here a shell in a session of its own, as when a wrapped command is handed to + * a job runner, a terminal multiplexer or `nohup sh -c`, or when the wrapping + * process is killed and the `sh -c` it spawned the command through is not. + */ +describe.if(isLinux)( + 'A mount point whose sandbox outlives the process that wrapped it', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + // Comfortably past the grace a new manifest is given (500 ms): after this + // only the writer being alive, or the lock, can make the manifest live. + const PAST_GRACE_MS = 1200 + + let BASE: string + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let SIGNAL: string // written from inside the sandbox once it is up + let GO: string // written by the test to let the sandbox try its write + let RESULT: string // what the denied write returned, from inside + let INFO: string // what the writer says about itself before it goes + let launcher: number | undefined // the shell bubblewrap is a child of + let writer: ChildProcess | undefined + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-orphan-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + RESULT = join(AREA, 'result') + INFO = join(BASE, 'info.json') + launcher = undefined + writer = undefined + }) + + afterEach(async () => { + writer?.kill('SIGKILL') + if (launcher !== undefined) { + await killTree(launcher) + } + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function waitFor( + what: string, + done: () => boolean, + timeoutMs = 30000, + ): Promise { + const deadline = Date.now() + timeoutMs + while (!done()) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${what}`) + } + await sleep(25) + } + } + + /** "pid state ppid" of every process this one can see. */ + function processes(): { pid: number; state: string; ppid: number }[] { + const found: { pid: number; state: string; ppid: number }[] = [] + for (const name of readdirSync('/proc')) { + if (!/^\d+$/.test(name)) continue + try { + const stat = readFileSync(`/proc/${name}/stat`, 'utf8') + const [state, ppid] = stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ') + found.push({ + pid: Number(name), + state: state ?? '?', + ppid: Number(ppid), + }) + } catch { + // Went away while we were looking. + } + } + return found + } + + /** `root` and everything under it that is still running (not a zombie). */ + function runningTree(root: number): number[] { + const all = processes() + const tree = [root] + for (let i = 0; i < tree.length; i++) { + for (const p of all) { + if (p.ppid === tree[i] && !tree.includes(p.pid)) tree.push(p.pid) + } + } + return tree.filter(pid => + all.some(p => p.pid === pid && p.state !== 'Z' && p.state !== 'X'), + ) + } + + async function killTree(root: number): Promise { + const tree = runningTree(root) + for (const pid of tree) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Gone already. + } + } + await waitFor( + `the sandbox under ${root} to be gone`, + () => + !processes().some( + p => tree.includes(p.pid) && p.state !== 'Z' && p.state !== 'X', + ), + ) + } + + /** + * Says it is up, waits to be told, tries the denied write, reports what + * that returned, and then STAYS: the sandbox is still running when it is + * killed further down. + */ + function heldCommand(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` + } + + /** + * A process that wraps the held command, starts it under a shell in a + * session of its own, says who is who, waits for the sandbox to be up and + * then goes: `leave` is how. + */ + function writerScript(leave: string): string { + return [ + `import { spawn } from 'node:child_process'`, + `import { existsSync, writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(heldCommand())},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + // "; exit" so that no shell execs bubblewrap in its own place: the + // shell has to stay, as the parent --die-with-parent watches. + `const child = spawn('/bin/sh', ['-c', wrapped + '\\nexit $?'], { detached: true, stdio: 'ignore' })`, + `child.unref()`, + `writeFileSync(${JSON.stringify(INFO)}, JSON.stringify({ writer: process.pid, launcher: child.pid, wrapped }))`, + // Not for ever: a sandbox that never comes up fails the test at once. + `const deadline = Date.now() + 30000`, + `while (!existsSync(${JSON.stringify(SIGNAL)})) {`, + ` if (Date.now() > deadline) process.exit(3)`, + ` await new Promise(r => setTimeout(r, 25))`, + `}`, + leave, + ].join('\n') + } + + /** A process that only collects: it has wrapped nothing. */ + function collectorScript(): string { + return [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + `cleanupBwrapMountPoints({ force: true })`, + ].join('\n') + } + + /** A process that denies the same path, runs a command and cleans up. */ + function secondWrapperScript(): string { + return [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + `process.exit(r.status ?? 1)`, + ].join('\n') + } + + function runScript(name: string, source: string): number | null { + const file = join(BASE, name) + writeFileSync(file, source) + return spawnSync(process.execPath, [file], { + encoding: 'utf8', + timeout: 60000, + }).status + } + + /** The manifests in `dir` that name the deny path. */ + function manifestsNamingLock(dir: string): string[] { + return readdirSync(dir).filter( + name => + name.endsWith('.json') && + readFileSync(join(dir, name), 'utf8').includes(LOCK), + ) + } + + type Writer = { writer: number; launcher: number; wrapped: string } + + /** + * Has a process wrap the held command, start it and go, and checks the + * premise: that process is gone, and its sandbox is up and running. + */ + async function sandboxWhoseWriterHasGone( + leave: string, + signal: NodeJS.Signals | null, + ): Promise { + const writerFile = join(BASE, 'writer.ts') + writeFileSync(writerFile, writerScript(leave)) + writer = spawn(process.execPath, [writerFile], { stdio: 'ignore' }) + const left = await new Promise<{ + code: number | null + signal: NodeJS.Signals | null + }>(resolve => + writer!.on('exit', (code, signal) => resolve({ code, signal })), + ) + expect(left).toEqual({ code: signal === null ? 0 : null, signal }) + + const info = JSON.parse(readFileSync(INFO, 'utf8')) as Writer + launcher = info.launcher + expect(existsSync(`/proc/${info.writer}`)).toBe(false) + expect(existsSync(SIGNAL)).toBe(true) + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + return info + } + + /** Lets the sandbox try its write, and says what came of it. */ + async function theDeniedWrite(): Promise<{ + deniedWrite: string + onTheHost: string + }> { + writeFileSync(GO, '') + await waitFor( + 'the sandbox to say what its write returned', + () => existsSync(RESULT) && readFileSync(RESULT, 'utf8').includes('\n'), + ) + return { + deniedWrite: readFileSync(RESULT, 'utf8').trim(), + onTheHost: existsSync(LOCK) + ? readFileSync(LOCK, 'utf8') + : '(no mount point)', + } + } + + const ways: [string, string, NodeJS.Signals | null][] = [ + [ + 'is killed before it can clean up', + `process.kill(process.pid, 'SIGKILL')`, + 'SIGKILL', + ], + // Its exit handler runs the cleanup, with `force`, under its own + // sandbox: everything the process made may go, and a sandbox that is + // still running under a manifest is not the process's to end. + ['exits, running its exit-time cleanup', `process.exit(0)`, null], + ] + + for (const [how, leave, signal] of ways) { + it.if(BWRAP_CAN_NAMESPACE)( + `stays, and the deny holds, while other processes collect after its writer ${how}; goes once the sandbox is gone`, + async () => { + const info = await sandboxWhoseWriterHasGone(leave, signal) + const manifest = /--lock-file (\S+)/.exec(info.wrapped)?.[1] + expect(manifest).toBeDefined() + // Still naming the deny path, and the process that is gone. + const written = JSON.parse(readFileSync(manifest!, 'utf8')) as { + pid: number + paths: string[] + } + expect(written.pid).toBe(info.writer) + expect(written.paths).toContain(LOCK) + + // Whether the mount point bwrap made is on the host, and the + // manifest that names it where the wrap put it, after each thing + // that might have taken them. Looked at together further down, so + // that a failure says all of what happened and not only the first + // of it. + const BOTH = 'the mount point and its manifest' + const stayed: Record = {} + const look = (when: string): void => { + const mountPoint = existsSync(LOCK) && lstatSync(LOCK).size === 0 + const named = existsSync(manifest!) + stayed[when] = + mountPoint && named + ? BOTH + : mountPoint + ? 'the mount point, not its manifest' + : named + ? 'the manifest, not the mount point' + : 'neither' + } + look('after its writer went') + + // Past the grace a new manifest is given, so that neither it nor + // the writer is what keeps the manifest live below. + await sleep(PAST_GRACE_MS) + + expect(runScript('collector.ts', collectorScript())).toBe(0) + look('after a process that wrapped nothing collected') + expect(runScript('second.ts', secondWrapperScript())).toBe(0) + look('after a process that denies the same path ran and cleaned up') + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + look('after this process collected, twice') + + // The sandbox is still there. Let it try the denied write: it must + // fail inside, and nothing may land on the host. (A mount point + // that was removed and made again by the second wrap is on the host + // again, but is no longer what this sandbox is bound over, so only + // the write tells.) + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ + stayed: { + 'after its writer went': BOTH, + 'after a process that wrapped nothing collected': BOTH, + 'after a process that denies the same path ran and cleaned up': + BOTH, + 'after this process collected, twice': BOTH, + }, + deniedWrite: expect.stringMatching(/^rc=[1-9]/), + onTheHost: '', + }) + + // Still running after its write, and a collect still leaves it. + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + expect(runScript('collector.ts', collectorScript())).toBe(0) + expect(existsSync(LOCK)).toBe(true) + + // Kill the sandbox. However it ends the kernel drops the lock, and + // the next collect, by anyone, takes the mount point and the + // manifest away. + await killTree(info.launcher) + launcher = undefined + expect(runScript('collector.ts', collectorScript())).toBe(0) + expect(existsSync(LOCK)).toBe(false) + expect(manifestsNamingLock(join(manifest!, '..'))).toEqual([]) + }, + 120000, + ) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'holds its deny while another process collects without pause, with the process that wrapped it gone', + async () => { + // A collector that never has a candidate proves nothing, and with the + // wrapping process alive it never has one: the writer vouches for the + // manifest whatever the kernel says. Here the writer is gone and the + // grace is over before the write is tried, so for most of each round + // the lock is all that stands between the collector and the mount + // point, and the collector is asking the whole time. + const loopFile = join(BASE, 'collect-loop.ts') + writeFileSync( + loopFile, + [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `const until = Date.now() + 90000`, + `while (Date.now() < until) cleanupBwrapMountPoints()`, + ].join('\n'), + ) + const loop = spawn(process.execPath, [loopFile], { stdio: 'ignore' }) + try { + for (let round = 0; round < 3; round++) { + const info = await sandboxWhoseWriterHasGone( + `process.kill(process.pid, 'SIGKILL')`, + 'SIGKILL', + ) + await sleep(PAST_GRACE_MS) + expect(loop.exitCode).toBe(null) // still collecting + expect(lstatSync(LOCK).size).toBe(0) + expect(await theDeniedWrite()).toEqual({ + deniedWrite: expect.stringMatching(/^rc=[1-9]/), + onTheHost: '', + }) + await killTree(info.launcher) + launcher = undefined + // With the sandbox gone the same collector takes it away. + await waitFor( + 'the collector to take the mount point away', + () => !existsSync(LOCK), + ) + for (const file of [SIGNAL, GO, RESULT, INFO]) { + rmSync(file, { force: true }) + } + } + } finally { + loop.kill('SIGKILL') + } + }, + 120000, + ) + }, +) + +/** + * The mount point for a deny whose first missing component is not the leaf is + * an empty DIRECTORY, bound from an empty read-only one. On the host it looks + * like anyone's empty directory, so only a manifest says what it is. + */ +describe.if(isLinux)( + 'A directory mount point a running sandbox relies on', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + let BASE: string + let AREA: string + let DIRECTORY: string // the first missing component of LEAF + let LEAF: string + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-directory-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'proj'), { recursive: true }) + DIRECTORY = join(AREA, 'proj', '.claude') + LEAF = join(DIRECTORY, 'commands') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + const wrap = (command: string, deny: string[]): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: deny }, + }) + + const run = ( + command: string, + ): { status: number | null; said: string; stdout: string } => { + const r = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + return { + status: r.status, + said: `${r.stdout}${r.stderr}`, + stdout: r.stdout, + } + } + + async function waitFor(file: string, timeoutMs = 30000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${file}`) + } + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + /** + * Another process: denies LEAF, holds its sandbox until `go` is there, + * and cleans up after it. + */ + function holder(up: string, go: string): ChildProcess { + const file = join(BASE, 'holder.ts') + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(`echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LEAF)}] },`, + `})`, + `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + ].join('\n'), + ) + return spawn(process.execPath, [file], { stdio: 'ignore' }) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be denied by a second wrap whose deny path is exactly that directory', + async () => { + // A live manifest names it, which used to get it /dev/null bound over + // it like a file mount point: bubblewrap cannot put a character device + // over a directory, and the command did not start. + const up = join(AREA, 'up-holder') + const go = join(AREA, 'go-holder') + const held = holder(up, go) + const heldExited = new Promise(resolve => held.on('exit', resolve)) + try { + await waitFor(up) + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + const command = await wrap(`touch ${DIRECTORY}/x; echo rc=$?`, [ + DIRECTORY, + ]) + expect(command).not.toContain(`--ro-bind /dev/null ${DIRECTORY}`) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + const second = run(command) + expect(second.said).not.toContain('bwrap:') + expect(second.status).toBe(0) + expect(second.stdout).toMatch(/rc=[1-9]/) + } finally { + writeFileSync(go, '') + await heldExited + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be denied again by the same list, with the earlier wrap of this process not yet cleaned up after', + async () => { + // No second process and nothing running: a manifest is live for as + // long as the process that wrote it has not let go of it. A deny list + // that names a missing directory and something beneath it makes the + // directory the mount point, and the same list again then finds it + // named by a live manifest. It is what the mandatory denies under + // `/.claude` make of a `denyWrite` on `/.claude` itself. + const first = run(await wrap('echo first-ran', [DIRECTORY, LEAF])) + expect(first.stdout).toContain('first-ran') + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + + const command = await wrap( + `echo second-ran; touch ${DIRECTORY}/x; echo rc=$?`, + [DIRECTORY, LEAF], + ) + expect(command).not.toContain(`--ro-bind /dev/null ${DIRECTORY}`) + const second = run(command) + expect(second.said).not.toContain('bwrap:') + expect(second.stdout).toContain('second-ran') + expect(second.stdout).toMatch(/rc=[1-9]/) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while that second sandbox runs, after the sandbox that made it has ended and been cleaned up after', + async () => { + // Bound onto itself and not named, it went with the sandbox that made + // it: the bind came off inside the second sandbox, the denied + // directory could be made again there, and what was written under it + // landed on the host. + const upHolder = join(AREA, 'up-holder') + const goHolder = join(AREA, 'go-holder') + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const held = holder(upHolder, goHolder) + const heldExited = new Promise(resolve => held.on('exit', resolve)) + let said = '' + try { + await waitFor(upHolder) + const second = spawn( + await wrap( + `echo up > ${upSecond}; while [ ! -e ${goSecond} ]; do sleep 0.05; done; mkdir -p ${LEAF}; echo pwned > ${LEAF}/x.md; echo rc=$?`, + [DIRECTORY], + ), + { shell: true }, + ) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => + second.on('exit', resolve), + ) + await waitFor(upSecond) + + writeFileSync(goHolder, '') + await heldExited // and its own clean-up has run + await new Promise(resolve => setTimeout(resolve, 700)) + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(true) + + writeFileSync(goSecond, '') + await secondExited + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(join(LEAF, 'x.md'))).toBe(false) + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + } finally { + writeFileSync(goHolder, '') + writeFileSync(goSecond, '') + await heldExited + } + }, + 90000, + ) + }, +) + +/** + * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID + * is, and /proc/locks leaves out every lock whose holder has no pid in the + * namespace of the /proc being read. A process that cleans up from another PID + * namespace while sharing the manifest directory - a container beside the + * host, one srt inside another's sandbox - sees a running sandbox's manifest + * exactly as it would see one a dead process left. + */ +describe.if(isLinux)( + 'A mount point a running sandbox relies on, seen from another PID namespace', + () => { + usePrivateManifestDirectory() + // A second PID namespace with a /proc of its own, uid unchanged, no root. + const IN_NEW_PID_NAMESPACE = [ + 'unshare', + '--user', + '--map-current-user', + '--pid', + '--fork', + '--mount-proc', + ] + const CAN = + bwrapCanNamespace() && + spawnSync(IN_NEW_PID_NAMESPACE[0]!, [ + ...IN_NEW_PID_NAMESPACE.slice(1), + 'true', + ]).status === 0 + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + let BASE: string + let AREA: string + let LOCK: string + let SIGNAL: string + let GO: string + let OUT: string + let WRAPPED: string + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pidns-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + OUT = join(BASE, 'out.txt') + WRAPPED = join(BASE, 'wrapped.txt') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + function writeScript(name: string, lines: string[]): string { + const file = join(BASE, name) + writeFileSync(file, lines.join('\n')) + return file + } + + async function waitFor(file: string, timeoutMs = 30000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${file}`) + } + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + it.if(CAN)( + 'survives a cleanup run where neither its writer nor its lock can be seen', + async () => { + const command = `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` + const holder = spawn( + process.execPath, + [ + writeScript('holder.ts', [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `writeFileSync(${JSON.stringify(WRAPPED)}, wrapped)`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(${JSON.stringify(OUT)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ]), + ], + // Outside every allowed write path, so no mount points for cwd. + { stdio: 'ignore', cwd: import.meta.dir }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + let manifest: string + try { + await waitFor(SIGNAL) + manifest = /--lock-file (\S+)/.exec( + readFileSync(WRAPPED, 'utf8'), + )![1]! + expect(existsSync(manifest)).toBe(true) + expect(lstatSync(LOCK).size).toBe(0) + // Past the grace: only the writer's pid and the kernel's lock vouch + // for the manifest now, and neither can be seen from over there. + await new Promise(resolve => setTimeout(resolve, 800)) + + const collector = spawnSync( + IN_NEW_PID_NAMESPACE[0]!, + [ + ...IN_NEW_PID_NAMESPACE.slice(1), + process.execPath, + writeScript('collector.ts', [ + `import { existsSync, readFileSync, statSync } from 'node:fs'`, + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + // The premise, so that a pass means something: from here the + // kernel's list really does leave the sandbox's lock out, and + // the writer's pid is nobody's. + `const inode = statSync(${JSON.stringify(manifest)}).ino`, + `if (readFileSync('/proc/locks', 'utf8').includes(':' + inode + ' ')) process.exit(7)`, + `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, + `cleanupBwrapMountPoints()`, + ]), + ], + { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + ) + expect(collector.status).toBe(0) + + // The sandbox is still running: both must still be there. + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(manifest)).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await holderExited + expect(readFileSync(OUT, 'utf8')).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK) ? readFileSync(LOCK, 'utf8') : '').toBe('') + + // And it still goes once nothing runs under it, from the namespace + // that can tell: the holder's exit handler, or this cleanup. + await new Promise(resolve => setTimeout(resolve, 600)) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifest)).toBe(false) + }, + 90000, + ) + }, +) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts new file mode 100644 index 000000000..7bbd66250 --- /dev/null +++ b/test/sandbox/mount-point-manifests.test.ts @@ -0,0 +1,978 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { + accessSync, + chmodSync, + constants, + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + statSync, + statfsSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + collectMountPoints, + publishMountPointManifest, +} from '../../src/sandbox/bwrap-mount-manifests.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' + +/** + * The manifests and the pass that collects on their word, below the level of a + * sandbox: what a pass believes, what it looks at again before it removes + * anything, and that nothing about the directory's lock can hold a process up + * or be relied on. + */ +describe.if(isLinux)('The mount point manifests', () => { + const runtime = usePrivateManifestDirectory() + const MODULE = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/bwrap-mount-manifests.ts'), + ) + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const NOT_ROOT = process.getuid?.() !== 0 + const OWN_NAMESPACE = isLinux ? readlinkSync('/proc/self/ns/pid') : '' + + let BASE: string + let DIR: string // where the manifests go + let LOCK: string // the directory's lock + let X: string // a mount point an earlier sandbox left + let TMP: string // the temp dir of every child process + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-manifests-'))) + TMP = join(BASE, 'tmp') + mkdirSync(TMP) + DIR = runtime.manifestDir() + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + chmodSync(DIR, 0o700) + LOCK = join(DIR, 'directory.lock') + X = join(BASE, 'config.lock') + }) + + afterEach(() => { + for (const name of readdirSync(DIR)) { + const file = join(DIR, name) + try { + chmodSync(file, 0o700) + } catch { + // A dangling link. + } + rmSync(file, { recursive: true, force: true }) + } + collectMountPoints() + rmSync(BASE, { recursive: true, force: true }) + }) + + /** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ + function leftover(p: string): void { + writeFileSync(p, '') + chmodSync(p, 0o444) + } + + /** A pid nothing in this PID namespace has. */ + function deadPid(): number { + for (let pid = 4194000; pid > 1; pid--) { + if (!existsSync(`/proc/${pid}`)) return pid + } + throw new Error('no free pid') + } + + /** + * The manifest of a wrap whose process is gone and which is long past its + * grace: what a killed process leaves. `fields` overrides what it says. + */ + function manifestOfADeadProcess( + paths: string[], + fields: Record = {}, + ): string { + const pid = deadPid() + const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) + const body: Record = { + version: 1, + pid, + start: '1', + ns: OWN_NAMESPACE, + created: Date.now() - 60_000, + paths, + sources: [], + ...fields, + } + for (const [key, value] of Object.entries(body)) { + if (value === undefined) delete body[key] + } + writeFileSync(file, JSON.stringify(body), { mode: 0o600 }) + return file + } + + /** + * Runs `source` with the module as `m` in a process of its own, which is + * killed if it has not ended by itself: what these cases guard against blocks + * the thread it happens on, so a test in this process could not time it out. + * `first` runs before the module is loaded, and an `env` entry that is + * undefined is one the child does not have. + */ + function inAChild( + source: string, + options: { + launcher?: string[] + env?: Record + first?: string + } = {}, + ): { status: number | null; ms: number; stdout: string } { + const script = join(BASE, `child-${Math.random().toString(16).slice(2)}.ts`) + writeFileSync( + script, + `${options.first ?? ''}\nconst m = await import(${MODULE})\n${source}\n`, + ) + const argv = [...(options.launcher ?? []), process.execPath, script] + // A temp dir of its own: it is where the manifests go when the runtime + // directory will not do, and the real one is every other process's. + const env: Record = { + ...process.env, + TMPDIR: TMP, + ...options.env, + } + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete env[name] + } + const began = Date.now() + const run = spawnSync(argv[0]!, argv.slice(1), { + env: env as Record, + encoding: 'utf8', + timeout: 6000, + killSignal: 'SIGKILL', + }) + return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + } + + const COLLECT = `m.collectMountPoints()` + const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` + + // ---- the directory's lock never holds a process up --------------------- + // + // Whatever is at the lock's name, a collect and a publish both come back: + // by the deadline when it is somebody's lock, and at once when it is nothing + // this process could ever take. Each used to go round for ever, on the one + // thread, in the wrap, in the clean-up after a command and in the exit + // handler. "At once" is held to well under the two seconds somebody's lock + // is waited for: the wait is made four times over by one command. + const AT_ONCE_MS = 1500 + + const notALock: [string, () => void, boolean][] = [ + ['a directory', () => mkdirSync(LOCK), true], + [ + 'a FIFO nobody writes to', + () => expect(spawnSync('mkfifo', [LOCK]).status).toBe(0), + true, + ], + [ + 'a link to nothing', + () => symlinkSync(join(BASE, 'nothing-here'), LOCK), + true, + ], + [ + 'a file that cannot be read', + () => { + writeFileSync(LOCK, `${deadPid()} 1 ${OWN_NAMESPACE}\n`) + chmodSync(LOCK, 0o000) + }, + NOT_ROOT, + ], + ] + for (const [what, plant, applies] of notALock) { + it.if(applies)( + `comes back from a collect when its lock is ${what}`, + () => { + plant() + const collected = inAChild(COLLECT) + expect(collected.status).toBe(0) + expect(collected.ms).toBeLessThan(AT_ONCE_MS) + }, + 15000, + ) + + it.if(applies)( + `records a wrap's mount points all the same when its lock is ${what}`, + () => { + plant() + const published = inAChild(PUBLISH) + expect(published.status).toBe(0) + expect(published.ms).toBeLessThan(AT_ONCE_MS) + // A wrap is not refused, nor left unrecorded, for want of the lock. + const manifest = JSON.parse(published.stdout) as { file: string } + expect(readFileSync(manifest.file, 'utf8')).toContain('/nonexistent/x') + }, + 15000, + ) + } + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'comes back from a collect at once where the directory is read-only from here, and collects nothing on its word', + () => { + // What a process inside a sandbox sees of the directory: bound + // read-only, with a lock in it that it could never remove. It keeps its + // own manifests elsewhere, and what is in this one is not for it to + // judge: it could remove the mount point and not the manifest. + const lock = `${deadPid()} 1 ${OWN_NAMESPACE}\n` + writeFileSync(LOCK, lock) + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const collected = inAChild(COLLECT, { + launcher: [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--ro-bind', + DIR, + DIR, + '--', + ], + }) + expect(collected.status).toBe(0) + expect(collected.ms).toBeLessThan(AT_ONCE_MS) + expect(readFileSync(LOCK, 'utf8')).toBe(lock) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }, + 15000, + ) + + it('waits for a lock somebody holds, and no longer than the wait', () => { + // This process, which is running, in this namespace. + const start = readFileSync('/proc/self/stat', 'utf8') + .split(') ') + .pop()! + .split(' ')[19] + writeFileSync(LOCK, `${process.pid} ${start} ${OWN_NAMESPACE}\n`) + leftover(X) + manifestOfADeadProcess([X]) + + const collected = inAChild(COLLECT) + expect(collected.status).toBe(0) + expect(collected.ms).toBeGreaterThan(1500) + expect(collected.ms).toBeLessThan(5500) + // The pass was left to the holder: nothing was removed, its lock stands. + expect(existsSync(X)).toBe(true) + expect(readFileSync(LOCK, 'utf8')).toContain(`${process.pid} `) + }, 15000) + + it('believes a lock that names nobody until it is old, and breaks it then', () => { + // An empty lock file is what a lock made in two steps looked like for a + // moment, and a waiter that read it then took it for nobody's and removed + // it at once: two processes inside. The lock is made whole now, and one + // that says nothing is believed like any other whose holder cannot be + // asked after. + writeFileSync(LOCK, '') + leftover(X) + manifestOfADeadProcess([X]) + + const young = inAChild(COLLECT) + expect(young.status).toBe(0) + expect(young.ms).toBeGreaterThan(1500) + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(X)).toBe(true) + + const longAgo = new Date(Date.now() - 61_000) + utimesSync(LOCK, longAgo, longAgo) + const old = inAChild(COLLECT) + expect(old.status).toBe(0) + expect(old.ms).toBeLessThan(4000) + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(X)).toBe(false) + }, 20000) + + it('breaks at once the lock of a holder in this namespace that is gone, and not one from another namespace', () => { + leftover(X) + manifestOfADeadProcess([X]) + + // Its pid is no process here, which says nothing about a holder whose pid + // was given in another namespace. + writeFileSync(LOCK, `${deadPid()} 1 pid:[1]\n`) + const elsewhere = inAChild(COLLECT) + expect(elsewhere.status).toBe(0) + expect(elsewhere.ms).toBeGreaterThan(1500) + expect(existsSync(X)).toBe(true) + + writeFileSync(LOCK, `${deadPid()} 1 ${OWN_NAMESPACE}\n`) + const here = inAChild(COLLECT) + expect(here.status).toBe(0) + // The pass ran, which it does not after waiting in vain. + expect(existsSync(X)).toBe(false) + expect(existsSync(LOCK)).toBe(false) + }, 20000) + + // ---- nothing rests on the lock ----------------------------------------- + + /** + * Runs `during` once, in the middle of the next pass: after it has listed + * the manifests and decided what is finished, before it removes anything. + */ + function duringTheNextPass(during: () => void): { restore(): void } { + const readFile = fs.readFileSync + let done = false + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks' && !done) { + done = true + during() + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('removes what a finished manifest names once nothing else names it', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + expect(collectMountPoints()).toEqual([X]) + expect(existsSync(X)).toBe(false) + expect(existsSync(manifest)).toBe(false) + }) + + it('keeps a mount point named by a manifest that is published while the pass is under way', () => { + // The lock does not exclude: its holder can be held up past the age at + // which it is broken, breaking one is not atomic, and a publish that + // cannot have it goes ahead without. So a manifest can appear between a + // pass listing the directory and its removals, naming a path the pass is + // about to remove, with a sandbox starting under it. + leftover(X) + manifestOfADeadProcess([X]) + let published: { status: number | null; stdout: string } | undefined + const pass = duringTheNextPass(() => { + rmSync(LOCK, { force: true }) // what a waiter breaking the lock does + published = inAChild( + `console.log(JSON.stringify(m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null))`, + ) + }) + let removed: string[] + try { + removed = collectMountPoints() + } finally { + pass.restore() + } + expect(published?.status).toBe(0) + const arrived = (JSON.parse(published!.stdout) as { file: string }).file + expect(readFileSync(arrived, 'utf8')).toContain(X) + expect(removed).toEqual([]) + expect(existsSync(X)).toBe(true) + }, 15000) + + it('gives up only a lock that is still its own', () => { + // Held up for longer than a lock is believed, a pass finds on its way out + // that the lock at that name is somebody else's by now. Removing it by + // name let a third process in beside the second. + const theirs = `${process.ppid} 1 pid:[1]\n` + let mine = '' + const pass = duringTheNextPass(() => { + mine = readFileSync(LOCK, 'utf8') + rmSync(LOCK) + writeFileSync(LOCK, theirs) + }) + try { + collectMountPoints() + } finally { + pass.restore() + } + // Whole while it was held: who, since when, and where that can be asked. + expect(mine).toMatch( + new RegExp( + `^${process.pid} \\d+ ${OWN_NAMESPACE.replace(/[[\]]/g, '\\$&')}\n$`, + ), + ) + expect(readFileSync(LOCK, 'utf8')).toBe(theirs) + }) + + it('makes its lock whole: it is never there with nothing in it', () => { + // Linked into place from a file already written, so that nobody who finds + // it can find it empty. + const linked: string[] = [] + const link = fs.linkSync + const spy = spyOn(fs, 'linkSync').mockImplementation((( + from: string, + to: string, + ) => { + if (to === LOCK) linked.push(readFileSync(from, 'utf8')) + return link(from, to) + }) as never) + try { + collectMountPoints() + } finally { + spy.mockRestore() + } + expect(linked.length).toBe(1) + expect(linked[0]).toMatch(/^\d+ \d+ \S+\n$/) + // And nothing of the making is left behind. + expect(readdirSync(DIR)).toEqual([]) + }) + + it('looks at /proc/locks a bounded number of times, not once for every mount point', () => { + // The list is the host's, and reading all of it for each removal made a + // pass over many mount points take seconds, under a lock whose waiters + // give up after two. + const many = 500 + const paths: string[] = [] + for (let i = 0; i < many; i++) { + const p = join(BASE, `.placeholder-${i}`) + leftover(p) + paths.push(p) + } + expect(publishMountPointManifest(paths, [])).toBeDefined() + const spy = spyOn(fs, 'readFileSync') + let removed: string[] + let looks: number + let tookMs: number + try { + const began = Date.now() + removed = collectMountPoints() + tookMs = Date.now() - began + looks = spy.mock.calls.filter(([file]) => file === '/proc/locks').length + } finally { + spy.mockRestore() + } + expect(removed.length).toBe(many) + // One to begin with, one before the first removal, one for every 64 + // removals after that, and one more for every two milliseconds the pass + // took, since a look goes out of date with time as well. No fewer than + // that either: one look is not good for ever. + expect(looks).toBeGreaterThanOrEqual(1 + Math.ceil(many / 64)) + expect(looks).toBeLessThanOrEqual( + 3 + Math.ceil(many / 64) + Math.ceil(tookMs / 2), + ) + expect(looks).toBeLessThan(many) + }) + + it.if(NOT_ROOT)( + 'keeps the manifest of a mount point it could not remove, for a pass that can', + () => { + // Seen read-only from here, as from inside a sandbox that may not write + // the directory it is in. The manifest is all that says the file is a + // mount point and whose; with it gone the file stayed for good. + const readOnly = join(BASE, 'read-only-from-here') + mkdirSync(readOnly) + const inside = join(readOnly, 'config.lock') + leftover(inside) + const manifest = manifestOfADeadProcess([inside]) + chmodSync(readOnly, 0o555) + try { + expect(collectMountPoints()).toEqual([]) + expect(existsSync(inside)).toBe(true) + expect(existsSync(manifest)).toBe(true) + } finally { + chmodSync(readOnly, 0o755) + } + expect(collectMountPoints()).toEqual([inside]) + expect(existsSync(manifest)).toBe(false) + }, + ) + + // ---- a manifest goes last, this process's own like anybody's ----------- + // + // A manifest is all that names its mount points. One this process wrote + // used to be unlinked as soon as the caller said its command was over, + // before the pass knew whether it would get as far as the removals or + // whether they would work; after a pass that turned back, or a removal that + // was refused, nothing on disk named the mount points and no later pass + // removed them. + + it.if(NOT_ROOT)( + 'keeps the manifest of a wrap of its own whose mount point it could not remove, for a pass that can', + () => { + const readOnly = join(BASE, 'read-only-from-here') + mkdirSync(readOnly) + const inside = join(readOnly, 'config.lock') + leftover(inside) + const manifest = publishMountPointManifest([inside], [])!.file + chmodSync(readOnly, 0o555) + try { + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(inside)).toBe(true) + expect(existsSync(manifest)).toBe(true) + } finally { + chmodSync(readOnly, 0o755) + } + // Whatever the next pass is told to release: that this wrap is over has + // been said already. + expect(collectMountPoints('none')).toEqual([inside]) + expect(existsSync(manifest)).toBe(false) + }, + ) + + it('keeps the manifest of a wrap of its own when the pass turns back half way, for the next', () => { + leftover(X) + const manifest = publishMountPointManifest([X], [])!.file + // Another version of this library publishes, in a layout this one cannot + // read, after the pass has listed the directory: what that names cannot be + // kept path by path, so the pass stops short of removing anything. + const stranger = join(DIR, '4242-0123456789abcdef.json') + const pass = duringTheNextPass(() => + writeFileSync(stranger, '{"version":2}'), + ) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + + rmSync(stranger) + expect(collectMountPoints('none')).toEqual([X]) + expect(existsSync(manifest)).toBe(false) + }) + + it('turns back, keeping everything, at an unreadable manifest that may have a sandbox starting under it', () => { + leftover(X) + const theirs = manifestOfADeadProcess([X]) + const own = publishMountPointManifest([join(BASE, 'other')], [])!.file + const stranger = join(DIR, '1-garbage.json') + writeFileSync(stranger, '{ not json') + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(theirs)).toBe(true) + expect(existsSync(own)).toBe(true) + // Once it is old enough that nothing can be starting under it, and no + // lock is on it, it stands in the way of nothing. + const halfAMinuteAgo = new Date(Date.now() - 30_000) + utimesSync(stranger, halfAMinuteAgo, halfAMinuteAgo) + expect(collectMountPoints('none')).toEqual([X]) + expect(existsSync(own)).toBe(false) + expect(existsSync(stranger)).toBe(true) + }) + + it('removes nothing when /proc/locks cannot be read', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const readFile = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks') { + throw Object.assign(new Error('EACCES: /proc/locks'), { + code: 'EACCES', + }) + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + it('keeps what a finished manifest names when its lock shows on the look before the removals', () => { + // The bubblewrap a wrap has handed to its caller takes its lock whenever + // the caller starts it, which no lock of this library's can put off: the + // kernel is asked again before anything goes. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n` + const readFile = fs.readFileSync + let reads = 0 + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks') { + reads++ + return reads === 1 ? '' : line + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(reads).toBeGreaterThan(1) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + it('lists the directory again between removals, not only before the first', () => { + // A sandbox about to start on a path shows as a manifest that was not + // there when the pass began, and it can appear at any point of a pass over + // many mount points. One listing is good for a quarter of a millisecond, + // far less than a sandbox needs to get to its binds. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X, Y]) + const unlink = fs.unlinkSync + let arrived: string | undefined + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + unlink(file) + if (file === X && arrived === undefined) { + // Published the moment the first mount point has gone, with the pass + // held up for a millisecond before it goes on to the next: less than + // the two for which it trusts what it read from /proc/locks. + arrived = manifestOfADeadProcess([Y], { + pid: process.pid, + created: Date.now(), + }) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) + } + }) as never) + let removed: string[] + try { + removed = collectMountPoints() + } finally { + spy.mockRestore() + } + expect(arrived).toBeDefined() + expect(removed).toEqual([X]) + expect(existsSync(Y)).toBe(true) + }) + + it('is live for half a second from when it was written, though its writer is gone', () => { + // bubblewrap takes the lock in the sandbox's init process, a moment after + // a wrapping process killed just then could still have vouched for it. + leftover(X) + const manifest = manifestOfADeadProcess([X], { created: Date.now() }) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + // ---- a manifest is only a claim about a path --------------------------- + + it('leaves what a finished manifest names when it no longer looks like a mount point', () => { + const written = join(BASE, 'written-to') + writeFileSync(written, 'mine') + chmodSync(written, 0o444) + const writable = join(BASE, 'writable') + writeFileSync(writable, '') + chmodSync(writable, 0o644) + const linked = join(BASE, 'linked') + leftover(linked) + linkSync(linked, join(BASE, 'linked-again')) + const pointer = join(BASE, 'pointer') + leftover(join(BASE, 'pointed-at')) + symlinkSync(join(BASE, 'pointed-at'), pointer) + const inUse = join(BASE, 'directory-in-use') + mkdirSync(inUse) + writeFileSync(join(inUse, 'file'), '') + const manifest = manifestOfADeadProcess([ + written, + writable, + linked, + pointer, + inUse, + ]) + expect(collectMountPoints()).toEqual([]) + for (const kept of [written, writable, linked, pointer, inUse]) { + expect(existsSync(kept)).toBe(true) + } + expect(existsSync(join(BASE, 'pointed-at'))).toBe(true) + // None of them is a mount point any more, so the manifest has nothing + // left to say. + expect(existsSync(manifest)).toBe(false) + }) + + it("leaves a file, and an empty directory, that is somebody else's", () => { + const directory = join(BASE, 'empty-directory') + leftover(X) + mkdirSync(directory) + manifestOfADeadProcess([X, directory]) + // As another user's would look: nothing here can make one. + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file === X || file === directory) { + const theirs = Object.create(Object.getPrototypeOf(stat)) as fs.Stats + return Object.assign(theirs, stat, { uid: stat.uid + 1 }) + } + return stat + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(directory)).toBe(true) + }) + + it('takes away what a killed process left half written, once it is old', () => { + const old = [ + join(DIR, 'directory.lock.4242.0123456789abcdef.tmp'), + join(DIR, '4242-0123456789abcdef.json.tmp'), + ] + const young = join(DIR, '4243-0123456789abcdef.json.tmp') + for (const file of [...old, young]) writeFileSync(file, '{}') + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + for (const file of old) utimesSync(file, hoursAgo, hoursAgo) + collectMountPoints() + for (const file of old) expect(existsSync(file)).toBe(false) + // This one may be on its way into place. + expect(existsSync(young)).toBe(true) + }) + + // ---- liveness is relative to a PID namespace --------------------------- + // + // /proc/locks lists a lock only when its holder has a pid in the namespace + // of the /proc being read, and /proc/PID is local to it. From another + // namespace a running sandbox's manifest looks exactly like one a dead + // process left. + + it.each([ + ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], + [ + 'written in another PID namespace a long time ago', + 'kept', + { ns: 'pid:[1]', created: Date.now() - 7 * 24 * 3600 * 1000 }, + ], + ['that does not say where it was written', 'kept', { ns: undefined }], + [ + 'that does not say where it was written, from hours ago', + 'collected', + { ns: undefined, created: Date.now() - 2 * 3600 * 1000 }, + ], + ['written in this PID namespace', 'collected', {}], + ] as const)( + 'a manifest %s, with no writer and no lock to be seen, is %s', + (_what, outcome, fields) => { + leftover(X) + const manifest = manifestOfADeadProcess([X], fields) + collectMountPoints() + expect(existsSync(X)).toBe(outcome === 'kept') + expect(existsSync(manifest)).toBe(outcome === 'kept') + }, + ) + + // ---- an inode number is not a file ------------------------------------- + + /** Has the next passes read `text` for /proc/locks. */ + function withProcLocks(text: string): { restore(): void } { + const readFile = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => + file === '/proc/locks' + ? text + : (readFile as (f: unknown, o: unknown) => unknown)( + file, + options, + )) as never) + return { restore: () => spy.mockRestore() } + } + + /** `major:minor` of the filesystem `p` is on, as /proc/locks prints it. */ + function deviceOf(p: string): string { + const dev = statSync(p, { bigint: true }).dev + const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) + const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) + const hex = (n: bigint): string => n.toString(16).padStart(2, '0') + return `${hex(major)}:${hex(minor)}` + } + + // tmpfs, ext2/3/4 and xfs report for a file the device /proc/locks prints. + const DEVICES_COMPARE = + isLinux && + [0x01021994, 0xef53, 0x58465342].includes( + Number(statfsSync(realpathSync(tmpdir())).type), + ) + + it.if(DEVICES_COMPARE)( + 'is not kept live by a lock on a file with the same inode number on another filesystem', + () => { + // Inode numbers are per filesystem, and on a young tmpfs they are small. + // A manifest whose number some unrelated locked file shared read as + // live for as long as that lock was held: never released, and every + // path it named a mount point to every later wrap. + leftover(X) + const manifest = publishMountPointManifest([X], [])!.file + const inode = statSync(manifest).ino + const elsewhere = withProcLocks( + `1: POSIX ADVISORY WRITE 4242 fe:77:${inode} 0 EOF\n`, + ) + try { + expect(collectMountPoints()).toEqual([X]) + } finally { + elsewhere.restore() + } + expect(existsSync(manifest)).toBe(false) + }, + ) + + it('is kept live by a lock on the manifest itself', () => { + leftover(X) + const manifest = publishMountPointManifest([X], [])!.file + const inode = statSync(manifest).ino + const onIt = withProcLocks( + `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${inode} 0 EOF\n`, + ) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + onIt.restore() + } + expect(existsSync(manifest)).toBe(true) + expect(existsSync(X)).toBe(true) + // And goes with the lock. + expect(collectMountPoints()).toEqual([X]) + }) + + // ---- where the manifests are kept -------------------------------------- + + const SHM = '/dev/shm' + const SHM_WRITABLE = (() => { + try { + accessSync(SHM, constants.W_OK | constants.X_OK) + return true + } catch { + return false + } + })() + + it.if(SHM_WRITABLE)( + 'are never kept under /dev, which the sandbox mounts afresh over them', + () => { + // bubblewrap opens the manifest after its mounts, and the wrap mounts a + // new /dev after every bind: a manifest under /dev is not there to be + // opened, and every command with a mount point refused to start. + const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) + chmodSync(shm, 0o700) + try { + const elsewhere = TMP + const fellBack = inAChild(PUBLISH, { + env: { XDG_RUNTIME_DIR: shm, TMPDIR: elsewhere }, + }) + expect(fellBack.status).toBe(0) + const manifest = JSON.parse(fellBack.stdout) as { file: string } + expect(manifest.file.startsWith(`${elsewhere}/`)).toBe(true) + expect(readdirSync(shm)).toEqual([]) + + // With nowhere else to go it records nothing, rather than somewhere + // bubblewrap cannot reach. + const env: Record = { ...process.env, TMPDIR: shm } + delete env.XDG_RUNTIME_DIR + const script = join(BASE, 'nowhere.ts') + writeFileSync(script, `const m = await import(${MODULE})\n${PUBLISH}\n`) + const nowhere = spawnSync(process.execPath, [script], { + env, + encoding: 'utf8', + timeout: 6000, + }) + expect(nowhere.status).toBe(0) + expect(JSON.parse(nowhere.stdout)).toBe(null) + expect(readdirSync(shm)).toEqual([]) + } finally { + rmSync(shm, { recursive: true, force: true }) + } + }, + 15000, + ) + + it("are not kept behind a link planted at the directory's name, whose target is left as it was", () => { + // The name is predictable and sits where sandboxed commands commonly + // write. A link there is refused, not followed: its target is somebody's + // directory, and would have had its mode changed. + const target = join(BASE, 'somebody-elses') + mkdirSync(target) + chmodSync(target, 0o755) + symlinkSync(target, join(TMP, `srt-mount-points-${process.getuid!()}`)) + const published = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: undefined } }) + expect(published.status).toBe(0) + expect(statSync(target).mode & 0o777).toBe(0o755) + expect(readdirSync(target)).toEqual([]) + // Recorded all the same, in a directory of this process's own. + const manifest = JSON.parse(published.stdout) as { file: string } + expect(dirname(manifest.file)).toMatch( + new RegExp(`^${TMP}/srt-mount-points-[A-Za-z0-9]{6}$`), + ) + }) + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'go to a directory this process can write when the usual one is read-only from here', + () => { + // A process inside a sandbox sees the directory bound read-only. It is + // ours in every other respect, and used to be settled on, after which + // every wrap of that process failed to record its mount points. + const elsewhere = TMP + const published = inAChild(PUBLISH, { + launcher: [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--ro-bind', + DIR, + DIR, + '--', + ], + }) + expect(published.status).toBe(0) + const manifest = JSON.parse(published.stdout) as { file: string } | null + expect(manifest).not.toBe(null) + expect(dirname(manifest!.file)).not.toBe(DIR) + expect(manifest!.file.startsWith(`${elsewhere}/`)).toBe(true) + }, + 15000, + ) + + // ---- all of it is bubblewrap's, which is Linux's ----------------------- + + it('does nothing at all where the platform is not Linux', () => { + // Every clean-up after a command, on every platform, comes through here. + // Off Linux it made the directory and its lock each time, and on a + // platform with no uids a fresh temporary directory each time. + leftover(X) + manifestOfADeadProcess([X]) + const before = readdirSync(DIR).sort() + const elsewhere = inAChild( + [ + `const u = await import(${LIBRARY})`, + `const published = m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null`, + `const live = [...m.liveMountPoints()]`, + `const directories = m.mountPointManifestDirectories()`, + `const collected = m.collectMountPoints()`, + `u.cleanupBwrapMountPoints()`, + `u.cleanupBwrapMountPoints({ force: true })`, + `console.log(JSON.stringify({ published, live, directories, collected }))`, + ].join('\n'), + { + first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, + }, + ) + expect(elsewhere.status).toBe(0) + expect(JSON.parse(elsewhere.stdout)).toEqual({ + published: null, + live: [], + directories: [], + collected: [], + }) + expect(existsSync(X)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual(before) + expect(readdirSync(TMP)).toEqual([]) + }) +}) diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 657ec039d..7c81dc9ed 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -23,6 +23,7 @@ import { import { isLinux } from '../helpers/platform.js' import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' /** Echoed first by every command that runs for real, so an assertion about * what the command did cannot pass on a sandbox that never started. */ @@ -52,6 +53,7 @@ function run( * the sandbox fail with "could not lock config file". */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { + usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area @@ -104,17 +106,20 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(existsSync(LOCK)).toBe(false) }) - it('goes on the first cleanup when no sandbox is running under it', async () => { + it('stays while a wrap of this process is outstanding, and goes with the last of them', async () => { plantLeftover(LOCK) await wrap([LOCK]) await wrap([LOCK]) - // Neither command was started, so nothing relies on the mount point: what - // keeps one is a sandbox holding its manifest's lock, not a count of the - // wraps this process has handed out. A command wrapped but not started by - // then refuses to start rather than running without its mount point (see - // mount-point-liveness.test.ts). + // Two wraps handed out, one cleaned up after: the other command may not + // have started yet, and a command that starts after its mount point or + // its manifest has gone either refuses to start or runs without its deny. + // A call cannot tell which command it is for, so this process gives up + // nothing of its own until it has been called for both. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) + cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }) @@ -238,13 +243,14 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) // A manifest whose writer is gone and that no sandbox has locked counts - // as live for a moment, in case the sandbox it covers is starting: the - // killed process left one, so the cleanup that takes its mount point - // away is the first one after that. - await new Promise(resolve => setTimeout(resolve, 1000)) + // as live for half a second, in case the sandbox it covers is starting: + // the killed process left one, so the cleanup that takes its mount + // point away is the first one after that. + await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }, + 30000, ) }) @@ -257,6 +263,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { describe.if(isLinux)( 'Mount points for deny paths that do not exist yet', () => { + usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area From 3f14b21b29aac20e42a4341c0d36e7a3061d44db Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 15:54:49 +0000 Subject: [PATCH 062/141] docs: describe when mount points are released, and commandId The Linux section says what cleanupAfterCommand() releases when commands overlap, what commandId is for, and where the manifest directory lives. --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 645e223f5..569eb08e7 100644 --- a/README.md +++ b/README.md @@ -736,16 +736,25 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, in a per-user runtime directory (`$XDG_RUNTIME_DIR` when there is one, else the system temp dir), and has bubblewrap hold a lock on that manifest for exactly the sandbox's lifetime, so any srt process can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone, unless the manifest of a sandbox that may still be running names it: then a wrap that denies that same directory names it too, so that it stays for as long as either sandbox runs. + +- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that uses the same manifest directory. +- **Known limits.** + - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. + - _Between looking and removing._ The directory lock does not exclude: a holder can be held up past the minute after which its lock is broken, and a wrap that cannot have the lock within two seconds records its manifest without it. A cleanup pass therefore reads `/proc/locks` and lists the directory again immediately before it removes anything, and then again whenever its listing is a quarter of a millisecond old, and what it read from `/proc/locks` two milliseconds old or 64 removals ago. It removes a manifest last, after everything it names, and keeps one whose mount point it could not remove. A pass that is held up for longer than a sandbox takes to start (a few milliseconds) between such a look and a removal can still take away the mount point of a sandbox that started on the same path in that time. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for a leftover, covers it, and removes it after its own command, whether or not the first sandbox is still running. The ones that could not be recorded at all are never removed by the process that made them either. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released. A string run after that refuses to start rather than running without its mount points. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string run after that refuses to start rather than running without its mount points. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already let go of what it held, so calling `cleanupAfterCommand()` after one is harmless. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) From ed9840501736a280a26f4196b2e326249a738a52 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:41 +0000 Subject: [PATCH 063/141] linux: mount nothing else inside a git directory that is bound whole A git directory this process cannot write (mode 0555, another user's) takes no placeholder for an absent commondir, so it is bound read-only whole. The deny loop had already buffered binds for the directory's other entries, and one for an absent config was emitted after the whole bind: bubblewrap then tried to create a file on a read-only mount and refused to start, for every later command in the tree. A placeholder emitted before the whole bind was no better: bubblewrap made a file in the directory that this process could never remove. When a git directory goes whole, every buffered deny under it is taken back and none is added after it. The recursive bind covers them all. The existing test for that branch asserted only that the bind was present, which the ancestor pin of the same directory also satisfies; it now asserts where the bind sits. --- src/sandbox/linux-sandbox-utils.ts | 38 +++- test/sandbox/linux-git-dir-whole.test.ts | 236 ++++++++++++++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 12 ++ 3 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 test/sandbox/linux-git-dir-whole.test.ts diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index a7aababe2..3dd72ca95 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2850,8 +2850,8 @@ function buildFilesystemArgs( // it, and one wrap needs that once however many destinations share it. const gitRedirectStoreFiles = new Map() // Git directories this wrap denies whole because no mount point could be - // made inside them, so the second redirect file in one does not emit a - // second bind of the same directory. + // made inside them. The bind of one is the only deny bind it gets: every + // other deny destination inside it is dropped at emission. const denyWholeGitDirs = new Set() // The store directory a placeholder came from, pinned read-only with the // other mount sources at the end. Set only where one was actually used. @@ -3966,9 +3966,22 @@ function buildFilesystemArgs( // Write paths already restored read-only by a dropped deny bind, so two // denies covering the same path emit one --ro-bind. const restoredReadOnlyWritePaths = new Set() + // Where the mounts this loop emits start. Every one is a three-word + // --ro-bind, which is what lets a git directory denied whole take back the + // ones inside it. + const denyBindsFrom = args.length for (let i = 0; i < denyWriteArgs.length; i += 3) { const dest = denyWriteArgs[i + 2]! const rawDest = denyWriteRawDests.get(dest) ?? dest + // Inside a git directory this loop has denied whole (below): that bind + // holds `dest` read-only already, and it is the only deny bind the + // directory takes. A placeholder here would need its mount point created + // on a read-only mount, which bubblewrap refuses to start over ("Can't + // create file at : Read-only file system"), and the directory's + // other redirect file would only find it unwritable a second time. + if ([...denyWholeGitDirs].some(gitDir => isStrictlyUnder(dest, gitDir))) { + continue + } // A mask's landing, not its dest: the landing is where the mask's bind // actually sits, and this deny's dest is canonical, so the two are // comparable as written. @@ -4033,14 +4046,21 @@ function buildFilesystemArgs( if ('denyGitDirWhole' in prepared) { const gitDir = prepared.denyGitDirWhole // Read-only over the directory itself: it needs nothing written to - // the host, and it denies every file inside at once — including the - // other redirect file, whose own turn in this loop then has nothing - // left to do. - if (!denyWholeGitDirs.has(gitDir)) { - denyWholeGitDirs.add(gitDir) - args.push('--ro-bind', gitDir, gitDir) - emittedDenyWriteDests.push(gitDir) + // the host, and it denies every file inside at once, so it is the + // only deny bind the directory takes. The destinations inside it + // still to come are skipped at the top of this loop, and the ones + // already emitted are taken back out. This bind lands over those, so + // they deny nothing it does not, and a placeholder among them would + // have bubblewrap, which can write where this process cannot, make a + // mount point no cleanup of this process could remove. A mount point + // this pass has already written or claimed in there stays recorded + // like any other, and goes with the same cleanup. + denyWholeGitDirs.add(gitDir) + for (let at = args.length - 3; at >= denyBindsFrom; at -= 3) { + if (isStrictlyUnder(args[at + 2]!, gitDir)) args.splice(at, 3) } + args.push('--ro-bind', gitDir, gitDir) + emittedDenyWriteDests.push(gitDir) continue } source = prepared.bind diff --git a/test/sandbox/linux-git-dir-whole.test.ts b/test/sandbox/linux-git-dir-whole.test.ts new file mode 100644 index 000000000..73b13d574 --- /dev/null +++ b/test/sandbox/linux-git-dir-whole.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { indexOfMount } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux } from '../helpers/platform.js' + +/** + * A git directory this process cannot write takes no mount point for the + * files git reads back, so the wrap denies it with one read-only bind of the + * directory. That bind has to be the only one the deny binds put there. One + * that needs a mount point made inside the directory either follows the bind, + * where bubblewrap cannot create it on a read-only mount and the command + * never starts, or precedes it, where bubblewrap creates it in a directory + * this process can never take it out of again. + * + * Root writes a 0555 directory like any other, so nothing here applies to it. + */ +describe.if(isLinux && process.getuid?.() !== 0)( + 'Linux sandbox — a git directory denied whole takes no other deny bind', + () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const LIVE = bwrapCanNamespace() + const savedCwd = process.cwd() + let dir: string + let project: string + let gitDir: string + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-dir-whole-'))) + project = join(dir, 'project') + gitDir = join(project, 'x', '.git') + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + // Or the temporary directory cannot be removed. + if (existsSync(gitDir)) chmodSync(gitDir, 0o755) + rmSync(dir, { recursive: true, force: true }) + }) + + /** + * A repository nested in the project whose git directory holds `entries` + * beside its HEAD (a directory for null, a file of those bytes otherwise) + * and is then made unwritable. Whichever of `hooks`, `commondir`, `config` + * and `config.worktree` is not named is an absent deny path, which needs + * a mount point. + */ + function makeUnwritableGitDir( + entries: Record, + ): void { + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + for (const [name, contents] of Object.entries(entries)) { + if (contents === null) mkdirSync(join(gitDir, name)) + else writeFileSync(join(gitDir, name), contents) + } + chmodSync(gitDir, 0o555) + } + + function wrapInProject(command = 'true'): Promise { + process.chdir(project) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + }) + } + + /** + * Every mount emitted after the write root's own bind that lands on the + * git directory or inside it, as the words it was emitted with. An + * ancestor pin spells the directory's own bind too, but before the write + * root's. indexOfMount refuses a command whose mount words went to the + * argument file, where nothing here could see them. + */ + function denyBindsOnGitDir(command: string): string[] { + const writeRootBind = indexOfMount(command, '--bind', project, project) + expect(writeRootBind).toBeGreaterThan(-1) + const argv = command.split(/\s+/) + const found: string[] = [] + for (let i = writeRootBind + 3; i < argv.length; i++) { + const words = + argv[i] === '--tmpfs' ? 2 : /^--(ro-)?bind$/.test(argv[i]!) ? 3 : 0 + const dest = argv[i + words - 1] + if ( + words > 0 && + dest !== undefined && + (dest === gitDir || dest.startsWith(`${gitDir}/`)) + ) { + found.push(argv.slice(i, i + words).join(' ')) + } + } + return found + } + + function run(command: string): { status: number | null; output: string } { + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: project, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + } + } + + it('emits no placeholder inside the git directory after the bind of it', async () => { + // `hooks` is there, and is emitted before `commondir` finds that the + // directory takes no mount point; `config` is not there, and its turn + // comes after. A placeholder for it then is a file bubblewrap has to + // create on the read-only mount it has just made. `config.worktree` + // finds nothing left to do, and adds no second bind of the directory. + makeUnwritableGitDir({ hooks: null }) + + const command = await wrapInProject() + + expect(denyBindsOnGitDir(command)).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }) + + it('emits none where no redirect file comes after it either', async () => { + // A `config.worktree` of the caller's own takes a bind from itself and + // never asks the directory for a mount point, so nothing after the + // absent `config` finds the directory unwritable a second time. + makeUnwritableGitDir({ + hooks: null, + 'config.worktree': '[core]\n', + }) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + }) + + it('emits no placeholder inside the git directory before the bind of it', async () => { + // No `hooks` either: its placeholder is buffered ahead of `commondir`, + // and bubblewrap, which can write the directory where this process + // cannot, would create a mount point no cleanup here could remove. + makeUnwritableGitDir({}) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + }) + + it('still takes away a redirect file it claimed before the directory went whole', async () => { + // A `commondir` a killed wrap left holding the placeholder is claimed + // without writing to the directory; `config.worktree`, which is absent, + // is what finds the directory unwritable. The bind the claim emitted is + // taken back with every other inside the directory, and the claim + // stands: once the directory can be written again the file goes with + // the rest of this wrap's mount points. + makeUnwritableGitDir({ hooks: null, commondir: '.\n' }) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + chmodSync(gitDir, 0o755) + cleanupBwrapMountPoints({ force: true }) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }) + + it.if(LIVE)( + 'runs the command, with the git directory read-only and the work tree writable', + async () => { + makeUnwritableGitDir({ hooks: null }) + + const first = run( + await wrapInProject( + `echo ${BOOTED}; echo x > x/.git/probe; echo ok > x/work.txt && echo WORK_OK`, + ), + ) + + expect(first.output).toContain(BOOTED) + expect(first.status).toBe(0) + expect(first.output).toContain('Read-only file system') + expect(first.output).toContain('WORK_OK') + expect(existsSync(join(gitDir, 'probe'))).toBe(false) + expect(existsSync(join(project, 'x', 'work.txt'))).toBe(true) + + // Not something one command can do to the next: the directory is as + // unwritable to the second as it was to the first. + cleanupBwrapMountPoints() + const second = run(await wrapInProject(`echo ${BOOTED}`)) + expect(second.output).toContain(BOOTED) + expect(second.status).toBe(0) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }, + ) + + it.if(LIVE)( + 'leaves nothing behind in a git directory with no hooks either', + async () => { + makeUnwritableGitDir({}) + + const result = run( + await wrapInProject( + `echo ${BOOTED}; mkdir x/.git/hooks || echo HOOKS_DENIED`, + ), + ) + + expect(result.output).toContain(BOOTED) + expect(result.status).toBe(0) + expect(result.output).toContain('HOOKS_DENIED') + cleanupBwrapMountPoints() + expect(readdirSync(gitDir)).toEqual(['HEAD']) + }, + ) + }, +) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index bc6da7207..897ef8fb1 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -6692,6 +6692,18 @@ describe.if(isLinux)('Placeholders for the files git reads', () => { expect(lastMountAt(command, gitDir)).toBe( `--ro-bind ${gitDir} ${gitDir}`, ) + // A deny bind is emitted after the write root's own bind; an + // ancestor pin spells the same words before it. + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), + ).toBeGreaterThan(writeRootBind) expect(existsSync(join(gitDir, 'commondir'))).toBe(false) expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) } finally { From ad5b7c31f616f639c6a5f4ea6d9842bef8261631 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:41 +0000 Subject: [PATCH 064/141] linux: repair a placeholder through the file that was checked, not its name gitRedirectMountPoint looked at /commondir with lstat and then read, wrote and changed the mode of the PATH. The git directory can be one a command that is still running writes, so a name swapped for a link between the check and the write had the unsandboxed process truncate the link's target and make it writable, and a FIFO swapped in blocked the read for ever. The name is now opened once with O_NOFOLLOW and O_NONBLOCK, the descriptor is what is checked, and the rewrite goes through a second descriptor verified to be the same file. A name that has been given to something else is left as found and the git directory is bound read-only whole for the command. The cleanup unlinks a mount point only while its name still leads to the file whose content it checked. --- src/sandbox/linux-sandbox-utils.ts | 184 +++++- .../linux-git-redirect-placeholder.test.ts | 583 ++++++++++++++++++ 2 files changed, 734 insertions(+), 33 deletions(-) create mode 100644 test/sandbox/linux-git-redirect-placeholder.test.ts diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 3dd72ca95..726d6e28f 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -273,18 +273,117 @@ function hasFileAncestor(targetPath: string): boolean { return false } +/** + * How a name is opened in a git directory a command already running may be + * writing: one that made the directory after its own wrap has nothing of it + * mounted. A link there is refused rather than followed, because it leads + * wherever that command chose, and a FIFO there is not waited on. + */ +const NO_LINK_NO_WAIT = fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK + +/** + * What an open with {@link NO_LINK_NO_WAIT} ends on when the name holds + * something other than the file last seen there: a link, nothing, or a FIFO + * nobody reads. + */ +const NAME_HOLDS_SOMETHING_ELSE = new Set([ + 'ELOOP', + 'EMLINK', + 'ENOENT', + 'ENXIO', +]) + /** * What `file` holds, when it is a regular file of no more than `limit` * bytes; undefined for anything else, which is everything this needs to tell - * from the placeholders it compares against. + * from the placeholders it compares against. With it, whether the file has + * the shape bubblewrap leaves (see {@link isStaleBwrapMountPoint}), asked of + * the descriptor the bytes came from so that both answers are one file's. */ -function fileContentsWithin(file: string, limit: number): string | undefined { +function fileContentsWithin( + file: string, + limit: number, +): { contents: string | undefined; bwrapShape: boolean } { + const nothing = { contents: undefined, bwrapShape: false } + let fd: number + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | NO_LINK_NO_WAIT) + } catch { + return nothing + } try { - const stat = fs.lstatSync(file) - if (!stat.isFile() || stat.size > limit) return undefined - return fs.readFileSync(file, 'utf8') + const stat = fs.fstatSync(fd) + if (!stat.isFile() || stat.size > limit) return nothing + // One byte past the limit, so that a file that grew since the stat is + // not taken for the bytes it began with. + const buffer = Buffer.alloc(limit + 1) + let read = 0 + while (read < buffer.length) { + const chunk = fs.readSync(fd, buffer, read, buffer.length - read, read) + if (chunk === 0) break + read += chunk + } + if (read > limit) return nothing + return { + contents: buffer.toString('utf8', 0, read), + bwrapShape: hasBwrapMountPointShape(stat), + } } catch { - return undefined + return nothing + } finally { + fs.closeSync(fd) + } +} + +/** + * Write `placeholder` into the empty mount point an earlier sandbox left at + * `dest`, and say whether that was done. The shape is checked and the bytes + * are written through descriptors on one file, opened following no link: a + * check of the path followed by a write to the path would, in this + * unsandboxed process, truncate whatever the name had been pointed at in + * between. False when the name no longer holds that mount point, which is + * then left exactly as found; throws what stopped the write otherwise. + */ +function rewriteStaleBwrapMountPoint( + dest: string, + placeholder: string, +): boolean { + const fds: number[] = [] + const open = (access: number): number => { + const fd = fs.openSync(dest, access | NO_LINK_NO_WAIT) + fds.push(fd) + return fd + } + try { + const readFd = open(fs.constants.O_RDONLY) + const found = fs.fstatSync(readFd) + if (!hasBwrapMountPointShape(found)) return false + let writeFd: number + try { + writeFd = open(fs.constants.O_WRONLY) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EACCES') throw err + // bubblewrap makes its mount points read-only (ensure_file(dest, + // 0444)), and the process that owns one need not be root, so the mode + // it was left with is no reason to leave the repository broken. Changed + // on the file whose shape was just checked, not on the name. + fs.fchmodSync(readFd, 0o644) + writeFd = open(fs.constants.O_WRONLY) + } + // The name may have changed hands between the two opens. The first + // descriptor is still open, so its inode number cannot have been given + // to another file meanwhile. + const opened = fs.fstatSync(writeFd) + if (opened.dev !== found.dev || opened.ino !== found.ino) return false + fs.ftruncateSync(writeFd, 0) + fs.writeSync(writeFd, placeholder, 0) + return true + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== undefined && NAME_HOLDS_SOMETHING_ELSE.has(code)) return false + throw err + } finally { + for (const fd of fds) fs.closeSync(fd) } } @@ -310,6 +409,11 @@ type GitRedirectBind = * that shape: this wrapper's own to repair and remove. Anything else: the * caller's file, and only the bind lands on it. * + * A name found taken is read, and repaired, through descriptors that follow + * no link, so that what is written is the file that was looked at whatever + * the name has been given to meanwhile; where it has been given to something + * else, that is left alone and the git directory is denied whole. + * * What is bound is the store's copy, never the mount point itself: a host * process rewriting the file between this call and the mount would otherwise * choose what the sandbox reads, and another process's cleanup could unlink @@ -373,9 +477,12 @@ function gitRedirectMountPoint( return bindFromStore() } - const contents = fileContentsWithin(dest, Buffer.byteLength(placeholder)) + const { contents, bwrapShape } = fileContentsWithin( + dest, + Buffer.byteLength(placeholder), + ) if (contents === '' && placeholder !== '') { - if (!isStaleBwrapMountPoint(dest)) { + if (!bwrapShape) { // Empty and not the shape bubblewrap leaves: a host git caught between // creating the file and writing it owns this one. Cover it with the // placeholder for the length of the command and leave the file alone. @@ -384,17 +491,9 @@ function gitRedirectMountPoint( ) return bindFromStore() } + let rewritten: boolean try { - // bubblewrap makes its mount points read-only (ensure_file(dest, - // 0444)), and the process that owns one need not be root, so the mode - // it was left with is no reason to leave the repository broken. - try { - fs.writeFileSync(dest, placeholder) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EACCES') throw err - fs.chmodSync(dest, 0o644) - fs.writeFileSync(dest, placeholder) - } + rewritten = rewriteStaleBwrapMountPoint(dest, placeholder) } catch (err) { throw placeholderUnavailable( dest, @@ -402,16 +501,25 @@ function gitRedirectMountPoint( err, ) } + if (!rewritten) { + // Something has taken the name since it was read, and whatever that is + // is not this wrapper's to write through or to remove. The git + // directory is the command's to change right now, so refusing over it + // would be a brick that command can plant, the same as above. + const gitDir = path.dirname(dest) + logForDebugging( + `[Sandbox Linux] ${dest} stopped being the empty file an earlier sandbox left while it was being rewritten; leaving it as it is and denying the git directory ${gitDir} whole instead, which needs nothing written to it`, + { level: 'warn' }, + ) + return { denyGitDirWhole: gitDir } + } takeOwnership() logForDebugging( `[Sandbox Linux] Rewrote ${dest}, left empty by a sandbox that did not clean up, to ${JSON.stringify(placeholder)}: git reads no bytes there as a fault, not as "no redirect"`, ) return bindFromStore() } - if ( - contents === placeholder && - (placeholder !== '' || isStaleBwrapMountPoint(dest)) - ) { + if (contents === placeholder && (placeholder !== '' || bwrapShape)) { // This wrapper's own, left by a process that could not clean up: nothing // else writes exactly these bytes there, and for an empty placeholder // nothing else leaves a file of that shape. Removed with the rest, and @@ -1437,18 +1545,22 @@ function ensureEmptyMountSourceDir(): string { */ function isStaleBwrapMountPoint(p: string): boolean { try { - const stat = fs.lstatSync(p) - return ( - stat.isFile() && - stat.size === 0 && - (stat.mode & 0o222) === 0 && - stat.nlink === 1 - ) + return hasBwrapMountPointShape(fs.lstatSync(p)) } catch { return false } } +/** The shape {@link isStaleBwrapMountPoint} describes, asked of a stat. */ +function hasBwrapMountPointShape(stat: fs.Stats): boolean { + return ( + stat.isFile() && + stat.size === 0 && + (stat.mode & 0o222) === 0 && + stat.nlink === 1 + ) +} + export const CAP_SETFCAP = 31 // This process's bounding set unioned with its inheritable set, read once — @@ -1976,10 +2088,16 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { stat.size === Buffer.byteLength(written) && fs.readFileSync(mountPoint, 'utf8') === written) if (stat.isFile() && stillAsCreated) { - fs.unlinkSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, - ) + // The look above goes through a link and the unlink does not, so a + // name that has become a link to a file still as created is not the + // file that was looked at: left where it is. + const named = fs.lstatSync(mountPoint) + if (named.dev === stat.dev && named.ino === stat.ino) { + fs.unlinkSync(mountPoint) + logForDebugging( + `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, + ) + } } else if (stat.isDirectory()) { // Empty directory mount points are created for intermediate // components (Fix 2). Only remove if still empty. diff --git a/test/sandbox/linux-git-redirect-placeholder.test.ts b/test/sandbox/linux-git-redirect-placeholder.test.ts new file mode 100644 index 000000000..4320ea341 --- /dev/null +++ b/test/sandbox/linux-git-redirect-placeholder.test.ts @@ -0,0 +1,583 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + indexOfMount, + lastIndexOfMount, + lastMountAt, +} from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux } from '../helpers/platform.js' + +/** + * The mount point for a file git reads back (`commondir`, `config.worktree`) + * is prepared on the host, in a git directory a command already running may + * be writing: one that made the directory after its own wrap has nothing of + * it mounted. So whatever the wrapper does to a name it found taken, it does + * to the file it looked at and to nothing the name was given to since. + */ +describe.if(isLinux)('The mount point of a file git reads back', () => { + const VICTIM = 'a file outside every write root\n' + /** Echoed by a command that runs for real, so nothing concludes anything + * from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const WROTE_GIT_DIR = 'WROTE_GIT_DIR' + const LIVE = bwrapCanNamespace() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-race-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept, wrapped with it as the cwd. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + function wrapIn(checkout: string, command = 'true'): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + /** A file with exactly this mode, whatever the umask. */ + function writeWithMode(file: string, contents: string, mode: number): void { + writeFileSync(file, contents) + chmodSync(file, mode) + } + + /** The shape bubblewrap's own ensure_file() leaves behind. */ + function leaveStaleMountPoint(file: string): void { + writeWithMode(file, '', 0o444) + } + + /** + * The source bwrap is given for the mount at `dest`. Throws where nothing + * is mounted there, so no assertion about it passes on a wrap that emitted + * no such mount. + */ + function mountSource(command: string, dest: string): string { + const mount = lastMountAt(command, dest) + if (mount === undefined) throw new Error(`nothing is mounted at ${dest}`) + const [, source, mounted] = mount.split(' ') + if (source === undefined || mounted !== dest) { + throw new Error(`the mount at ${dest} has no source: ${mount}`) + } + return source + } + + /** + * Runs `wrap` and hands the name `dest` to `swap` right after the wrapper + * has touched that name for the `nth` time, counting from the exclusive + * create that found it taken: each later look at the path, and each stat of + * a descriptor opened on it, is one more. That is every point at which a + * command writing the directory could have given the name to something + * else between a check and what is done on the strength of it. + */ + async function wrapSwappingAfterTouch( + dest: string, + nth: number, + swap: () => void, + wrap: () => Promise, + ): Promise<{ result: T; swapped: boolean }> { + type Fn = (...args: unknown[]) => unknown + const spies: { mockRestore(): void }[] = [] + const descriptors = new Set() + let armed = false + let touches = 0 + let swapped = false + const touched = (): void => { + if (!armed || ++touches !== nth) return + swapped = true + swap() + } + const intercept = ( + name: keyof typeof fs, + around: (real: Fn, args: unknown[]) => unknown, + ): void => { + const real = fs[name] as Fn + spies.push( + spyOn(fs, name).mockImplementation(((...args: unknown[]) => + around(real, args)) as never), + ) + } + const byPath = (real: Fn, args: unknown[]): unknown => { + try { + return real(...args) + } finally { + if (String(args[0]) === dest) touched() + } + } + + for (const name of [ + 'lstatSync', + 'statSync', + 'readFileSync', + 'chmodSync', + ] as const) { + intercept(name, byPath) + } + intercept('writeFileSync', (real, args) => { + const exclusive = + String(args[0]) === dest && + (args[2] as { flag?: string } | undefined)?.flag === 'wx' + if (exclusive) armed = true + return byPath(real, args) + }) + intercept('openSync', (real, args) => { + try { + const fd = real(...args) as number + if (String(args[0]) === dest) descriptors.add(fd) + return fd + } finally { + if (String(args[0]) === dest) touched() + } + }) + intercept('fstatSync', (real, args) => { + try { + return real(...args) + } finally { + if (descriptors.has(args[0] as number)) touched() + } + }) + intercept('closeSync', (real, args) => { + descriptors.delete(args[0] as number) + return real(...args) + }) + + try { + return { result: await wrap(), swapped } + } finally { + for (const spy of spies) spy.mockRestore() + } + } + + /** Gives the name `dest` to `replacement`, the way rename(2) does. */ + function renameOver(replacement: string, dest: string): void { + fs.renameSync(replacement, dest) + } + + /** + * Every point at which the name can change hands, one wrap each, until the + * wrapper is done with the name before the swap comes due: the stale mount + * point's name goes to a link to a file outside every write root, made by + * `link`. Returns what each wrap emitted, so the caller asserts on more + * than survival. + */ + async function atEveryTouch( + victimMode: number, + link: (victim: string, name: string) => void, + check: (nth: number, victim: string) => void, + ): Promise { + const outcomes: string[] = [] + for (let nth = 1; ; nth++) { + const checkout = makeCheckout(`repo-${nth}`) + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + leaveStaleMountPoint(commondir) + const victim = join(dir, `victim-${nth}`) + writeWithMode(victim, VICTIM, victimMode) + + const { result: command, swapped } = await wrapSwappingAfterTouch( + commondir, + nth, + () => { + const linked = join(dir, `link-${nth}`) + link(victim, linked) + renameOver(linked, commondir) + }, + () => + wrapIn( + checkout, + `echo ${BOOTED}; touch .git/probe && echo ${WROTE_GIT_DIR}`, + ), + ) + if (!swapped) return outcomes + check(nth, victim) + + // Either the file is denied where it stands or the git directory is + // denied whole: no wrap comes out with the redirect file left open. A + // pin of the git directory lies beneath the bind that makes the + // checkout writable in every wrap, so it is a bind of it above that + // one that is the deny. + const madeWritable = indexOfMount(command, '--bind', checkout, checkout) + expect(madeWritable).toBeGreaterThan(-1) + if ( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir) > madeWritable + ) { + expect(lastMountAt(command, commondir)).toBeUndefined() + outcomes.push('git directory denied whole') + if (LIVE) { + // The sandbox starts with the link still standing there, and + // nothing in the git directory can be written, the link included. + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: checkout, + }) + const output = `${run.stdout}${run.stderr}` + expect({ nth, output }).toEqual({ + nth, + output: expect.stringContaining(BOOTED), + }) + expect(output).not.toContain(WROTE_GIT_DIR) + expect(readFileSync(commondir, 'utf8')).toBe(VICTIM) + check(nth, victim) + } + } else { + mountSource(command, commondir) + outcomes.push('file denied') + } + + // Forced, as the exit handler runs it: what the wrap took for its own + // goes, and the victim is no more its own for being linked to. + cleanupBwrapMountPoints({ force: true }) + check(nth, victim) + } + } + + it('never writes through a link the name of a stale mount point was swapped for', async () => { + const outcomes = await atEveryTouch( + 0o644, + fs.symlinkSync, + (nth, victim) => { + expect({ nth, contents: readFileSync(victim, 'utf8') }).toEqual({ + nth, + contents: VICTIM, + }) + }, + ) + + // The swap landed at least once, and at least once between the wrapper + // finding the stale file and rewriting it, which is where it gives up on + // the file and denies the directory instead. + expect(outcomes.length).toBeGreaterThan(1) + expect(outcomes).toContain('git directory denied whole') + }) + + it('never makes writable what such a link leads to', async () => { + // Read-only, so that a write through the link is refused and what is left + // to go wrong is the chmod that was there to make bubblewrap's own + // read-only mount point writable. + const outcomes = await atEveryTouch( + 0o444, + fs.symlinkSync, + (nth, victim) => { + expect({ + nth, + mode: (statSync(victim).mode & 0o777).toString(8), + contents: readFileSync(victim, 'utf8'), + }).toEqual({ nth, mode: '444', contents: VICTIM }) + }, + ) + + expect(outcomes.length).toBeGreaterThan(1) + }) + + it('never writes to another file the name was made a hard link to', async () => { + // No link to refuse this time: the name opens, as a regular file, and + // only what the descriptors say tells it from the file that was checked. + const outcomes = await atEveryTouch(0o644, fs.linkSync, (nth, victim) => { + expect({ nth, contents: readFileSync(victim, 'utf8') }).toEqual({ + nth, + contents: VICTIM, + }) + }) + + expect(outcomes.length).toBeGreaterThan(1) + expect(outcomes).toContain('git directory denied whole') + }) + + /** + * Runs `wrap` and says whether it waited on the FIFO that is, or is about + * to be, at `name`. Opening a FIFO to read waits for somebody to open it to + * write, and nobody will; so that a wrapper that does wait fails its test + * rather than hanging the run, a helper opens both ends after a while. A + * wrap that is back well before then waited on nothing. + */ + async function waitsOnFifoAt( + name: string, + wrap: () => Promise, + ): Promise<{ result: T; waited: boolean }> { + const RESCUE_AFTER_MS = 3000 + const rescuer = spawn( + 'sh', + [ + '-c', + `sleep ${RESCUE_AFTER_MS / 1000}; exec 3<>"$1"; sleep 1`, + 'sh', + name, + ], + { stdio: 'ignore' }, + ) + const started = Date.now() + try { + const result = await wrap() + return { result, waited: Date.now() - started >= RESCUE_AFTER_MS / 2 } + } finally { + rescuer.kill('SIGKILL') + } + } + + it('does not wait on a FIFO the name was swapped for', async () => { + for (let nth = 1; ; nth++) { + const checkout = makeCheckout(`repo-${nth}`) + const commondir = join(checkout, '.git', 'commondir') + leaveStaleMountPoint(commondir) + const fifo = join(dir, `fifo-${nth}`) + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + + const { + result: { swapped }, + waited, + } = await waitsOnFifoAt(commondir, () => + wrapSwappingAfterTouch( + commondir, + nth, + () => renameOver(fifo, commondir), + () => wrapIn(checkout), + ), + ) + if (!swapped) break + + expect({ nth, waited }).toEqual({ nth, waited: false }) + expect(lstatSync(commondir).isFIFO()).toBe(true) + cleanupBwrapMountPoints({ force: true }) + expect(lstatSync(commondir).isFIFO()).toBe(true) + } + }, 60000) + + it('binds a FIFO that was there all along from itself, without waiting on it', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + expect(spawnSync('mkfifo', [commondir]).status).toBe(0) + + const { result: command, waited } = await waitsOnFifoAt(commondir, () => + wrapIn(checkout), + ) + + expect(waited).toBe(false) + expect(mountSource(command, commondir)).toBe(commondir) + }, 60000) + + it('does not remove a name that stopped being its mount point', async () => { + // The check looks through a link and the unlink does not, so a mount + // point swapped for a link to an empty file would pass for still empty + // and lose the link. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + const configWorktree = join(checkout, '.git', 'config.worktree') + const emptyFile = join(dir, 'empty') + const placeholderFile = join(dir, 'placeholder') + writeFileSync(emptyFile, '') + writeFileSync(placeholderFile, '.\n') + + await wrapIn(checkout) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(readFileSync(configWorktree, 'utf8')).toBe('') + rmSync(commondir) + symlinkSync(placeholderFile, commondir) + rmSync(configWorktree) + symlinkSync(emptyFile, configWorktree) + + cleanupBwrapMountPoints({ force: true }) + + expect(lstatSync(commondir).isSymbolicLink()).toBe(true) + expect(lstatSync(configWorktree).isSymbolicLink()).toBe(true) + expect(existsSync(placeholderFile)).toBe(true) + expect(existsSync(emptyFile)).toBe(true) + }) + + /** + * The ordinary arms, as one table: what each file of the git directory + * holds before the wrap, what is bound over it, what the host finds there + * while the command runs, and whether the cleanup takes it away. + */ + describe('what is bound, by what was there', () => { + const ownerCannotWrite = process.getuid?.() !== 0 + type Before = { contents: string; mode: number } | undefined + type Expected = { + /** 'itself', or the bytes of the placeholder file bound instead. */ + boundFrom: 'itself' | { placeholder: string } + during: { contents: string; mode: number } + removedByCleanup: boolean + } + const cases: { + name: string + file: 'commondir' | 'config.worktree' + before: Before + expected: Expected + }[] = [ + { + name: 'an absent commondir', + file: 'commondir', + before: undefined, + expected: { + boundFrom: { placeholder: '.\n' }, + during: { contents: '.\n', mode: 0o444 }, + removedByCleanup: true, + }, + }, + { + name: 'an absent config.worktree', + file: 'config.worktree', + before: undefined, + expected: { + boundFrom: { placeholder: '' }, + during: { contents: '', mode: 0o444 }, + removedByCleanup: true, + }, + }, + { + name: 'a commondir placeholder a killed wrap left', + file: 'commondir', + before: { contents: '.\n', mode: 0o444 }, + expected: { + boundFrom: { placeholder: '.\n' }, + during: { contents: '.\n', mode: 0o444 }, + removedByCleanup: true, + }, + }, + { + name: 'a config.worktree placeholder a killed wrap left', + file: 'config.worktree', + before: { contents: '', mode: 0o444 }, + expected: { + boundFrom: { placeholder: '' }, + during: { contents: '', mode: 0o444 }, + removedByCleanup: true, + }, + }, + { + name: 'a commondir naming a directory', + file: 'commondir', + before: { contents: '../..\n', mode: 0o644 }, + expected: { + boundFrom: 'itself', + during: { contents: '../..\n', mode: 0o644 }, + removedByCleanup: false, + }, + }, + { + name: 'a commondir as long as the placeholder, holding something else', + file: 'commondir', + before: { contents: '..', mode: 0o444 }, + expected: { + boundFrom: 'itself', + during: { contents: '..', mode: 0o444 }, + removedByCleanup: false, + }, + }, + { + name: 'a config.worktree with settings in it', + file: 'config.worktree', + before: { contents: '[core]\n', mode: 0o644 }, + expected: { + boundFrom: 'itself', + during: { contents: '[core]\n', mode: 0o644 }, + removedByCleanup: false, + }, + }, + { + name: 'an empty read-only commondir, the shape bubblewrap leaves', + file: 'commondir', + before: { contents: '', mode: 0o444 }, + expected: { + boundFrom: { placeholder: '.\n' }, + // Rewritten in place; made writable for that only where the owner + // of a read-only file cannot write it as it is. + during: { contents: '.\n', mode: ownerCannotWrite ? 0o644 : 0o444 }, + removedByCleanup: true, + }, + }, + { + name: 'an empty writable commondir, a host git mid-write', + file: 'commondir', + before: { contents: '', mode: 0o644 }, + expected: { + boundFrom: { placeholder: '.\n' }, + during: { contents: '', mode: 0o644 }, + removedByCleanup: false, + }, + }, + { + name: 'an empty writable config.worktree', + file: 'config.worktree', + before: { contents: '', mode: 0o644 }, + expected: { + boundFrom: 'itself', + during: { contents: '', mode: 0o644 }, + removedByCleanup: false, + }, + }, + ] + + for (const { name, file, before, expected } of cases) { + it(name, async () => { + const checkout = makeCheckout('repo') + const dest = join(checkout, '.git', file) + if (before !== undefined) { + writeWithMode(dest, before.contents, before.mode) + } + + const command = await wrapIn(checkout) + + const mount = lastMountAt(command, dest) + const source = mountSource(command, dest) + expect(mount).toBe(`--ro-bind ${source} ${dest}`) + expect( + source === dest + ? 'itself' + : { placeholder: readFileSync(source, 'utf8') }, + ).toEqual(expected.boundFrom) + expect(source).not.toBe('/dev/null') + expect({ + contents: readFileSync(dest, 'utf8'), + mode: statSync(dest).mode & 0o777, + }).toEqual(expected.during) + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(dest)).toBe(!expected.removedByCleanup) + if (!expected.removedByCleanup) { + expect(readFileSync(dest, 'utf8')).toBe(expected.during.contents) + } + }) + } + }) +}) From 115109a543ec0e55d8560f532d010f7401be2eec Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:42 +0000 Subject: [PATCH 065/141] The .git/modules walk goes on past a directory that looks like a git directory A directory under .git/modules is taken for a git directory when it has an entry named HEAD, config, hooks or objects, and the walk then looked only at its modules. A segment of a submodule's name (vendor of vendor/lib) is a directory a command can write, so one empty file, vendor/HEAD, made vendor a git directory and vendor/lib was never listed again: its hooks and config lost their denies on both platforms. The directory is still recorded, and its other child directories are now walked as well. What git itself keeps in a git directory (objects, refs, logs, hooks, info, worktrees, lfs, rr-cache) is left at one listing each, and walked after all where that listing shows a config or a hooks, which a submodule named vendor/lfs has and git's own lfs does not. The same deadline, visited set and unreadable-directory handling apply to the extra descent. --- src/sandbox/mandatory-deny-paths.ts | 99 +++- .../mandatory-deny-modules-walk.test.ts | 439 ++++++++++++++++++ 2 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 test/sandbox/mandatory-deny-modules-walk.test.ts diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index e3960e184..c26c830ca 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -56,6 +56,38 @@ const WALK_DEADLINE_CHECK_INTERVAL = 128 */ const GIT_DIR_MARKERS = new Set(['HEAD', 'config', 'hooks', 'objects']) +/** + * Directories git itself keeps in a git directory: its objects, its refs and + * their logs, its hooks and `info`, the administrative directories of linked + * worktrees, and the stores git-lfs and rerere put beside them. The + * `.git/modules` walk goes on past a git directory (see + * {@link submoduleGitDirs}) and stays out of these: they are where a + * repository is large, and git keeps no submodule's git directory in any of + * them. `modules` is not one of them because it IS walked, as the place a git + * directory keeps its own submodules. + */ +const GIT_OWN_DIRECTORIES = new Set([ + 'objects', + 'refs', + 'logs', + 'hooks', + 'info', + 'worktrees', + 'lfs', + 'rr-cache', +]) + +/** + * What tells a submodule's git directory that only has one of those names + * (`vendor/lfs`, `tools/hooks`) from git's own directory of that name: the + * two entries this file protects. Every git directory git makes has a + * `config`, and git puts neither name in a directory of its own, where + * `logs/HEAD` and `lfs/objects` rule the other two markers out. A linked + * worktree or a ref namespace that is itself NAMED one of the two is the + * exception: what holds it is walked for it, which only ever denies more. + */ +const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) + /** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' @@ -829,6 +861,19 @@ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { * its path, so one can sit several levels down (modules/vendor/lib), hence * the walk. * + * A directory recorded as a git directory is walked on all the same. What + * makes it one is a name ({@link GIT_DIR_MARKERS}), and a segment of a + * submodule's name (`vendor` of `vendor/lib`) is an ordinary directory a + * sandboxed command can write: a walk that stopped at the first directory + * which looks like a git directory would let one empty `HEAD` left in `vendor` + * take the denies off every submodule beneath it for the commands that + * follow. git refuses a submodule whose git directory would sit inside + * another's, so in a tree git made there is nothing past a git directory to + * find, and looking costs one listing of each directory in it. The ones where + * a repository is large are not walked into beyond that listing: see + * {@link GIT_OWN_DIRECTORIES}. `modulesDir` itself, and a git directory's own + * `modules`, are never taken for a git directory whatever they hold. + * * Every directory is visited once, keyed by where it really is, so a symlink * pointing back into the tree ends the branch that reached it rather than * looping - but a bind mount gives the same directory a second real path, and @@ -854,10 +899,14 @@ export function submoduleGitDirs( // directory listed to see whether it is a git directory carries its entries // with it, so that no directory is listed twice. The root counts as visited: // an entry linked straight back to it is then the same dead end as one - // linked to any other directory already walked. - const pending: Array<{ dir: string; entries?: fs.Dirent[] }> = [ - { dir: modulesDir }, - ] + // linked to any other directory already walked. `gitDir` says the directory + // was recorded as a git directory, so that some of its entries may be git's + // own. + const pending: Array<{ + dir: string + entries?: fs.Dirent[] + gitDir?: boolean + }> = [{ dir: modulesDir }] const visited = new Set([realPathOrSelf(modulesDir)]) let entriesSeen = 0 for (let item = pending.pop(); item !== undefined; item = pending.pop()) { @@ -873,6 +922,29 @@ export function submoduleGitDirs( ) } const child = path.join(item.dir, entry.name) + let childEntries: fs.Dirent[] | undefined + if (item.gitDir === true) { + // Queued when the git directory was recorded. + if (entry.name === 'modules') continue + // One of git's own directories by its name: walked on only where one + // listing of it shows something this file protects, which is what a + // submodule's git directory that has such a name shows and git's own + // directory never does. Listed before the entry is followed: following + // a symlink puts the directory holding it on the whole-directory + // denies (see walkEntry), and a git directory whose `objects` or + // `rr-cache` is a link to a shared store is an ordinary one. + if ( + GIT_OWN_DIRECTORIES.has(entry.name) && + (entry.isDirectory() || entry.isSymbolicLink()) + ) { + childEntries = listDirectory(child, walk) + if ( + childEntries?.some(e => GIT_PROTECTED_ENTRIES.has(e.name)) !== true + ) { + continue + } + } + } // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. @@ -892,17 +964,18 @@ export function submoduleGitDirs( if (visited.has(visitKey)) continue visited.add(visitKey) - const childEntries = listDirectory(child, walk) + childEntries ??= listDirectory(child, walk) if (childEntries === undefined) continue const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) - if (isGitDir) walk.gitDirs.push(child) - // A git directory keeps its own submodules under `modules`; anything - // else is a segment of a submodule name (`vendor` of `vendor/lib`). - pending.push( - isGitDir - ? { dir: path.join(child, 'modules') } - : { dir: child, entries: childEntries }, - ) + if (isGitDir) { + walk.gitDirs.push(child) + // A git directory keeps its own submodules under `modules`. + pending.push({ dir: path.join(child, 'modules') }) + } + // Anything else is a segment of a submodule name (`vendor` of + // `vendor/lib`) - and a directory that looks like a git directory can + // be one as well, so what else it holds is looked at either way. + pending.push({ dir: child, entries: childEntries, gitDir: isGitDir }) } } return { diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts new file mode 100644 index 000000000..5e3575351 --- /dev/null +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' +import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-utils.js' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + SubmoduleWalkBudgetError, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + submoduleGitDirs, +} from '../../src/sandbox/mandatory-deny-paths.js' + +/** + * The walk of a `.git/modules` where one directory is two things at once: a + * git directory by what it holds, and a segment of a submodule's name by what + * lies under it. + * + * A submodule named `vendor/lib` keeps its git directory at + * `.git/modules/vendor/lib`, so `.git/modules/vendor` is an ordinary directory + * a sandboxed command can write, and what makes a directory a git directory + * to the walk is the NAME of one entry in it. A command that leaves a `HEAD` + * in `vendor` must not end the walk there: the submodule beneath it would go + * without its denies for every command after that one. + */ + +/** The names the walk takes for what git itself keeps in a git directory. */ +const GIT_OWN_NAMES = [ + 'objects', + 'refs', + 'logs', + 'hooks', + 'info', + 'worktrees', + 'lfs', + 'rr-cache', +] + +/** A submodule git directory as `git clone` leaves one. */ +function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir +} + +describe('The .git/modules walk beneath what looks like a git directory', () => { + let dir: string + let modules: string + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-modules-walk-'))) + modules = join(dir, 'modules') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('finds the submodule beneath a name segment a command left a marker in', () => { + // Each of the four names, as the emptiest thing a command can leave. + const expected: string[] = [] + for (const marker of ['HEAD', 'config', 'hooks', 'objects']) { + const vendor = join(modules, `vendor-${marker}`) + const lib = makeGitDir(join(vendor, 'lib')) + writeFileSync(join(vendor, marker), '') + // The segment is recorded too. The markers only ever deny more, and a + // HEAD alone is also what a git directory looks like with the rest of + // it moved aside, so what the segment was denied by before it still is. + expected.push(vendor, lib) + } + + const scan = submoduleGitDirs(modules) + + expect(scan).toEqual({ + gitDirs: expected.sort(), + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const marker of ['HEAD', 'config', 'hooks', 'objects']) { + const lib = join(modules, `vendor-${marker}`, 'lib') + for (const deny of gitDirDenyPaths(lib, false)) { + expect(denies).toContain(deny) + } + } + }) + + it('finds it several segments further down as well', () => { + const deep = makeGitDir(join(modules, 'a', 'b', 'c', 'lib')) + writeFileSync(join(modules, 'a', 'HEAD'), '') + writeFileSync(join(modules, 'a', 'b', 'config'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + join(modules, 'a'), + join(modules, 'a', 'b'), + deep, + ]) + }) + + it('finds a submodule whose own name is one of the markers', () => { + // `x/config`: the git directory is a DIRECTORY named config, which makes + // `x` look like a git directory with no help from any command. + const x = join(modules, 'x') + const sub = makeGitDir(join(x, 'config')) + + const scan = submoduleGitDirs(modules) + + // Both. The deny `x` gets for its `config` is the submodule's git + // directory whole, which is all the submodule had before and costs it its + // git writes in the sandbox; its own hooks and config are now named too. + expect(scan.gitDirs).toEqual([x, sub]) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const deny of gitDirDenyPaths(sub, false)) { + expect(denies).toContain(deny) + } + }) + + it('finds a submodule named like one of the directories git keeps, beneath such a segment', () => { + // `vendor/lfs`, `vendor/refs` and the rest: not walked into where they + // are git's own, which a `HEAD` left in `vendor` would otherwise make + // them. What this file protects is what tells the two apart: git keeps + // no `config` and no `hooks` in any of its own directories. + const expected: string[] = [] + for (const name of GIT_OWN_NAMES) { + const vendor = join(modules, `vendor-${name}`) + const sub = makeGitDir(join(vendor, name)) + writeFileSync(join(vendor, 'HEAD'), '') + expected.push(vendor, sub) + } + + expect(submoduleGitDirs(modules).gitDirs).toEqual(expected.sort()) + }) + + it('never takes the directory it was asked to walk for a git directory', () => { + // A `.git/modules` is as writable as any segment under it, and its + // entries are submodule names whatever else is left beside them. + const expected = GIT_OWN_NAMES.map(name => makeGitDir(join(modules, name))) + writeFileSync(join(modules, 'HEAD'), '') + writeFileSync(join(modules, 'config'), '') + const nested = makeGitDir(join(modules, 'objects', 'modules', 'refs')) + writeFileSync(join(modules, 'objects', 'modules', 'HEAD'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual( + [...expected, nested].sort(), + ) + }) + + it('does not walk into what git itself keeps in a git directory', () => { + // A real submodule git directory with everything in it that is large: + // loose objects fanned out over 256 directories, refs and their logs, an + // LFS store, rerere's cache and a linked worktree. Each holds something + // shaped like a git directory, which a walk into it would record. + const sub = makeGitDir(join(modules, 'sub')) + for (let i = 0; i < 256; i++) { + mkdirSync(join(sub, 'objects', i.toString(16).padStart(2, '0'))) + } + mkdirSync(join(sub, 'objects', 'pack')) + mkdirSync(join(sub, 'objects', 'info')) + mkdirSync(join(sub, 'refs', 'heads'), { recursive: true }) + mkdirSync(join(sub, 'logs', 'refs', 'heads'), { recursive: true }) + // Every repository with a reflog has this one, and an LFS store keeps + // its own `objects`: neither makes a git directory of what holds it. + writeFileSync(join(sub, 'logs', 'HEAD'), '') + mkdirSync(join(sub, 'lfs', 'objects'), { recursive: true }) + mkdirSync(join(sub, 'info')) + for (const inside of [ + 'objects/ab', + 'objects/pack', + 'refs/heads', + 'logs/refs', + 'lfs/objects', + 'hooks', + 'info', + 'rr-cache', + 'worktrees', + ]) { + makeGitDir(join(sub, inside, 'planted')) + } + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it.if(!isWindows)( + 'does not follow a link that is one of those directories', + () => { + // A rerere cache shared between clones. Following an entry that is a + // symlink denies the directory holding it whole, which here would be an + // ordinary submodule's git directory. + const sub = makeGitDir(join(modules, 'sub')) + const shared = join(dir, 'shared-rr-cache') + mkdirSync(shared) + symlinkSync(shared, join(sub, 'rr-cache')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + + it('walks what else a git directory holds, and finds nothing in an ordinary one', () => { + // Directories that are neither git's own large ones nor `modules`: the + // empty `branches` of an older template, a rebase in progress. + const sub = makeGitDir(join(modules, 'sub')) + mkdirSync(join(sub, 'branches')) + mkdirSync(join(sub, 'rebase-merge')) + writeFileSync(join(sub, 'rebase-merge', 'head-name'), 'refs/heads/main\n') + const inner = makeGitDir(join(sub, 'modules', 'dep')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub, inner], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it.if(!isWindows)( + 'follows a symlinked entry beneath such a segment like any other', + () => { + const elsewhere = makeGitDir(join(dir, 'elsewhere')) + const vendor = join(modules, 'vendor') + mkdirSync(vendor, { recursive: true }) + symlinkSync(elsewhere, join(vendor, 'lib')) + writeFileSync(join(vendor, 'HEAD'), '') + + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toEqual([vendor, join(vendor, 'lib')]) + expect(scan.linkedEntryDirs).toEqual([vendor]) + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'denies a directory beneath such a segment that it could not list', + () => { + const vendor = join(modules, 'vendor') + const locked = join(vendor, 'locked') + makeGitDir(join(locked, 'deep')) + writeFileSync(join(vendor, 'HEAD'), '') + chmodSync(locked, 0o000) + try { + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toEqual([vendor]) + expect(scan.unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('keeps looking at the clock beneath such a segment', () => { + // A thousand entries under a segment that looks like a git directory, + // and a clock that moves once each time the walk looks at it: the walk + // looks once per so many entries, so it gets past the root on the first + // look and has to run out somewhere among the thousand. + const vendor = join(modules, 'vendor') + mkdirSync(vendor, { recursive: true }) + writeFileSync(join(vendor, 'HEAD'), '') + for (let i = 0; i < 1000; i++) { + writeFileSync(join(vendor, `f${i}`), '') + } + + let now = 0 + const clock = spyOn(Date, 'now').mockImplementation(() => now++) + try { + expect(() => submoduleGitDirs(modules, 1)).toThrow( + SubmoduleWalkBudgetError, + ) + } finally { + clock.mockRestore() + } + }) + + it.if(!isWindows)( + 'names the literals of such a submodule for Seatbelt as well', + () => { + // Seatbelt's glob for `.git/modules/*` stops at one segment, so the + // literals read off this walk are all a `vendor/lib` has there. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const vendor = join(checkout, '.git', 'modules', 'vendor') + const lib = makeGitDir(join(vendor, 'lib')) + const saved = process.cwd() + process.chdir(checkout) + try { + const literals = (): string[] => + macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + const before = literals() + writeFileSync(join(vendor, 'HEAD'), '') + const after = literals() + + for (const deny of gitDirDenyPaths(lib, false)) { + expect(before).toContain(deny) + expect(after).toContain(deny) + } + } finally { + process.chdir(saved) + } + }, + ) +}) + +describe.if(isLinux)( + 'A marker left in a segment of a submodule name, under bubblewrap', + () => { + const LIVE = bwrapCanNamespace() + let dir: string + let checkout: string + let vendor: string + let lib: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-modules-walk-live-'))) + // A superproject with one submodule named `vendor/lib`, and no checkout + // of it: the scan of the work tree has no `.git` pointer file to reach + // the git directory by, so the walk of `.git/modules` is what covers it. + checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + vendor = join(checkout, '.git', 'modules', 'vendor') + lib = makeGitDir(join(vendor, 'lib')) + writeFileSync(join(checkout, 'work.txt'), 'one\n') + process.chdir(checkout) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + function wrap(command: string): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + it('still binds the hooks and config of the submodule beneath it read-only', async () => { + writeFileSync(join(vendor, 'HEAD'), '') + + const command = await wrap('true') + + for (const name of ['hooks', 'config']) { + const deny = join(lib, name) + expect(lastMountAt(command, deny)).toBe(`--ro-bind ${deny} ${deny}`) + } + // The two that are not there take a mount all the same: a placeholder + // git reads as it reads the file's absence. + for (const name of ['commondir', 'config.worktree']) { + expect(lastMountAt(command, join(lib, name))).toBeDefined() + } + }) + + it.if(LIVE)( + 'holds them against the command after the one that left the marker', + async () => { + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: checkout, + env: { ...process.env, LC_ALL: 'C' }, + }) + + // `vendor` is no git directory and nothing denies a write to it. + const first = run( + await wrap( + 'echo BOOTED; : > .git/modules/vendor/HEAD && echo PLANTED', + ), + ) + expect(first.stdout).toContain('BOOTED') + expect(first.stdout).toContain('PLANTED') + expect(existsSync(join(vendor, 'HEAD'))).toBe(true) + + // No cleanup in between: the second wrap walks the tree exactly as + // the first command left it. + const second = run( + await wrap( + 'echo BOOTED; ' + + "{ echo '[core]' >> .git/modules/vendor/lib/config; } 2>&1 || echo CONFIG_DENIED; " + + '{ echo x > .git/modules/vendor/lib/hooks/pre-commit; } 2>&1 || echo HOOK_DENIED; ' + + 'echo two >> work.txt && echo WORK_WRITTEN; ' + + 'echo DONE', + ), + ) + expect(second.stdout).toContain('BOOTED') + expect(second.stdout).toContain('DONE') + expect(second.stdout).toContain('CONFIG_DENIED') + expect(second.stdout).toContain('HOOK_DENIED') + expect(second.stdout).toContain('Read-only file system') + expect(second.stdout).toContain('WORK_WRITTEN') + expect(readFileSync(join(lib, 'config'), 'utf8')).toBe('[core]\n') + expect(existsSync(join(lib, 'hooks', 'pre-commit'))).toBe(false) + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe( + 'one\ntwo\n', + ) + }, + 60000, + ) + }, +) From d62d3f1d148317064a71cd8b95c0816df002bf1b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:42 +0000 Subject: [PATCH 066/141] linux: bound the profile rebuild loop and the walk of a symlink chain Rebuild loop: when a profile does not fit, generateFilesystemArgs steps the collapse level and builds again. A submodule git directory outside every write root costs no mount, so collapsing it gives nothing back, and a profile a few words over took one full rebuild per submodule before it reached the last level: 402 builds and 20 seconds for 400 submodules. A build that is not shorter than the one before it now goes straight to the last level. Chain walk: resolveChain does one lstat per path component, bounded only by 40 hops. A link target runs to 4095 bytes and a pointer or commondir value to 1 MiB, and none of it looked at a clock, so a tree of long chains blocked the wrap for minutes without ever reaching the typed refusal the rest of discovery gives. The walk now takes the deadline the .git/modules walk has, checks it on the first component and every 128 after, and ends in the same SubmoduleWalkBudgetError (deny_scan_failed from the Linux wrap). The .git pointer files one scan lists share one budget between them. --- src/sandbox/linux-deny-collapse.ts | 14 + src/sandbox/linux-sandbox-utils.ts | 61 +- src/sandbox/mandatory-deny-paths.ts | 144 ++++- .../mandatory-deny-time-budget.test.ts | 575 ++++++++++++++++++ 4 files changed, 754 insertions(+), 40 deletions(-) create mode 100644 test/sandbox/mandatory-deny-time-budget.test.ts diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 634dd13fb..e256c0b49 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -207,6 +207,20 @@ export function collapseFurther( return undefined } +/** + * The last level: every submodule git directory and every `.git/modules` + * denied whole, past which {@link collapseFurther} has nothing left to take. + * It is where the caller goes once a step has given nothing back, rather than + * on through entries that may all be like the ones it just took: see + * `generateFilesystemArgs` in src/sandbox/linux-sandbox-utils.ts. + */ +export function collapseEverything(plan: SubmoduleDenyPlan): CollapseLevel { + return { + wholeGitDirs: collapsibleGitDirs(plan).length, + wholeModulesDirs: plan.repositories.length, + } +} + /** How many entries before `from` it takes for `gives` to reach `mounts`, at * least one: a step that degraded nothing would loop for ever. */ function stepBack( diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 726d6e28f..04135c6fb 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -34,6 +34,7 @@ import { GitMetadataError, SubmoduleWalkBudgetError, gitDirDenies, + gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenies, @@ -47,6 +48,7 @@ import type { } from './linux-deny-collapse.js' import { NO_COLLAPSE, + collapseEverything, collapseFurther, collapsedDenyPaths, describeCollapse, @@ -859,7 +861,7 @@ function cwdMandatoryDenyPlan( } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. - const pointer = gitFileDenies(dotGitPath, allowGitConfig) + const pointer = gitFileDenies(dotGitPath, allowGitConfig, deadline) denyPaths.push( ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), @@ -1187,12 +1189,20 @@ function monitorDotGitDenyPaths( // nothing followed to name. if (dotGitStat.isFile()) return [dotGitPath] if (dotGitStat.isDirectory()) { - const denies = gitDirDenies(dotGitPath, allowGitConfig) - return [ - ...withoutChainHopLinks(denies.denyPaths, denies.chainHops), - ...denies.linkedEntryDirs.map(wholeDirDenyLanding), - ...chainHopDenies(denies.chainHops, allowWritePaths).held, - ] + try { + const denies = gitDirDenies(dotGitPath, allowGitConfig) + return [ + ...withoutChainHopLinks(denies.denyPaths, denies.chainHops), + ...denies.linkedEntryDirs.map(wholeDirDenyLanding), + ...chainHopDenies(denies.chainHops, allowWritePaths).held, + ] + } catch (err) { + // An entry that is a symlink into a chain too long to walk in the time + // there is. The entries' own names need nothing followed, and the + // monitor is not what fails over one repository. + if (!(err instanceof SubmoduleWalkBudgetError)) throw err + return gitDirDenyPaths(dotGitPath, allowGitConfig) + } } return [] } @@ -1383,6 +1393,11 @@ async function linuxGetMandatoryDenyPaths( const dirPatterns = dangerousDirectories.map(d => normalizeCaseForComparison(d).split('/'), ) + // The `.git` pointer files the scan listed have ONE budget between them, + // taken when the first is met. Each is a file a command can write, as large + // as git accepts and under as many names as it cares to link it by, so a + // budget apiece would bound nothing of what a tree full of them costs. + let pointersDeadline: number | undefined for (const match of matches) { // rg prefixes each match with its target, cwd, and does not follow // symlinks, so every line is under it. Segments are compared relative to @@ -1407,7 +1422,11 @@ async function linuxGetMandatoryDenyPaths( } else if (relative.length > 1) { // cwd's own pointer file is handled above, before the scan. const pointer = asProfileRefusal(() => - gitFileDenies(match, allowGitConfig), + gitFileDenies( + match, + allowGitConfig, + (pointersDeadline ??= walkDeadline()), + ), ) denyPaths.push( ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), @@ -2814,7 +2833,9 @@ function pushReadDenyDirMounts( * repository's submodules really cost cannot be worked out before this point * (see src/sandbox/linux-deny-collapse.ts for what a step gives up, and * `LinuxSandboxProfileErrorCode`'s `too_many_arguments` for what is left when - * there is nothing to degrade). + * there is nothing to degrade). A pass that comes out no shorter than the one + * before it is the last that is stepped to: the next is the last level, so + * the number of passes does not grow with what a repository holds. * * A pass that is built and then degraded may already have written mount * points on the host for the denies it dropped; they are recorded like any @@ -2847,6 +2868,8 @@ async function generateFilesystemArgs( plan === undefined ? NO_COLLAPSE : collapseFloor(plan, writeConfig?.allowOnly ?? [], wordsAvailable) + // What the pass before this one came to, once there has been one. + let wordsBefore: number | undefined for (;;) { const args = buildFilesystemArgs( readConfig, @@ -2856,7 +2879,7 @@ async function generateFilesystemArgs( plan === undefined ? [] : collapsedDenyPaths(plan, level), ) if (plan === undefined) return args - const degraded = + let degraded = args.length <= wordsAvailable ? undefined : collapseFurther( @@ -2864,7 +2887,25 @@ async function generateFilesystemArgs( level, Math.ceil((args.length - wordsAvailable) / BWRAP_WORDS_PER_MOUNT), ) + // A pass no shorter than the one before it took entries that cost this + // profile nothing: submodule git directories no write root contains, + // say, which the read-only root holds without a mount of their own. How + // many more of those lie ahead is a number a command can choose, and + // finding out a step at a time is the whole profile built once per step, + // so the stepping stops and the next pass is the last level. Where the + // words over are ones no level gives back, that is where the steps were + // going anyway; where entries further on would have given some back, it + // degrades more than they strictly needed, and costs what it costs + // whatever the repository holds. + if ( + degraded !== undefined && + wordsBefore !== undefined && + args.length >= wordsBefore + ) { + degraded = collapseEverything(plan) + } if (degraded !== undefined) { + wordsBefore = args.length level = degraded continue } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index c26c830ca..54a0dfc86 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -39,13 +39,16 @@ function isUnusablePathError(err: unknown): boolean { */ const MAX_GIT_METADATA_BYTES = 1024 * 1024 -/** Time the `.git/modules` walk gets when the caller sets no deadline. */ +/** Time the `.git/modules` walk gets when the caller sets no deadline, and + * the walk of a symlink chain or a pointer's value likewise. */ const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS /** * How often the walk looks at the clock: once per this many entries, rather * than once per directory, because one directory can hold as many entries as - * the whole rest of the walk. + * the whole rest of the walk. {@link resolveChain} counts path components the + * same way, for the same reason: one link's target, or one pointer's value, + * can hold as many as every other path put together. */ const WALK_DEADLINE_CHECK_INTERVAL = 128 @@ -124,10 +127,12 @@ export class GitMetadataError extends Error { /** * The `.git/modules` walk ran out of the time it was given, so the submodule - * git directories it never reached would have been left with writable hooks. - * The Linux wrap turns this into a `LinuxSandboxProfileError` carrying - * `deny_scan_failed`; on macOS it reaches the caller as itself. Branch on - * `.code`, not on the message. + * git directories it never reached would have been left with writable hooks — + * or the walk of a symlink chain or of the path a `.git` pointer names did, + * which spends the same budget, so the git directory it never landed on would + * have been. The Linux wrap turns this into a `LinuxSandboxProfileError` + * carrying `deny_scan_failed`; on macOS it reaches the caller as itself. + * Branch on `.code`, not on the message. */ export class SubmoduleWalkBudgetError extends Error { readonly code = 'submodule_walk_budget_exhausted' as const @@ -421,11 +426,14 @@ export interface GitDirDenies { * its own: see the comment at the end of this function. * * An ordinary git directory costs one lstat per entry and is denied by exactly - * what it always was. + * what it always was. `deadline` bounds the walk of the chain an entry that IS + * a symlink leads into (see {@link resolveChain}); past it this throws + * {@link SubmoduleWalkBudgetError}, as the `.git/modules` walk does. */ export function gitDirDenies( gitDir: string, allowGitConfig: boolean, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, ): GitDirDenies { const denyPaths: string[] = [] const escapingDenyPaths: string[] = [] @@ -472,7 +480,7 @@ export function gitDirDenies( } } for (const entryPath of gitDirDenyPaths(gitDir, allowGitConfig)) { - const entry = gitDirEntry(entryPath) + const entry = gitDirEntry(entryPath, deadline) switch (entry.kind) { case 'plain': denyPaths.push(entryPath) @@ -523,7 +531,7 @@ export function gitDirDenies( // for the backend whose denies resolve, where a bind at the link's own path // would land on the modules tree and take every submodule with it. const modulesPath = path.join(gitDir, 'modules') - const modules = gitDirEntry(modulesPath) + const modules = gitDirEntry(modulesPath, deadline) switch (modules.kind) { case 'plain': break @@ -566,7 +574,7 @@ type GitDirEntry = * chain as the walk read before it stopped. */ | { kind: 'unknown'; hops: ChainHop[] } -function gitDirEntry(entryPath: string): GitDirEntry { +function gitDirEntry(entryPath: string, deadline: number): GitDirEntry { let stats: fs.Stats try { stats = fs.lstatSync(entryPath) @@ -589,8 +597,9 @@ function gitDirEntry(entryPath: string): GitDirEntry { // link - counts as where this repository lives and not as a hop of this // chain. The landing is what walking the whole path from the root gives. const chain = resolveChain( - physicalPath(path.parse(entryPath).root, path.dirname(entryPath)), + physicalPath(path.parse(entryPath).root, path.dirname(entryPath), deadline), path.basename(entryPath), + deadline, ) try { fs.statSync(entryPath) @@ -639,7 +648,8 @@ export function gitRedirectPlaceholder(denyPath: string): string | undefined { * Throws {@link GitMetadataError} when the pointer or the `commondir` names * something this cannot resolve the way git does; the wrap is then refused * rather than applied with a deny list that may not cover the directory git - * uses. + * uses. Throws {@link SubmoduleWalkBudgetError} where walking what they name + * takes longer than the `.git/modules` walk is given by default. */ export function gitFileDenyPaths( gitFile: string, @@ -656,10 +666,17 @@ export function gitFileDenyPaths( * whose denies resolve, and so does a link in the path the pointer's own * value walks. Nothing else is enumerated through a pointer — a target's own * `.git/modules` is not walked — so this is what it adds. + * + * `deadline` bounds the walk of the pointer's value and of the `commondir`'s, + * each of which can run to the megabyte the file may hold, and of any chain + * an entry of the git directories they lead to starts. Past it this throws + * {@link SubmoduleWalkBudgetError}: a value not walked to its end names a git + * directory this never found, so there is no list to hand back. */ export function gitFileDenies( gitFile: string, allowGitConfig: boolean, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, ): GitDirDenies { const denyPaths = [gitFile] const linkedEntryDirs: string[] = [] @@ -669,7 +686,13 @@ export function gitFileDenies( kind: GitDirKind, source: string, ): void => { - const denies = gitDirTargetDenies(target, kind, allowGitConfig, source) + const denies = gitDirTargetDenies( + target, + kind, + allowGitConfig, + source, + deadline, + ) denyPaths.push(...denies.denyPaths) linkedEntryDirs.push(...denies.linkedEntryDirs) chainHops.push(...denies.chainHops) @@ -702,6 +725,15 @@ export function gitFileDenies( { level: 'warn' }, ) } + // Looked at before the file is, whatever the file turns out to hold: a tree + // full of `.git` files is as many reads of up to a megabyte as a command + // cared to leave names for, and one that is no pointer starts no walk that + // would look at the clock for it. + if (Date.now() > deadline) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The time given for working out what the .git files lead to ran out before ${gitFile} was read; refusing to sandbox on the ones read before it`, + ) + } try { const pointer = readGitMetadataFile(gitFile) let target: string | undefined @@ -722,7 +754,7 @@ export function gitFileDenies( if (target === undefined) { return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } - const targets = gitMetadataTargets(path.dirname(gitFile), target) + const targets = gitMetadataTargets(path.dirname(gitFile), target, deadline) holdChain(gitFile, targets) const gitDirs = targets.gitDirs.map(gitDir => ({ gitDir, @@ -769,7 +801,7 @@ export function gitFileDenies( break } if (commonTarget === undefined) continue - const commonTargets = gitMetadataTargets(gitDir, commonTarget) + const commonTargets = gitMetadataTargets(gitDir, commonTarget, deadline) holdChain(commonFile, commonTargets) for (const commonDir of commonTargets.gitDirs) { if (commonDir === gitDir) continue @@ -778,6 +810,9 @@ export function gitFileDenies( } } catch (err) { if (err instanceof GitMetadataError) throw err + // Out of time is not a pointer that could not be followed: what it names + // was not worked out, and the file alone is no deny for that. + if (err instanceof SubmoduleWalkBudgetError) throw err // A dangling pointer names nothing git would read. A pointer this process // cannot read is one the host's git cannot read either, so the file // itself is the whole deny; an unreadable TARGET is denied whole by @@ -797,7 +832,8 @@ export function gitFileDenies( * and config, and the same for each submodule git directory under its * `modules` (what a commit inside that submodule runs), plus whatever the * walk could not see through. `deadline` bounds the walk (see - * {@link submoduleGitDirs}). + * {@link submoduleGitDirs}) and, out of the same budget, the chains the + * entries of each git directory it found lead into (see {@link gitDirDenies}). * * Kept apart rather than flattened so that a backend which cannot carry every * mount can degrade the submodule denies and leave the rest alone; @@ -810,11 +846,13 @@ export function gitDirTreeDenies( options: { deadline?: number } = {}, ): GitDirTreeDenies { const modulesDir = path.join(gitDir, 'modules') - const modules = submoduleGitDirs(modulesDir, options.deadline) - const own = gitDirDenies(gitDir, allowGitConfig) + const deadline = + options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS + const modules = submoduleGitDirs(modulesDir, deadline) + const own = gitDirDenies(gitDir, allowGitConfig, deadline) const submodules = modules.gitDirs.map(submodule => ({ gitDir: submodule, - ...gitDirDenies(submodule, allowGitConfig), + ...gitDirDenies(submodule, allowGitConfig, deadline), })) return { ownDenyPaths: own.denyPaths, @@ -948,7 +986,7 @@ export function submoduleGitDirs( // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. - const found = walkEntry(entry, child, walk) + const found = walkEntry(entry, child, walk, deadline) if (found.kind === 'skip') continue if (found.kind === 'dangling') { // Where a link leads to nothing is denied as a git directory that is @@ -1046,13 +1084,15 @@ function walkEntry( entry: fs.Dirent, entryPath: string, walk: SubmoduleWalk, + deadline: number, ): WalkEntry { if (entry.isDirectory()) return { kind: 'directory' } if (!entry.isSymbolicLink()) return { kind: 'skip' } const holder = path.dirname(entryPath) const chain = resolveChain( - physicalPath(path.parse(entryPath).root, holder), + physicalPath(path.parse(entryPath).root, holder, deadline), entry.name, + deadline, ) // The first hop is the entry, whose holder joins `linkedEntryDirs` below; // the rest lie between it and what it lands on, and that deny reaches @@ -1144,6 +1184,7 @@ function gitDirTargetDenies( kind: GitDirKind, allowGitConfig: boolean, source: string, + deadline: number, ): GitDirDenies { const only = (denyPaths: string[]): GitDirDenies => ({ denyPaths, @@ -1154,7 +1195,7 @@ function gitDirTargetDenies( switch (kind) { case 'git-dir': case 'absent': - return gitDirDenies(target, allowGitConfig) + return gitDirDenies(target, allowGitConfig, deadline) case 'unreadable': { const denied = deepestReachableAncestor(target) ?? target logForDebugging( @@ -1335,14 +1376,19 @@ interface GitMetadataTargets { * as hops all the same: putting a directory in one of their places is what * makes such a chain reach anything at all. */ -function gitMetadataTargets(base: string, target: string): GitMetadataTargets { +function gitMetadataTargets( + base: string, + target: string, + deadline: number, +): GitMetadataTargets { const lexical = path.resolve(base, target) const root = path.parse(lexical).root const chain = resolveChain( // An absolute value starts at the root and never looks at the base, so // resolving the base then is a walk that buys nothing. - path.isAbsolute(target) ? root : physicalPath(root, base), + path.isAbsolute(target) ? root : physicalPath(root, base, deadline), target, + deadline, ) const hops = chain.hops const resolved = chain.resolved @@ -1352,7 +1398,7 @@ function gitMetadataTargets(base: string, target: string): GitMetadataTargets { } // Both sides resolved the same way, so a base that merely spells itself // differently (a /var that is a symlink to /private/var) is no difference. - return chain.landing === physicalPath(root, lexical) + return chain.landing === physicalPath(root, lexical, deadline) ? { gitDirs: [lexical], hops, resolved } : { gitDirs: [lexical, chain.landing], hops, resolved } } @@ -1371,14 +1417,38 @@ function gitMetadataTargets(base: string, target: string): GitMetadataTargets { * The hops are what a caller protecting the landing still has to hold: each * one is a name a command able to write the directory around it can point * somewhere else, which moves the landing without touching either end. + * + * The hop limit bounds how many links are followed and nothing bounds how long + * each is: a link's target runs to a filesystem's four thousand bytes and a + * pointer's value to the megabyte its file may hold, every component of either + * is an lstat, and a command can leave as many of them as it likes. `deadline` + * is what bounds that, looked at as the components go by, and running out of + * it throws {@link SubmoduleWalkBudgetError} as the `.git/modules` walk does: + * a landing that was never reached is not one to work a deny out from. */ -function resolveChain(base: string, target: string): ResolvedChain { +function resolveChain( + base: string, + target: string, + deadline: number, +): ResolvedChain { let current = path.isAbsolute(target) ? path.parse(target).root : base let pending = target.split('/') const hops: ChainHop[] = [] const named = new Set() let followed = 0 + let componentsSeen = 0 while (pending.length > 0) { + // On the first component as well as every so many after it: a tree full + // of short chains is as many walks as a command cared to leave, and none + // of them is long enough to come round to a later look. + if ( + componentsSeen++ % WALK_DEADLINE_CHECK_INTERVAL === 0 && + Date.now() > deadline + ) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${abridged(target)} from ${base} ran out of the time it was given at ${current}, ${followed} symlinks in and with ${pending.length} path components still to go; refusing to sandbox on a path that was not walked to where it leads`, + ) + } const name = pending.shift() if (name === undefined || name === '' || name === '.') continue if (name === '..') { @@ -1424,8 +1494,16 @@ function resolveChain(base: string, target: string): ResolvedChain { } /** {@link resolveChain} for a caller that wants only where the walk landed. */ -function physicalPath(base: string, target: string): string { - return resolveChain(base, target).landing +function physicalPath(base: string, target: string, deadline: number): string { + return resolveChain(base, target, deadline).landing +} + +/** `value` cut to what a message can carry: a pointer's value is as long as + * its file, and the rest of it says nothing the start does not. */ +function abridged(value: string, limit = 200): string { + return value.length <= limit + ? value + : `${value.slice(0, limit)}… (${value.length - limit} more characters)` } /** @@ -1439,9 +1517,15 @@ function physicalPath(base: string, target: string): string { * `fs.realpathSync` does not answer this: it throws on a path that is not * there, and an absent `hooks` or `config.worktree` is exactly what these * denies exist to stop a command from creating. + * + * Throws {@link SubmoduleWalkBudgetError} past `deadline`, which bounds the + * walk as it bounds every other (see {@link resolveChain}). */ -export function physicalDenyPath(denyPath: string): string { - return physicalPath(path.parse(denyPath).root, denyPath) +export function physicalDenyPath( + denyPath: string, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): string { + return physicalPath(path.parse(denyPath).root, denyPath, deadline) } /** diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts new file mode 100644 index 000000000..26fa5007a --- /dev/null +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, + LinuxSandboxProfileError, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + SubmoduleWalkBudgetError, + gitDirDenies, + gitDirTreeDenies, + gitFileDenies, +} from '../../src/sandbox/mandatory-deny-paths.js' +import type { GitDirDenies } from '../../src/sandbox/mandatory-deny-paths.js' +import { + NO_COLLAPSE, + collapseEverything, + collapseFurther, +} from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir +} + +/** + * The passes a profile that does not fit is built in. Each one builds the + * whole profile again, so what bounds the passes bounds the wrap: a + * repository a command can make must not decide how many there are. + */ +describe.if(isLinux)('Passes over a profile that does not fit', () => { + let dir: string + /** A write root of its own with nothing under it, which is what makes the + * passes countable: see `wrapCountingPasses`. */ + let passMarker: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-collapse-passes-'))) + passMarker = join(dir, 'pass-marker') + mkdirSync(passMarker) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A repository with `count` submodule git directories, named so that + * sorted order is the order they were made in. */ + function makeSuperproject( + checkout: string, + count: number, + ): { checkout: string; subs: string[] } { + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < count; i++) { + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', name))) + } + return { checkout, subs } + } + + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } + + /** + * One wrap from the working directory with `writeRoot` what the command may + * write, and how many times its profile was built: every pass looks once at + * whether each write root exists, and nothing else in a wrap asks that of a + * write root that holds nothing, which is what the marker is. + * + * `spentWords` is how the budget is set: each name to unset is two words + * bubblewrap parses before the mounts, counted as they are, so what is left + * for the mounts shrinks by exactly that much. + */ + async function wrapCountingPasses( + writeRoot: string, + spentWords: number, + ): Promise<{ outcome: unknown; passes: number; warnings: string[] }> { + const realExistsSync = fs.existsSync + let passes = 0 + const spy = spyOn(fs, 'existsSync').mockImplementation((( + p: fs.PathLike, + ) => { + if (String(p) === passMarker) passes++ + return realExistsSync(p) + }) as typeof fs.existsSync) + try { + const { result, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + // No seccomp helper, whose presence differs by machine. + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { + allowOnly: [writeRoot, passMarker], + denyWithinAllow: [], + }, + unsetEnvVars: Array.from( + { length: spentWords / 2 }, + (_, i) => `SRT_SPENT_${i}`, + ), + // A refusal is an outcome like any other here: what it was is for + // the caller to say. + }).catch((refusal: unknown) => refusal), + ) + return { outcome: result, passes, warnings } + } finally { + spy.mockRestore() + } + } + + const didNotFit = (warnings: string[]): boolean => + warnings.some(warning => warning.includes('did not fit')) + + it('stops stepping where a deeper level gives no words back', async () => { + // Four hundred submodule git directories the command may not write: no + // deny of theirs takes a mount, since the read-only root already holds + // them, so degrading one gives back nothing. The words that do not fit + // are the root's and the write root's own binds, which nothing degrades. + // Stepping through such a plan an entry at a time builds the profile four + // hundred times over to arrive where it was always going to. + const writeRoot = join(dir, 'work') + mkdirSync(writeRoot) + + // Where the profile stops fitting is the same for one submodule as for + // four hundred - none of them costs a word - so it is found on the small + // repository: the least that can be spent before the mounts for the wrap + // to say its profile did not fit. + const probe = makeSuperproject(join(dir, 'probe'), 1) + process.chdir(probe.checkout) + let fits = 0 + let doesNotFit = 9000 + while (doesNotFit - fits > 2) { + const spent = 2 * Math.floor((fits + doesNotFit) / 4) + if (didNotFit((await wrapCountingPasses(writeRoot, spent)).warnings)) { + doesNotFit = spent + } else { + fits = spent + } + } + expect((await wrapCountingPasses(writeRoot, fits)).passes).toBe(1) + + const { checkout } = makeSuperproject(join(dir, 'repo'), 400) + process.chdir(checkout) + const { outcome, passes, warnings } = await wrapCountingPasses( + writeRoot, + doesNotFit, + ) + + // One pass that does not fit, one a step deeper that is no shorter, and + // one at the last level, which is what is returned. + expect(didNotFit(warnings)).toBe(true) + expect(passes).toBeGreaterThan(1) + expect(passes).toBeLessThanOrEqual(3) + // Wrapped all the same: the few words the mounts are over by are inside + // what is kept back for the words that come after them. + expect(typeof outcome).toBe('string') + }, 120000) + + it('goes to the level the steps would have run out at', () => { + // Two repositories, three submodule git directories and two: whatever a + // step is asked to give back, the steps end where every one of the five + // and both `modules` directories are denied whole, and nowhere short of + // it or past it. + const repository = ( + modulesDir: string, + names: string[], + ): SubmoduleDenyPlan['repositories'][number] => ({ + modulesDir, + wholeDirDenies: [], + gitDirs: names.map(name => ({ + gitDir: join(modulesDir, name), + denyPaths: [ + join(modulesDir, name, 'hooks'), + join(modulesDir, name, 'config'), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })), + }) + const repositories = [ + repository(join(dir, 'repo', '.git', 'modules'), ['a', 'b', 'c']), + repository(join(dir, 'repo', 'nested', '.git', 'modules'), ['d', 'e']), + ] + const plan: SubmoduleDenyPlan = { + denyPaths: repositories.flatMap(each => + each.gitDirs.flatMap(submodule => submodule.denyPaths), + ), + repositories, + chainHops: [], + } + + for (const mountsOver of [1, 3, 100]) { + let level = NO_COLLAPSE + for ( + let deeper = collapseFurther(plan, level, mountsOver); + deeper !== undefined; + deeper = collapseFurther(plan, level, mountsOver) + ) { + level = deeper + } + expect(collapseEverything(plan)).toEqual(level) + } + expect(collapseEverything(plan)).toEqual({ + wholeGitDirs: 5, + wholeModulesDirs: 2, + }) + }) + + it('still stops at the first level that fits where stepping does give words back', async () => { + // A hundred submodule git directories the command MAY write, in a budget + // that holds some of them precise and not all: each one degraded gives + // back the twelve words of its four denies, so every twelve words less + // to spend on mounts degrades exactly one more, and never the lot. + const { checkout, subs } = makeSuperproject(join(dir, 'repo'), 100) + process.chdir(checkout) + + const preciseAt = async ( + spentWords: number, + ): Promise<{ precise: string[]; passes: number }> => { + const { outcome, passes } = await wrapCountingPasses(checkout, spentWords) + expect(outcome).toBeTypeOf('string') + const denied = new Set(mountWords(outcome as string)) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + // The first in sorted order keep their precise denies, and every one + // after them is one read-only bind of the git directory. + expect(precise).toEqual(subs.slice(0, precise.length)) + for (const sub of subs.slice(precise.length)) { + expect(denied.has(sub)).toBe(true) + } + return { precise, passes } + } + + const spent = 8000 + const first = await preciseAt(spent) + expect(first.precise.length).toBeGreaterThan(1) + expect(first.precise.length).toBeLessThan(subs.length - 1) + // Measured, not predicted: the level the count alone starts from is not + // the one that fits, and the passes after it are what find that. + expect(first.passes).toBeGreaterThan(1) + expect(first.passes).toBeLessThanOrEqual(4) + + const tighter = await preciseAt(spent + 12) + expect(tighter.precise.length).toBe(first.precise.length - 1) + const looser = await preciseAt(spent - 12) + expect(looser.precise.length).toBe(first.precise.length + 1) + }, 120000) +}) + +/** + * A symlink chain, and the path a `.git` pointer or a `commondir` names, are + * walked a component at a time, and how many components there are is up to + * whoever wrote them: a link's target runs to four thousand bytes and a + * pointer file to a megabyte. The walk of them spends the same budget the + * rest of the enumeration does, and running out of it is the same refusal. + */ +describe.if(!isWindows)( + 'Chains and pointer values inside the time budget', + () => { + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-chain-budget-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** What a wrap was refused with, or undefined where it was not refused: + * the command it would have returned is not what these tests look at. */ + async function refusalOf( + params: Parameters[0], + ): Promise { + try { + await wrapCommandWithSandboxLinux(params) + return undefined + } catch (err) { + if (err instanceof LinuxSandboxProfileError) return err + throw err + } + } + + /** One step of a path that goes nowhere: into `a` and back out. */ + const DETOUR = 'a/../' + + /** + * Sixteen links in `holder`, each leading to the next through eight hundred + * detours and the last to a real directory: a chain the kernel follows in + * one stat, and a walk by hand takes some thirteen thousand lstats for. + * Sixteen is well inside the forty links the kernel follows, so that a + * stat through the chain answers the same way every time. Returns the + * first link. + */ + function makeLongChain(holder: string): string { + const links = 16 + mkdirSync(join(holder, 'a'), { recursive: true }) + mkdirSync(join(holder, 'end')) + for (let i = 0; i < links; i++) { + symlinkSync( + DETOUR.repeat(800) + (i + 1 < links ? `l${i + 1}` : 'end'), + join(holder, `l${i}`), + ) + } + return join(holder, 'l0') + } + + /** A repository whose `.git/modules` holds `count` git directories with + * every entry that is denied a link into one long chain. */ + function makeChainedSubmodules(count: number): { + checkout: string + gitDir: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const chain = makeLongChain(join(gitDir, 'chain')) + for (let i = 0; i < count; i++) { + const sub = join(gitDir, 'modules', `s${String(i).padStart(3, '0')}`) + mkdirSync(sub, { recursive: true }) + writeFileSync(join(sub, 'HEAD'), 'ref: refs/heads/main') + for (const entry of [ + 'hooks', + 'config', + 'commondir', + 'config.worktree', + ]) { + symlinkSync(chain, join(sub, entry)) + } + } + return { checkout, gitDir } + } + + it('stops in the middle of a chain rather than at the end of the last one', () => { + // Forty-eight git directories, four chains each: some two and a half + // million lstats, which is seconds of this process doing nothing else. + const { gitDir } = makeChainedSubmodules(48) + + const started = Date.now() + const error = (() => { + try { + gitDirTreeDenies(gitDir, false, { deadline: Date.now() + 100 }) + return undefined + } catch (err) { + return err + } + })() + const took = Date.now() - started + + expect(error).toBeInstanceOf(SubmoduleWalkBudgetError) + expect((error as SubmoduleWalkBudgetError).code).toBe( + 'submodule_walk_budget_exhausted', + ) + expect((error as Error).message).toContain(join(gitDir, 'modules')) + expect(took).toBeLessThan(2000) + }, 120000) + + it.if(isLinux)( + 'refuses the wrap where the chains outlast its budget', + async () => { + const { checkout, gitDir } = makeChainedSubmodules(48) + process.chdir(checkout) + + const started = Date.now() + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: 'rg', timeoutMs: 100 }, + }) + const took = Date.now() - started + + expect(refusal?.code).toBe('deny_scan_failed') + expect(refusal?.message).toContain(join(gitDir, 'modules')) + expect(took).toBeLessThan(2000) + }, + 120000, + ) + + it('stops in the middle of the path a pointer names', () => { + // A megabyte of detours, just under the size git accepts for a `.git` + // file: two hundred thousand lstats for one pointer. + const checkout = join(dir, 'checkout') + mkdirSync(join(checkout, 'a'), { recursive: true }) + makeGitDir(join(checkout, 'gd')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${DETOUR.repeat(200000)}gd\n`) + + // Time enough to start on it and not to finish: what stops the walk is + // the look at the clock it takes as the components go by. + const error = (() => { + try { + gitFileDenies(pointer, false, Date.now() + 25) + return undefined + } catch (err) { + return err + } + })() + expect(error).toBeInstanceOf(SubmoduleWalkBudgetError) + expect((error as Error).message).toContain('path components still to go') + // The value itself is not what the message carries. + expect((error as Error).message.length).toBeLessThan(2000) + + // A budget already spent stops it before the file is read at all, and + // stops at a `.git` file that is no pointer as well: that one starts no + // walk, and a tree full of them is still as many reads as there are. + expect(() => gitFileDenies(pointer, false, Date.now() - 1)).toThrow( + SubmoduleWalkBudgetError, + ) + const plain = join(dir, 'plain', '.git') + mkdirSync(join(dir, 'plain')) + writeFileSync(plain, 'not a pointer\n') + expect(gitFileDenies(plain, false).denyPaths).toEqual([plain]) + expect(() => gitFileDenies(plain, false, Date.now() - 1)).toThrow( + SubmoduleWalkBudgetError, + ) + // Given the time, it is walked to its end like any other. + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(checkout, 'gd', 'hooks'), + ) + }, 120000) + + it.if(isLinux)( + 'gives the pointer files a scan lists one budget between them', + async () => { + // One large pointer, and forty names for it: a hard link costs a + // command nothing, and each name is a `.git` file the scan lists. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const first = join(checkout, 'w00') + mkdirSync(join(first, 'a'), { recursive: true }) + writeFileSync( + join(first, '.git'), + `gitdir: ${DETOUR.repeat(100000)}gd\n`, + ) + const pointers = [join(first, '.git')] + for (let i = 1; i < 40; i++) { + const worktree = join(checkout, `w${String(i).padStart(2, '0')}`) + mkdirSync(join(worktree, 'a'), { recursive: true }) + linkSync(join(first, '.git'), join(worktree, '.git')) + pointers.push(join(worktree, '.git')) + } + // A stand-in for ripgrep that lists them and exits, so that what the + // budget is spent on is the pointers and not the scan. + const scan = join(dir, 'scan.sh') + writeFileSync( + scan, + `#!/bin/sh\nprintf '%s\\0' ${pointers.map(p => JSON.stringify(p)).join(' ')}\n`, + { mode: 0o755 }, + ) + process.chdir(checkout) + + const started = Date.now() + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: scan, timeoutMs: 500 }, + }) + const took = Date.now() - started + + expect(refusal?.code).toBe('deny_scan_failed') + // The pointers' budget, not the scan's own timeout. + expect(refusal?.message).not.toContain('did not finish') + expect(refusal?.message).toContain(checkout) + expect(took).toBeLessThan(4000) + }, + 120000, + ) + + it('resolves an ordinary chain exactly as it always has', () => { + // hooks -> ../first -> second -> .githooks: three links, short targets. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const first = join(dir, 'repo', 'first') + const second = join(dir, 'repo', 'second') + mkdirSync(landing, { recursive: true }) + symlinkSync('.githooks', second) + symlinkSync('second', first) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../first', join(gitDir, 'hooks')) + + const expected: GitDirDenies = { + denyPaths: [ + join(gitDir, 'hooks'), + landing, + first, + second, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ], + escapingDenyPaths: [landing, first, second], + linkedEntryDirs: [gitDir], + chainHops: [first, second].map(link => ({ + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link, + holder: join(dir, 'repo'), + })), + } + expect(gitDirDenies(gitDir, false)).toEqual(expected) + expect(gitDirDenies(gitDir, false, Date.now() + 60000)).toEqual(expected) + + // And the same chain in a pointer's value: through a linked directory + // to the git directory behind it. + const real = makeGitDir(join(dir, 'real', 'gd')) + symlinkSync(join(dir, 'real'), join(dir, 'repo', 'through')) + const pointer = join(dir, 'repo', 'worktree', '.git') + mkdirSync(join(dir, 'repo', 'worktree')) + writeFileSync(pointer, 'gitdir: ../through/gd\n') + const through = join(dir, 'repo', 'through') + const expectedPointer: GitDirDenies = { + denyPaths: [ + pointer, + through, + join(through, 'gd', 'hooks'), + join(through, 'gd', 'commondir'), + join(through, 'gd', 'config'), + join(through, 'gd', 'config.worktree'), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: through, + holder: join(dir, 'repo'), + }, + ], + } + expect(real).toBe(join(dir, 'real', 'gd')) + expect(gitFileDenies(pointer, false)).toEqual(expectedPointer) + expect(gitFileDenies(pointer, false, Date.now() + 60000)).toEqual( + expectedPointer, + ) + }) + }, +) From fcbb885c8bae209fefe64df342770dce300229f7 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:42 +0000 Subject: [PATCH 067/141] Deny a linked worktree's git directory from the repository that keeps it Two gaps around linked worktrees. What is a git directory was decided only by entries a command can rename. A pointer's target counted as one while it held HEAD or objects; a linked worktree's git directory has HEAD and no objects, so moving HEAD aside made the next wrap deny only the pointer file and skip the commondir behind it. .git/worktrees/, and anything under a .git/modules, is now a git directory whatever it holds, judged on the path the kernel lands on so that a symlinked .git cannot lend the shape to an ordinary directory. Elsewhere commondir counts as a marker too, which covers a bare repository's worktrees. /worktrees was never listed. After git worktree add ../wt the pointer file is outside the write roots, but .git/worktrees/wt/commondir was an ordinary writable file, and rewriting it redirects the hooks and config the host's git uses in that worktree. gitDirTreeDenies now lists the directory and denies commondir, gitdir and config.worktree of each entry, plus what an existing commondir names, on both backends. They join the collapse plan like submodules and degrade before them. --- src/sandbox/linux-deny-collapse.ts | 106 ++- src/sandbox/linux-sandbox-utils.ts | 20 +- src/sandbox/mandatory-deny-paths.ts | 444 ++++++++--- test/sandbox/mandatory-deny-worktrees.test.ts | 745 ++++++++++++++++++ 4 files changed, 1190 insertions(+), 125 deletions(-) create mode 100644 test/sandbox/mandatory-deny-worktrees.test.ts diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index e256c0b49..b51509466 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -26,6 +26,13 @@ export interface RepositorySubmodules { * rather than sitting under it, and it is no reason to degrade anything. */ wholeDirDenies: string[] + /** + * Set where these are not submodules at all but the git directories of the + * repository's linked worktrees, and `modulesDir` its `.git/worktrees`: the + * same shape degraded the same way (see {@link repositoryWorktrees}), and + * named for what it is in the wrap's warning. + */ + linkedWorktrees?: true } /** What one wrap denies, and what of that can be degraded. */ @@ -37,7 +44,9 @@ export interface SubmoduleDenyPlan { * something under, in the order they were served: the working directory's * own repository first, then the nested ones the scan found, sorted. A * repository with nothing under its `modules` is not here at all, so an - * absent one is never denied and never costs anything. + * absent one is never denied and never costs anything. Each repository's + * `.git/worktrees` follows its `modules` as an entry of its own, under the + * same conditions. */ repositories: RepositorySubmodules[] /** @@ -95,6 +104,41 @@ export function repositorySubmodules( } } +/** + * The git directories of one repository's linked worktrees as a plan + * degrades them, or undefined where it has none. + * + * A `.git/worktrees` is as writable inside the sandbox as a `.git/modules` + * is, so a command can fill one, and each directory in it costs a mount per + * file denied there and a pin. They are degraded in the steps a submodule is + * — the worktree's git directory bound read-only whole, then `worktrees` + * itself — and what a step costs is smaller: the command runs in another + * checkout, so what stops working is git housekeeping that reaches across + * worktrees (`git gc` expiring that worktree's reflog) and, at the last step, + * `git worktree add`. What a worktree's `commondir` names outside it is + * marked as leading out of the directory by the producer, and is kept. + */ +export function repositoryWorktrees( + denies: GitDirTreeDenies, +): RepositorySubmodules | undefined { + // Under `worktrees` only, and nothing at all for a repository that has + // none: an absent `.git/worktrees` is never denied, for the reason an + // absent `.git/modules` is not. + const wholeDirDenies = [ + ...denies.unreadableDirs, + ...denies.linkedEntryDirs, + ].filter(denyPath => isAtOrUnder(denyPath, denies.worktreesDir)) + if (denies.worktrees.length === 0 && wholeDirDenies.length === 0) { + return undefined + } + return { + modulesDir: denies.worktreesDir, + gitDirs: denies.worktrees, + wholeDirDenies, + linkedWorktrees: true, + } +} + /** * The deny paths of `plan` at `level`. The wrap and the violation monitor * both go through this, so what the monitor judges a refused write against is @@ -253,28 +297,41 @@ export function describeCollapse( const order = collapsibleGitDirs(plan) const alreadyWhole = coveredByWholeDirDeny(plan) const parts: string[] = [] - if (level.wholeGitDirs > 0) { - const collapsed = order - .slice(order.length - level.wholeGitDirs) - .filter(entry => !alreadyWhole(entry.gitDir)) - if (collapsed.length > 0) { - const repositories = [ - ...new Set(collapsed.map(entry => entry.modulesDir)), - ].join(', ') - parts.push( - `${collapsed.length} of the ${order.length} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, - ) + // Submodules and linked worktrees are degraded by the same steps and lose + // different things to them, so each is told in its own words. + for (const linkedWorktrees of [false, true]) { + const ofKind = (entry: { linkedWorktrees?: true }): boolean => + (entry.linkedWorktrees === true) === linkedWorktrees + if (level.wholeGitDirs > 0) { + const collapsed = order + .slice(order.length - level.wholeGitDirs) + .filter(entry => ofKind(entry) && !alreadyWhole(entry.gitDir)) + if (collapsed.length > 0) { + const repositories = [ + ...new Set(collapsed.map(entry => entry.modulesDir)), + ].join(', ') + const total = order.filter(ofKind).length + parts.push( + linkedWorktrees + ? `${collapsed.length} of the ${total} linked worktree git directories under ${repositories} are denied whole rather than by path, so git writes into those worktrees' own HEAD, index and logs fail read-only` + : `${collapsed.length} of the ${total} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, + ) + } } - } - if (level.wholeModulesDirs > 0) { - const collapsed = plan.repositories - .slice(plan.repositories.length - level.wholeModulesDirs) - .map(repository => repository.modulesDir) - .filter(modulesDir => !alreadyWhole(modulesDir)) - if (collapsed.length > 0) { - parts.push( - `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking every submodule under ${collapsed.length === 1 ? 'it' : 'them'}`, - ) + if (level.wholeModulesDirs > 0) { + const collapsed = plan.repositories + .slice(plan.repositories.length - level.wholeModulesDirs) + .filter(ofKind) + .map(repository => repository.modulesDir) + .filter(modulesDir => !alreadyWhole(modulesDir)) + if (collapsed.length > 0) { + const taken = linkedWorktrees + ? `every linked worktree's git directory under ${collapsed.length === 1 ? 'it' : 'them'}, and \`git worktree add\` with ${collapsed.length === 1 ? 'it' : 'them'}` + : `every submodule under ${collapsed.length === 1 ? 'it' : 'them'}` + parts.push( + `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking ${taken}`, + ) + } } } return parts.join('; ') @@ -298,10 +355,13 @@ function coveredByWholeDirDeny( * own sorted. */ function collapsibleGitDirs( plan: SubmoduleDenyPlan, -): Array<{ modulesDir: string; gitDir: string } & GitDirDenies> { +): Array< + { modulesDir: string; linkedWorktrees?: true; gitDir: string } & GitDirDenies +> { return plan.repositories.flatMap(repository => repository.gitDirs.map(submodule => ({ modulesDir: repository.modulesDir, + linkedWorktrees: repository.linkedWorktrees, ...submodule, })), ) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 04135c6fb..e38b515fa 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -53,6 +53,7 @@ import { collapsedDenyPaths, describeCollapse, repositorySubmodules, + repositoryWorktrees, } from './linux-deny-collapse.js' import type { FsReadRestrictionConfig, @@ -856,8 +857,7 @@ function cwdMandatoryDenyPlan( const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) chainHops.push(...denies.chainHops) - const repository = repositorySubmodules(denies) - if (repository !== undefined) repositories.push(repository) + repositories.push(...degradableGitDirs(denies)) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. @@ -872,6 +872,19 @@ function cwdMandatoryDenyPlan( return { denyPaths, repositories, chainHops } } +/** + * What of one repository's denies a wrap whose profile does not fit may + * degrade: its submodule git directories, then its linked worktrees'. A + * collapse works back from the end, so within a repository the worktrees' go + * first: a command in this working directory has less use for another + * checkout's HEAD and index than for a submodule's objects. + */ +function degradableGitDirs(denies: GitDirTreeDenies): RepositorySubmodules[] { + return [repositorySubmodules(denies), repositoryWorktrees(denies)].filter( + repository => repository !== undefined, + ) +} + /** * {@link gitDirTreeDenyPaths} plus the directories that have to be denied * WHOLE because they hold an entry that is a symlink, and minus the deny @@ -1451,8 +1464,7 @@ async function linuxGetMandatoryDenyPaths( ) denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) plan.chainHops.push(...denies.chainHops) - const repository = repositorySubmodules(denies) - if (repository !== undefined) plan.repositories.push(repository) + plan.repositories.push(...degradableGitDirs(denies)) } // Deduplicated here rather than at emission: a checked-out submodule's diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 54a0dfc86..f3d854760 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -91,6 +91,13 @@ const GIT_OWN_DIRECTORIES = new Set([ */ const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) +/** + * The narrower set {@link gitDirKind} goes by, for a directory that file + * content names; see there for why `config` and `hooks` are not in it and + * `commondir` is. + */ +const TARGET_GIT_DIR_MARKERS = new Set(['HEAD', 'objects', 'commondir']) + /** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' @@ -128,11 +135,12 @@ export class GitMetadataError extends Error { /** * The `.git/modules` walk ran out of the time it was given, so the submodule * git directories it never reached would have been left with writable hooks — - * or the walk of a symlink chain or of the path a `.git` pointer names did, - * which spends the same budget, so the git directory it never landed on would - * have been. The Linux wrap turns this into a `LinuxSandboxProfileError` - * carrying `deny_scan_failed`; on macOS it reaches the caller as itself. - * Branch on `.code`, not on the message. + * or the listing of `.git/worktrees`, which shares that time, did; or the + * walk of a symlink chain or of the path a `.git` pointer names did, which + * spends the same budget, so the git directory it never landed on would have + * been. The Linux wrap turns this into a `LinuxSandboxProfileError` carrying + * `deny_scan_failed`; on macOS it reaches the caller as itself. Branch on + * `.code`, not on the message. */ export class SubmoduleWalkBudgetError extends Error { readonly code = 'submodule_walk_budget_exhausted' as const @@ -286,13 +294,19 @@ export interface GitDirTreeDenies { modulesDir: string /** One per submodule git directory under `modulesDir`, sorted by path. */ submodules: Array<{ gitDir: string } & GitDirDenies> - /** What the walk could not see through; see {@link SubmoduleScan}. */ + /** `/worktrees`, whether or not anything is under it. */ + worktreesDir: string + /** One per linked worktree's git directory under `worktreesDir`, sorted by + * path; see {@link worktreeGitDirs}. */ + worktrees: Array<{ gitDir: string } & GitDirDenies> + /** What neither walk could see through; see {@link SubmoduleScan}. */ unreadableDirs: string[] /** Directories holding a symlinked entry; see {@link SubmoduleScan}. */ linkedEntryDirs: string[] /** Every hop between a symlinked entry of this tree and what it leads to, - * the git directory's own entries, the walked `.git/modules` entries and - * the submodules' own alike. See {@link GitChainHop}. */ + * the git directory's own entries, the walked `.git/modules` and + * `.git/worktrees` entries and the git directories under them alike, and + * the hops a worktree's `commondir` value walks. See {@link GitChainHop}. */ chainHops: GitChainHop[] } @@ -361,6 +375,34 @@ export function gitDirDenyPaths( return denyPaths } +/** + * The paths inside a linked worktree's git directory, + * `/worktrees/`, that are denied where it is reached from the + * repository that keeps it rather than through the worktree's own `.git` + * file. That file can lie outside everything the command may write — after + * `git worktree add ../wt` it does — while this directory is inside the + * repository's own, and is where the host's git, run in that worktree, starts + * from. + * + * `commondir` names the directory whose hooks and config that git uses, and + * `config.worktree` is config it reads: the files of + * {@link GIT_REDIRECT_FILES}, each under the condition it has there. `gitdir` + * names the worktree's checkout, which `git worktree remove` deletes and + * `git worktree prune` decides by. `hooks` and `config` are not among them: + * git reads neither from here while a `commondir` stands, and that is denied. + */ +function worktreeGitDirDenyPaths( + worktreeGitDir: string, + allowGitConfig: boolean, +): string[] { + return [ + ...GIT_REDIRECT_FILES.filter( + file => file.deniedWithConfigAllowed || !allowGitConfig, + ).map(file => path.join(worktreeGitDir, file.name)), + path.join(worktreeGitDir, 'gitdir'), + ] +} + /** * What a git directory is denied by once the entries it holds have been * looked at, and what of that a read-only bind of the git directory would not @@ -429,11 +471,16 @@ export interface GitDirDenies { * what it always was. `deadline` bounds the walk of the chain an entry that IS * a symlink leads into (see {@link resolveChain}); past it this throws * {@link SubmoduleWalkBudgetError}, as the `.git/modules` walk does. + * + * `entryPaths` is what is looked at, for a caller that denies other entries + * of the directory than these: a linked worktree's git directory reached from + * the repository that keeps it ({@link worktreeGitDirDenyPaths}). */ export function gitDirDenies( gitDir: string, allowGitConfig: boolean, deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, + entryPaths: string[] = gitDirDenyPaths(gitDir, allowGitConfig), ): GitDirDenies { const denyPaths: string[] = [] const escapingDenyPaths: string[] = [] @@ -479,7 +526,7 @@ export function gitDirDenies( }) } } - for (const entryPath of gitDirDenyPaths(gitDir, allowGitConfig)) { + for (const entryPath of entryPaths) { const entry = gitDirEntry(entryPath, deadline) switch (entry.kind) { case 'plain': @@ -681,50 +728,7 @@ export function gitFileDenies( const denyPaths = [gitFile] const linkedEntryDirs: string[] = [] const chainHops: GitChainHop[] = [] - const targetDenies = ( - target: string, - kind: GitDirKind, - source: string, - ): void => { - const denies = gitDirTargetDenies( - target, - kind, - allowGitConfig, - source, - deadline, - ) - denyPaths.push(...denies.denyPaths) - linkedEntryDirs.push(...denies.linkedEntryDirs) - chainHops.push(...denies.chainHops) - } - // A pointer's VALUE is a path git walks as the kernel does, so a symlink in - // it is held as a symlinked entry is: the file naming it is denied and the - // git directory it reaches is denied, while a command that retargets a link - // between the two moves where git lands without touching either end. - // - // A value that reaches nothing names no git directory to deny, and the - // links the walk did go through are the whole of what a command can move. - // One warning says so, because a `.git` file is something a sandboxed - // command may create: refusing every later command, or denying the - // checkout whole for it, would make writing one a way to lock the session - // out of its own project. - const holdChain = (source: string, targets: GitMetadataTargets): void => { - for (const hop of targets.hops) { - denyPaths.push(hop.link) - chainHops.push({ - kind: 'pointer', - chain: targets.resolved ? 'resolved' : 'unresolvable', - source, - link: hop.link, - holder: hop.holder, - }) - } - if (targets.resolved) return - logForDebugging( - `[Sandbox] The path ${source} names cannot be walked to an end (a symlink loop, or more than the ${MAX_SYMLINK_RESOLUTION_DEPTH} hops the kernel follows), so git opens nothing through it either; denying ${source} and the ${targets.hops.length} link(s) the walk did go through, and nothing besides`, - { level: 'warn' }, - ) - } + const gathered: GatheredDenies = { denyPaths, linkedEntryDirs, chainHops } // Looked at before the file is, whatever the file turns out to hold: a tree // full of `.git` files is as many reads of up to a megabyte as a command // cared to leave names for, and one that is no pointer starts no walk that @@ -755,13 +759,20 @@ export function gitFileDenies( return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } const targets = gitMetadataTargets(path.dirname(gitFile), target, deadline) - holdChain(gitFile, targets) + holdValueChain(gitFile, targets, gathered) const gitDirs = targets.gitDirs.map(gitDir => ({ gitDir, kind: gitDirKind(gitDir), })) for (const { gitDir, kind } of gitDirs) { - targetDenies(gitDir, kind, gitFile) + gatherTargetDenies( + gitDir, + kind, + allowGitConfig, + gitFile, + gathered, + deadline, + ) } // A linked worktree's git directory holds the path of the main one, whose @@ -772,41 +783,7 @@ export function gitFileDenies( // process could not list is already denied whole, and git, running as // the same user, cannot read through it either. if (kind !== 'git-dir') continue - const commonFile = path.join(gitDir, 'commondir') - let common: GitMetadata - try { - common = readGitMetadataFile(commonFile) - } catch (err) { - if (isAbsenceError(err)) continue - // The file is there and could not be read, so the directory whose - // hooks this worktree's commits run is unknown. Returning the denies - // gathered so far would leave that repository's hooks writable. - throw new GitMetadataError( - `[Sandbox] ${commonFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, - ) - } - let commonTarget: string | undefined - switch (common.kind) { - case 'contents': - commonTarget = gitMetadataPath(common.bytes, commonFile) - break - case 'too-large': - // git reads commondir whole, with no size limit of its own, so a - // file past this bound still names the directory whose hooks git - // runs. - throw new GitMetadataError( - `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, - ) - case 'none': - break - } - if (commonTarget === undefined) continue - const commonTargets = gitMetadataTargets(gitDir, commonTarget, deadline) - holdChain(commonFile, commonTargets) - for (const commonDir of commonTargets.gitDirs) { - if (commonDir === gitDir) continue - targetDenies(commonDir, gitDirKind(commonDir), commonFile) - } + gatherCommonDirDenies(gitDir, allowGitConfig, gathered, deadline) } } catch (err) { if (err instanceof GitMetadataError) throw err @@ -827,13 +804,141 @@ export function gitFileDenies( return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } } +/** What following a `gitdir:` pointer or a `commondir` adds to, one target + * and one hop at a time. */ +type GatheredDenies = Pick< + GitDirDenies, + 'denyPaths' | 'linkedEntryDirs' | 'chainHops' +> + +/** The denies of one directory a `gitdir:` or a `commondir` names, added to + * `gathered`; see {@link gitDirTargetDenies}. */ +function gatherTargetDenies( + target: string, + kind: GitDirKind, + allowGitConfig: boolean, + source: string, + gathered: GatheredDenies, + deadline: number, +): void { + const denies = gitDirTargetDenies( + target, + kind, + allowGitConfig, + source, + deadline, + ) + gathered.denyPaths.push(...denies.denyPaths) + gathered.linkedEntryDirs.push(...denies.linkedEntryDirs) + gathered.chainHops.push(...denies.chainHops) +} + +/** + * The links the walk of a `gitdir:` or `commondir` value went through, added + * to `gathered`. + * + * Such a VALUE is a path git walks as the kernel does, so a symlink in it is + * held as a symlinked entry is: the file naming it is denied and the git + * directory it reaches is denied, while a command that retargets a link + * between the two moves where git lands without touching either end. + * + * A value that reaches nothing names no git directory to deny, and the links + * the walk did go through are the whole of what a command can move. One + * warning says so, because a `.git` file is something a sandboxed command may + * create: refusing every later command, or denying the checkout whole for it, + * would make writing one a way to lock the session out of its own project. + */ +function holdValueChain( + source: string, + targets: GitMetadataTargets, + gathered: GatheredDenies, +): void { + for (const hop of targets.hops) { + gathered.denyPaths.push(hop.link) + gathered.chainHops.push({ + kind: 'pointer', + chain: targets.resolved ? 'resolved' : 'unresolvable', + source, + link: hop.link, + holder: hop.holder, + }) + } + if (targets.resolved) return + logForDebugging( + `[Sandbox] The path ${source} names cannot be walked to an end (a symlink loop, or more than the ${MAX_SYMLINK_RESOLUTION_DEPTH} hops the kernel follows), so git opens nothing through it either; denying ${source} and the ${targets.hops.length} link(s) the walk did go through, and nothing besides`, + { level: 'warn' }, + ) +} + +/** + * What the `commondir` of `gitDir` leads to, added to `gathered`: the links + * its value walks, and the denies of each directory it names that is not in + * `heldAlready` — `gitDir` itself, for a value of `.`, and for a caller that + * came from the main git directory, that one. + * + * Throws {@link GitMetadataError} for a `commondir` that is there and whose + * target cannot be worked out: the directory whose hooks git runs through it + * is then unknown, and the denies gathered so far would leave it writable. + */ +function gatherCommonDirDenies( + gitDir: string, + allowGitConfig: boolean, + gathered: GatheredDenies, + deadline: number, + heldAlready: readonly string[] = [gitDir], +): void { + const commonFile = path.join(gitDir, 'commondir') + let common: GitMetadata + try { + common = readGitMetadataFile(commonFile) + } catch (err) { + if (isAbsenceError(err)) return + // The file is there and could not be read, so the directory whose hooks + // this worktree's commits run is unknown. Returning the denies gathered + // so far would leave that repository's hooks writable. + throw new GitMetadataError( + `[Sandbox] ${commonFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, + ) + } + let commonTarget: string | undefined + switch (common.kind) { + case 'contents': + commonTarget = gitMetadataPath(common.bytes, commonFile) + break + case 'too-large': + // git reads commondir whole, with no size limit of its own, so a file + // past this bound still names the directory whose hooks git runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + case 'none': + break + } + if (commonTarget === undefined) return + const commonTargets = gitMetadataTargets(gitDir, commonTarget, deadline) + holdValueChain(commonFile, commonTargets, gathered) + for (const commonDir of commonTargets.gitDirs) { + if (heldAlready.includes(commonDir)) continue + gatherTargetDenies( + commonDir, + gitDirKind(commonDir), + allowGitConfig, + commonFile, + gathered, + deadline, + ) + } +} + /** * Every git directory a deny must cover once `gitDir` is one: its own hooks/ - * and config, and the same for each submodule git directory under its - * `modules` (what a commit inside that submodule runs), plus whatever the - * walk could not see through. `deadline` bounds the walk (see + * and config, the same for each submodule git directory under its `modules` + * (what a commit inside that submodule runs), what redirects git in each + * linked worktree's git directory under its `worktrees`, plus whatever the + * walks could not see through. `deadline` bounds both walks (see * {@link submoduleGitDirs}) and, out of the same budget, the chains the - * entries of each git directory it found lead into (see {@link gitDirDenies}). + * entries of each git directory they found lead into (see + * {@link gitDirDenies}). * * Kept apart rather than flattened so that a backend which cannot carry every * mount can degrade the submodule denies and leave the rest alone; @@ -854,20 +959,27 @@ export function gitDirTreeDenies( gitDir: submodule, ...gitDirDenies(submodule, allowGitConfig, deadline), })) + const worktrees = worktreeGitDirs(gitDir, allowGitConfig, deadline) return { ownDenyPaths: own.denyPaths, modulesDir, submodules, - unreadableDirs: modules.unreadableDirs, + worktreesDir: worktrees.worktreesDir, + worktrees: worktrees.gitDirs, + unreadableDirs: [...modules.unreadableDirs, ...worktrees.unreadableDirs], linkedEntryDirs: [ ...own.linkedEntryDirs, ...modules.linkedEntryDirs, ...submodules.flatMap(submodule => submodule.linkedEntryDirs), + ...worktrees.linkedEntryDirs, + ...worktrees.gitDirs.flatMap(worktree => worktree.linkedEntryDirs), ], chainHops: [ ...own.chainHops, ...modules.chainHops, ...submodules.flatMap(submodule => submodule.chainHops), + ...worktrees.chainHops, + ...worktrees.gitDirs.flatMap(worktree => worktree.chainHops), ], } } @@ -879,6 +991,7 @@ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { ...denies.ownDenyPaths, ...denies.unreadableDirs, ...denies.submodules.flatMap(submodule => submodule.denyPaths), + ...denies.worktrees.flatMap(worktree => worktree.denyPaths), ] // A hop of an entry under `.git/modules` has no other list to sit in, while // a hop of a git directory's OWN entry is already in that directory's deny @@ -893,6 +1006,101 @@ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { return denyPaths } +/** The linked worktrees' git directories under one `.git/worktrees`, each + * with what it is denied by, and what could not be read of the rest. */ +interface WorktreeScan { + worktreesDir: string + /** Sorted by path. A `commondir` that names another git directory than the + * one these sit in adds that directory's denies to the worktree's own, as + * ones that lead out of it. */ + gitDirs: Array<{ gitDir: string } & GitDirDenies> + /** As in {@link SubmoduleScan}, for `worktreesDir` and its entries. */ + unreadableDirs: string[] + linkedEntryDirs: string[] + chainHops: GitChainHop[] +} + +/** + * The git directories of the linked worktrees `gitDir` keeps, one per entry + * of its `worktrees`, and what each is denied by + * ({@link worktreeGitDirDenyPaths}). + * + * Every directory there is one, whatever it holds: git makes nothing else + * under `worktrees`, and an entry a command emptied — or made, for a `.git` + * file elsewhere to name — is one the host's git opens all the same. One + * level, since the name of a worktree's git directory is a single segment. + * An entry that is a symlink is followed and held as an entry of a + * `.git/modules` is ({@link walkEntry}), and a directory that cannot be + * listed is denied whole ({@link listDirectory}). + * + * Each one's `commondir` is followed as it is from a `.git` pointer: the + * host's git, run in that worktree, uses the hooks and config of whatever it + * names, which is this `gitDir` unless something has rewritten it. + * + * `deadline` is the `.git/modules` walk's, and running out of it throws + * {@link SubmoduleWalkBudgetError} the same way: a `worktrees` is as writable + * inside the sandbox as a `modules` is, and as easy to fill. + */ +function worktreeGitDirs( + gitDir: string, + allowGitConfig: boolean, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): WorktreeScan { + const worktreesDir = path.join(gitDir, 'worktrees') + const walk: SubmoduleWalk = { + gitDirs: [], + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + chainHops: [], + } + const gitDirs: WorktreeScan['gitDirs'] = [] + const entries = listDirectory(worktreesDir, walk) ?? [] + for (const [at, entry] of entries.entries()) { + if (at % WALK_DEADLINE_CHECK_INTERVAL === 0 && Date.now() > deadline) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${worktreesDir} ran out of the time it was given with ${entries.length - at} of its ${entries.length} entries still to look at; refusing to sandbox on the linked worktrees it did reach`, + ) + } + const child = path.join(worktreesDir, entry.name) + const found = walkEntry(entry, child, walk, deadline) + if (found.kind === 'skip') continue + // Where a link leads to nothing is denied as a git directory that is not + // there yet, as the `.git/modules` walk denies one. + const worktreeGitDir = found.kind === 'dangling' ? found.landing : child + const own = gitDirDenies( + worktreeGitDir, + allowGitConfig, + deadline, + worktreeGitDirDenyPaths(worktreeGitDir, allowGitConfig), + ) + const common: GatheredDenies = { + denyPaths: [], + linkedEntryDirs: [], + chainHops: [], + } + gatherCommonDirDenies(worktreeGitDir, allowGitConfig, common, deadline, [ + worktreeGitDir, + gitDir, + ]) + gitDirs.push({ + gitDir: worktreeGitDir, + denyPaths: [...own.denyPaths, ...common.denyPaths], + // No bind of the worktree's git directory covers what its commondir + // names, so a degrade of it must not drop those. + escapingDenyPaths: [...own.escapingDenyPaths, ...common.denyPaths], + linkedEntryDirs: [...own.linkedEntryDirs, ...common.linkedEntryDirs], + chainHops: [...own.chainHops, ...common.chainHops], + }) + } + return { + worktreesDir, + gitDirs: gitDirs.sort((a, b) => (a.gitDir < b.gitDir ? -1 : 1)), + unreadableDirs: [...walk.unreadableDirs].sort(), + linkedEntryDirs: [...walk.linkedEntryDirs].sort(), + chainHops: walk.chainHops, + } +} + /** * Git directories of the submodules under `modulesDir` (a repository's * .git/modules), nested submodules included, sorted. A submodule's name is @@ -1231,6 +1439,22 @@ function gitDirTargetDenies( * directory at all. The walk reaches only what lies under `.git/modules` — * except through a symlinked entry there, which a command able to write under * it can aim at one directory of its choosing. + * + * `HEAD` and `objects` are not the whole answer, because neither is a deny + * path: a command can rename one aside, and a linked worktree's git directory + * holds `HEAD` and no `objects`, so one `mv` would leave the next wrap + * denying the pointer file alone and not following `commondir`. Two things + * stand in where they are gone: + * + * - `commondir` counts as they do. A linked worktree's git directory always + * holds one, wherever its repository keeps it — a bare repository's + * `repo.git/worktrees/` as much as a `.git/worktrees/` — and it IS + * a deny path, so from the first wrap that saw the directory on it cannot + * be moved aside. No ordinary directory has an entry of that name, and one + * a command makes is what one named `HEAD` already was. + * - WHERE a directory is cannot be arranged by what a file says, so one that + * sits where only git puts a git directory is one whatever it holds just + * now: see {@link isGitOwnedLocation}. */ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] @@ -1240,9 +1464,33 @@ function gitDirKind(dir: string): GitDirKind { if (isUnusablePathError(err)) return 'unusable' return isAbsenceError(err) ? 'absent' : 'unreadable' } - return entries.some(e => e.name === 'HEAD' || e.name === 'objects') - ? 'git-dir' - : 'other' + if (entries.some(e => TARGET_GIT_DIR_MARKERS.has(e.name))) return 'git-dir' + return isGitOwnedLocation(dir) ? 'git-dir' : 'other' +} + +/** + * Whether `dir` sits where git keeps a git directory of its own making: a + * linked worktree's, `.git/worktrees/`, or a submodule's, anywhere under + * a `.git/modules` — the repository's, or the one a linked worktree keeps + * for its own submodules under `.git/worktrees//modules`. + * + * Asked of the path the kernel lands on and not of the one a pointer spells: + * a command can make `x/.git` a symlink to any directory, and a value + * written through it has this shape while what it reaches is an ordinary + * directory of the project. A directory that really is at such a path got + * there by being created or moved there, which file content cannot do. + */ +function isGitOwnedLocation(dir: string): boolean { + const parts = physicalDenyPath(dir).split('/') + return parts.some((name, at) => { + if (name !== '.git') return false + let below = parts.slice(at + 1) + if (below[0] === 'worktrees' && below.length >= 2) { + if (below.length === 2) return true + below = below.slice(2) + } + return below[0] === 'modules' && below.length >= 2 + }) } /** diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts new file mode 100644 index 000000000..e787af97a --- /dev/null +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -0,0 +1,745 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' +import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-utils.js' +import { + cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + gitFileDenyPaths, +} from '../../src/sandbox/mandatory-deny-paths.js' +import * as denyCollapse from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' + +/** + * A linked worktree's git directory, `
/.git/worktrees/`, is reached + * from two ends: through the `.git` pointer file of the worktree's checkout, + * and from the repository that keeps it. Its `commondir` names the directory + * whose hooks and config git runs in that worktree, so both ends have to hold + * it, and neither may depend on an entry a wrapped command can move aside. + */ +describe.if(!isWindows)('Linked worktree git directories', () => { + /** Everything the live arms need: a sandbox that starts. */ + const LIVE = isLinux && bwrapCanNamespace() + const HAS_GIT = Bun.which('git') !== null + const NOT_ROOT = process.getuid?.() !== 0 + /** For a case that has git itself make its fixture, several spawns of it. */ + const GIT_FIXTURE_TIMEOUT_MS = 60000 + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + // Real, so a temporary directory that is itself reached through a + // symlink does not make every path here two paths. + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktree-denies-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A directory git would accept as a repository's git directory. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir + } + + /** + * What `git worktree add` leaves: the worktree's git directory under the + * main one, and the checkout whose `.git` file names it. + */ + function makeLinkedWorktree( + mainGitDir: string, + id: string, + checkout: string, + ): { worktreeGitDir: string; pointer: string } { + const worktreeGitDir = join(mainGitDir, 'worktrees', id) + mkdirSync(worktreeGitDir, { recursive: true }) + writeFileSync(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/wt\n') + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + writeFileSync(join(worktreeGitDir, 'gitdir'), `${join(checkout, '.git')}\n`) + mkdirSync(checkout, { recursive: true }) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktreeGitDir}\n`) + return { worktreeGitDir, pointer } + } + + function git(cwd: string, args: string[]): void { + const result = spawnSync( + 'git', + [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'init.defaultBranch=main', + ...args, + ], + { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { + ...process.env, + HOME: dir, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + LC_ALL: 'C', + }, + }, + ) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + } + + /** + * A repository with one commit and a linked worktree checked out BESIDE it, + * both made by git itself: the worktree's `.git` file is outside the main + * checkout, and only its git directory is inside. + */ + function gitRepoWithWorktreeBeside(): { + main: string + worktreeGitDir: string + } { + const main = join(dir, 'main') + mkdirSync(main, { recursive: true }) + git(main, ['init', '-q', '.']) + writeFileSync(join(main, 'index.js'), 'console.log(1)\n') + git(main, ['add', 'index.js']) + git(main, ['commit', '-q', '-m', 'one']) + git(main, ['worktree', 'add', '-q', '-b', 'wt', join(dir, 'wt')]) + return { main, worktreeGitDir: join(main, '.git', 'worktrees', 'wt') } + } + + function wrap(command: string, allowOnly: string[]): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly, denyWithinAllow: [] }, + }) + } + + function run(wrapped: string, cwd: string): string { + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 60000, + cwd, + }) + return `${result.stdout}${result.stderr}` + } + + describe('reached through the pointer', () => { + it('keeps the denies behind the pointer with HEAD moved aside', () => { + // HEAD is the one marker a worktree's git directory holds, and it is + // no deny path: a command can rename it. Where the directory is says + // what it is all the same. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + // And the commondir is still followed: the hooks a commit in the + // worktree runs are the main repository's. + ...gitDirDenyPaths(mainGitDir, false), + ]) + }) + + it("keeps them for a bare repository's worktree, by the commondir it still holds", () => { + // `main.git/worktrees/wt` is nowhere only git puts a directory, so what + // it holds has to say: `commondir` is there beside HEAD, and unlike + // HEAD it is a deny path, which a command cannot move aside. + const bare = makeGitDir(join(dir, 'main.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + bare, + 'wt', + join(dir, 'wt'), + ) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(bare, false), + ]) + }) + + it('carries them into the macOS entries where the working directory is the worktree', () => { + // Profile generation is pure string building, so this runs anywhere. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const checkout = join(dir, 'wt') + const { worktreeGitDir } = makeLinkedWorktree(mainGitDir, 'wt', checkout) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + process.chdir(checkout) + + const literals = macGetMandatoryDenyEntries(false, [dir]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toContain(join(worktreeGitDir, 'commondir')) + expect(literals).toContain(join(mainGitDir, 'hooks')) + expect(literals).toContain(join(mainGitDir, 'config')) + }) + + it('keeps them with nothing left in the directory at all', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + for (const name of ['HEAD', 'commondir', 'gitdir']) { + rmSync(join(worktreeGitDir, name)) + } + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ]) + }) + + it('keeps the denies of a submodule git directory with its markers moved aside', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'vendor', 'lib')) + renameSync(join(submodule, 'HEAD'), join(submodule, 'H')) + renameSync(join(submodule, 'objects'), join(submodule, 'o')) + const checkout = join(dir, 'main', 'vendor', 'lib') + mkdirSync(checkout, { recursive: true }) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: ../../.git/modules/vendor/lib\n') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(submodule, false), + ]) + }) + + it('goes by where the directory really is, not by how the pointer spells it', () => { + // `x/.git` is a link a command made, so the path the pointer writes has + // the shape of a worktree's git directory and the directory it reaches + // is an ordinary one of the project. + const project = join(dir, 'project') + mkdirSync(join(project, 'web', 'worktrees', 'a', 'config'), { + recursive: true, + }) + mkdirSync(join(project, 'x'), { recursive: true }) + symlinkSync('../web', join(project, 'x', '.git')) + mkdirSync(join(project, 'y'), { recursive: true }) + const pointer = join(project, 'y', '.git') + writeFileSync(pointer, 'gitdir: ../x/.git/worktrees/a\n') + + const denyPaths = gitFileDenyPaths(pointer, false) + + expect(denyPaths).toContain(pointer) + expect( + denyPaths.filter(denyPath => + realPathOrSelf(denyPath).startsWith(join(project, 'web') + '/'), + ), + ).toEqual([]) + }) + + it('leaves a worktrees directory that no .git holds alone', () => { + mkdirSync(join(dir, 'data', 'worktrees', 'a', 'config'), { + recursive: true, + }) + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../data/worktrees/a\n') + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }) + + for (const mainGitDirName of ['.git', 'main.git']) { + it.if(LIVE)( + `holds commondir for the command after one that moved HEAD aside (${mainGitDirName})`, + async () => { + // The working directory is the linked worktree, and the write roots + // hold the main repository as well: the layout a commit from inside + // the sandbox needs. Once for a repository with a work tree of its + // own, once for a bare one. + const mainGitDir = makeGitDir(join(dir, 'main', mainGitDirName)) + const checkout = join(dir, 'wt') + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + checkout, + ) + const commondir = join(worktreeGitDir, 'commondir') + process.chdir(checkout) + + const first = run( + await wrap( + `mv ${join(worktreeGitDir, 'HEAD')} ${join(worktreeGitDir, 'H')} && echo MOVED`, + [dir], + ), + checkout, + ) + expect(first).toContain('MOVED') + expect(existsSync(join(worktreeGitDir, 'HEAD'))).toBe(false) + cleanupBwrapMountPoints({ force: true }) + + const second = run( + await wrap( + 'echo BOOTED; ' + + `echo ../../../planted > ${commondir} || echo COMMONDIR_HELD; ` + + `mkdir ${join(mainGitDir, 'hooks', 'x')} || echo MAIN_HOOKS_HELD; ` + + 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', + [dir], + ), + checkout, + ) + + expect(second).toContain('BOOTED') + expect(second).toContain('COMMONDIR_HELD') + expect(second).toContain('MAIN_HOOKS_HELD') + expect(second).toContain('WORK_TREE_WRITTEN') + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + expect(readFileSync(join(checkout, 'notes.txt'), 'utf8')).toBe('ok\n') + }, + 120000, + ) + } + }) + + describe('a git directory kept outside any .git', () => { + // Where it is says nothing about one of these, so what it holds has to, + // and one marker has to be enough: neither of the two below holds both a + // HEAD and an objects. + it("is followed where it is a bare repository's linked worktree", () => { + // HEAD and commondir, and no objects of its own. A repository made with + // --separate-git-dir keeps its linked worktrees the same way. + const bare = makeGitDir(join(dir, 'main.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + bare, + 'wt', + join(dir, 'wt'), + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(bare, false), + ]) + }) + + it('is followed where its objects live elsewhere', () => { + // HEAD and refs, which is all git itself asks of the directory when the + // object store is named by the environment. + const gitDir = join(dir, 'store') + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../store\n') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + }) + + describe('reached from the repository that keeps it', () => { + /** What is denied inside a worktree's git directory from this end. */ + function heldFiles(worktreeGitDir: string): string[] { + return ['commondir', 'config.worktree', 'gitdir'].map(name => + join(worktreeGitDir, name), + ) + } + + it.if(HAS_GIT)( + 'denies commondir, gitdir and config.worktree of a worktree checked out elsewhere', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + + const denyPaths = gitDirTreeDenyPaths( + gitDirTreeDenies(join(main, '.git'), false), + ) + + for (const held of heldFiles(worktreeGitDir)) { + expect(denyPaths).toContain(held) + } + // Nothing else in there: HEAD, index and logs are what git in that + // worktree writes on every checkout and commit. + expect( + denyPaths.filter(denyPath => denyPath.startsWith(worktreeGitDir)), + ).toEqual(heldFiles(worktreeGitDir)) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT)( + 'leaves config.worktree where config writes are allowed', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + + const denyPaths = gitDirTreeDenyPaths( + gitDirTreeDenies(join(main, '.git'), true), + ) + + expect(denyPaths).toContain(join(worktreeGitDir, 'commondir')) + expect(denyPaths).toContain(join(worktreeGitDir, 'gitdir')) + expect(denyPaths).not.toContain(join(worktreeGitDir, 'config.worktree')) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT && isLinux)( + 'carries them into the Linux deny list', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + process.chdir(main) + + const denyPaths = linuxGetCwdMandatoryDenyPaths(false, [main]) + + for (const held of heldFiles(worktreeGitDir)) { + expect(denyPaths).toContain(held) + } + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT)( + 'carries them into the macOS entries as literals', + () => { + // Profile generation is pure string building, so this runs anywhere. + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + process.chdir(main) + + const literals = macGetMandatoryDenyEntries(false, [main]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + for (const held of heldFiles(worktreeGitDir)) { + expect(literals).toContain(held) + } + expect(literals.length).toBe(new Set(literals).size) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT && LIVE)( + 'refuses a rewrite of commondir, and leaves the rest of the repository writable', + async () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + const commondir = join(worktreeGitDir, 'commondir') + const before = readFileSync(commondir, 'utf8') + process.chdir(main) + + const output = run( + await wrap( + 'echo BOOTED; ' + + `echo ../../../planted > ${commondir} || echo COMMONDIR_HELD; ` + + `echo /elsewhere/.git > ${join(worktreeGitDir, 'gitdir')} || echo GITDIR_HELD; ` + + `echo '[core]' > ${join(worktreeGitDir, 'config.worktree')} || echo WORKTREE_CONFIG_HELD; ` + + `mv ${worktreeGitDir} ${worktreeGitDir}.aside || echo DIRECTORY_HELD; ` + + `echo x > ${join(worktreeGitDir, 'ORIG_HEAD')} && echo REST_WRITTEN; ` + + 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', + [main], + ), + main, + ) + + expect(output).toContain('BOOTED') + expect(output).toContain('COMMONDIR_HELD') + expect(output).toContain('GITDIR_HELD') + expect(output).toContain('WORKTREE_CONFIG_HELD') + expect(output).toContain('DIRECTORY_HELD') + expect(output).toContain('REST_WRITTEN') + expect(output).toContain('WORK_TREE_WRITTEN') + expect(readFileSync(commondir, 'utf8')).toBe(before) + + // The mount point an absent config.worktree took goes with the rest. + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(worktreeGitDir, 'config.worktree'))).toBe(false) + }, + 120000, + ) + + it('denies what the commondir of one names, where that is another git directory', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + const other = makeGitDir(join(dir, 'other.git')) + writeFileSync(join(worktreeGitDir, 'commondir'), `${other}\n`) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + for (const held of gitDirDenyPaths(other, false)) { + expect(denyPaths).toContain(held) + } + }) + + it('refuses on a commondir whose target cannot be worked out, as from a pointer', () => { + // The directory whose hooks git runs in that worktree is then unknown, + // and sandboxing on the rest would leave it writable. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + writeFileSync( + join(worktreeGitDir, 'commondir'), + Buffer.from([0x2f, 0xff, 0xfe, 0x0a]), + ) + + expect(() => gitDirTreeDenies(mainGitDir, false)).toThrow( + GitMetadataError, + ) + }) + + it('names nothing twice for a commondir that names the repository itself', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + expect(denyPaths.length).toBe(new Set(denyPaths).size) + }) + + it('holds a worktree git directory that holds nothing yet', () => { + // Where it is says what it is, from this end as from the other. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const empty = join(mainGitDir, 'worktrees', 'empty') + mkdirSync(empty, { recursive: true }) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + for (const held of heldFiles(empty)) { + expect(denyPaths).toContain(held) + } + }) + + it('denies the directory holding an entry that is a symlink, and what it leads to', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktrees = join(mainGitDir, 'worktrees') + mkdirSync(worktrees, { recursive: true }) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(elsewhere, { recursive: true }) + symlinkSync(elsewhere, join(worktrees, 'linked')) + + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.linkedEntryDirs).toContain(worktrees) + expect(gitDirTreeDenyPaths(tree)).toContain( + join(worktrees, 'linked', 'commondir'), + ) + }) + + it.if(NOT_ROOT)('denies a worktrees directory it cannot list whole', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + const worktrees = join(mainGitDir, 'worktrees') + chmodSync(worktrees, 0o000) + try { + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.unreadableDirs).toContain(worktrees) + expect(gitDirTreeDenyPaths(tree)).toContain(worktrees) + } finally { + chmodSync(worktrees, 0o755) + } + }) + + it('refuses rather than hand back a listing that ran out of its time', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + + expect(() => + gitDirTreeDenies(mainGitDir, false, { deadline: Date.now() - 1 }), + ).toThrow(SubmoduleWalkBudgetError) + }) + + it('costs a repository with no linked worktrees nothing', () => { + // An absent `.git/worktrees` is never denied: a mount point planted at + // one stops `git worktree add` working in that repository. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(gitDirTreeDenyPaths(denies)).toEqual( + gitDirDenyPaths(mainGitDir, false), + ) + expect(denyCollapse.repositoryWorktrees(denies)).toBeUndefined() + }) + }) + + describe('past what the profile can carry', () => { + /** The plan a wrap builds for one repository that has no submodules. */ + function planFor(mainGitDir: string): SubmoduleDenyPlan { + const denies = gitDirTreeDenies(mainGitDir, false) + expect(denyCollapse.repositorySubmodules(denies)).toBeUndefined() + const worktrees = denyCollapse.repositoryWorktrees(denies) + if (worktrees === undefined) throw new Error('no linked worktrees found') + return { + denyPaths: gitDirTreeDenyPaths(denies), + repositories: [worktrees], + chainHops: denies.chainHops, + } + } + + it("folds a worktree's git directory into one bind, and keeps what its commondir names", () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const first = makeLinkedWorktree(mainGitDir, 'a', join(dir, 'a')) + const last = makeLinkedWorktree(mainGitDir, 'b', join(dir, 'b')) + const other = makeGitDir(join(dir, 'other.git')) + writeFileSync(join(last.worktreeGitDir, 'commondir'), `${other}\n`) + const plan = planFor(mainGitDir) + + const denies = denyCollapse.collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + + // The last in sorted order is the one degraded. + expect(denies).toContain(last.worktreeGitDir) + expect(denies).not.toContain(join(last.worktreeGitDir, 'commondir')) + expect(denies).toContain(join(first.worktreeGitDir, 'commondir')) + expect(denies).not.toContain(first.worktreeGitDir) + // No bind of the worktree's git directory covers these. + for (const kept of [ + ...gitDirDenyPaths(other, false), + ...gitDirDenyPaths(mainGitDir, false), + ]) { + expect(denies).toContain(kept) + } + }) + + it('takes every one of them with a bind of worktrees, and says which it was', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const first = makeLinkedWorktree(mainGitDir, 'a', join(dir, 'a')) + makeLinkedWorktree(mainGitDir, 'b', join(dir, 'b')) + const plan = planFor(mainGitDir) + const level = { wholeGitDirs: 2, wholeModulesDirs: 1 } + + const denies = denyCollapse.collapsedDenyPaths(plan, level) + + expect(denies).toContain(join(mainGitDir, 'worktrees')) + expect(denies).not.toContain(first.worktreeGitDir) + expect(denies).not.toContain(join(first.worktreeGitDir, 'commondir')) + for (const kept of gitDirDenyPaths(mainGitDir, false)) { + expect(denies).toContain(kept) + } + const told = denyCollapse.describeCollapse(plan, level) + expect(told).toContain('2 of the 2 linked worktree git directories') + expect(told).toContain(join(mainGitDir, 'worktrees')) + expect(told).not.toContain('submodule') + }) + + it.if(isLinux)( + 'degrades a worktrees directory a command filled rather than refusing every command after it', + async () => { + // Nine hundred, each three denies and a pin: past what bubblewrap + // parses, so the tail is one read-only bind each. + const main = join(dir, 'main') + const mainGitDir = makeGitDir(join(main, '.git')) + const worktreeGitDirs: string[] = [] + for (let i = 0; i < 900; i++) { + const id = `w${String(i).padStart(5, '0')}` + worktreeGitDirs.push( + makeLinkedWorktree(mainGitDir, id, join(dir, 'checkouts', id)) + .worktreeGitDir, + ) + } + const precise = worktreeGitDirs[0] as string + const collapsed = worktreeGitDirs[worktreeGitDirs.length - 1] as string + process.chdir(main) + + const command = await wrap( + 'echo BOOTED; ' + + `echo x > ${join(collapsed, 'commondir')} || echo COLLAPSED_HELD; ` + + `echo x > ${join(precise, 'commondir')} || echo PRECISE_HELD; ` + + `echo x > ${join(precise, 'ORIG_HEAD')} && echo PRECISE_REST_WRITTEN; ` + + 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', + [main], + ) + + const words = mountWords(command) + expect(words.length).toBeLessThan(9000) + const mounted = (dest: string): boolean => + words.some( + (word, at) => + word === '--ro-bind' && + words[at + 1] === dest && + words[at + 2] === dest, + ) + expect(mounted(join(precise, 'commondir'))).toBe(true) + expect(mounted(join(collapsed, 'commondir'))).toBe(false) + expect(mounted(collapsed)).toBe(true) + + if (LIVE) { + const output = run(command, main) + expect(output).toContain('BOOTED') + expect(output).toContain('COLLAPSED_HELD') + expect(output).toContain('PRECISE_HELD') + expect(output).toContain('PRECISE_REST_WRITTEN') + expect(output).toContain('WORK_TREE_WRITTEN') + } + }, + 300000, + ) + }) + + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } + + function realPathOrSelf(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } + } +}) From fe8d039154d69393447a0fc206fc254bbb0e521b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:42 +0000 Subject: [PATCH 068/141] linux: keep a deny off the work tree by where it lands, and refuse less The guard that keeps a whole-directory deny off the working directory and the write roots judged each deny path as it was spelled. A git directory entry that is itself a link to the checkout (nested/.git/hooks -> ../..) is spelled inside the git directory, passed the guard, and was bound where it lands: the project read-only from the next command on, with nothing inside the sandbox able to undo it. Every deny path is now judged by its landing. The binds a collapse adds past the argument cap were never guarded at all; they are now, and a bind taken back there leaves the precise denies it stood for. One existing test pinned a second write root bound whole past the cap and now expects its four precise denies. deny_unresolvable_git_entry is kept for the working directory's own repository. Its four entries are denied at their own paths from the first wrap on, so a looping link there is the caller's layout. The same chain behind a nested repository the scan found, a submodule's git directory or a modules that was not there gets the warning a looping pointer value already gets: refusing let a few ordinary writes lock every later command out and protected nothing. A debug line now names the link each directory is bound read-only to hold, so a subtree that turns read-only can be traced to its cause. --- src/sandbox/linux-deny-collapse.ts | 10 + src/sandbox/linux-sandbox-utils.ts | 109 +++++- test/sandbox/linux-work-tree-guard.test.ts | 415 +++++++++++++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 16 +- 4 files changed, 531 insertions(+), 19 deletions(-) create mode 100644 test/sandbox/linux-work-tree-guard.test.ts diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index b51509466..a7b1e64aa 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -61,6 +61,16 @@ export interface SubmoduleDenyPlan { * and `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. */ chainHops: GitChainHop[] + /** + * The entries of the git directory the working directory's own `.git` is or + * names: `hooks`, `config`, `commondir`, `config.worktree`. Each is denied + * at its own path from the first wrap that saw the repository, so a link + * found at one was there before any command ran: the layout is the user's, + * where any other git directory a wrap finds - a nested repository, a + * submodule's - may be one an earlier command made. Absent for a plan that + * was not built from a working directory. + */ + ownEntries?: string[] } /** diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e38b515fa..29933ff43 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -666,8 +666,9 @@ function truncated(text: string, limit = 400): string { * to deny for it: `unreadableAtDepthLimit` counts them, which is what says * the run's EACCES is accounted for. * - * Throws {@link LinuxSandboxProfileError} when `deadline` passes: the walk - * spends what is left of the scan's own time budget and no more. + * Throws {@link LinuxSandboxProfileError} when `deadline` passes. Every + * caller hands it a budget of its own, of the scan's length, rather than what + * a slow scan left: see `walkDeadline` in `linuxGetMandatoryDenyPaths`. */ function walkScanDirectories( cwd: string, @@ -853,11 +854,13 @@ function cwdMandatoryDenyPlan( return { denyPaths: [...denyPaths, dotGitPath], repositories, chainHops } } } + let ownEntries: string[] = [] if (dotGitStat?.isDirectory()) { const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) chainHops.push(...denies.chainHops) repositories.push(...degradableGitDirs(denies)) + ownEntries = gitDirDenyPaths(dotGitPath, allowGitConfig) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. @@ -867,9 +870,15 @@ function cwdMandatoryDenyPlan( ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), ) chainHops.push(...pointer.chainHops) + // What the pointer names is this checkout's git directory, and the one + // its `commondir` names with it. No `modules` is walked from this end, + // so every entry met here is one of theirs. + ownEntries = pointer.chainHops + .filter(hop => hop.kind === 'entry') + .map(hop => hop.source) } - return { denyPaths, repositories, chainHops } + return { denyPaths, repositories, chainHops, ownEntries } } /** @@ -1000,14 +1009,23 @@ function guardedDenyPlan( allowWritePaths: readonly string[], ): SubmoduleDenyPlan { const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) + const ownEntries = new Set(plan.ownEntries ?? []) const unresolvable = unheld.filter( - // A pointer's value is exempt: a `.git` file is something a sandboxed - // command may create, so refusing for one would let a command lock every - // later command out of the project by writing a looping pointer. What it - // could reach by re-pointing a link it is free to write, it can reach by + // Only for an entry of the working directory's own repository. Each of + // those is denied at its own path from the first wrap on, so a link there + // is the user's layout, and refusing is the answer that leaves nothing + // open. Everything else is exempt, because a command may have made it: a + // `.git` pointer file, a nested repository the scan found, a submodule's + // git directory, a `modules` that was not there. Refusing for one of + // those would let a command lock every later command out of the project + // with a few ordinary writes, and would protect nothing: what it could + // reach by re-pointing a link it is free to write, it can reach by // creating a nested repository outright, which pre-spawn scanning has // always missed. - hop => hop.kind === 'entry' && hop.chain === 'unresolvable', + hop => + hop.kind === 'entry' && + hop.chain === 'unresolvable' && + ownEntries.has(hop.source), ) if (unresolvable.length > 0) throw unresolvableGitEntry(unresolvable) if (unheld.length > 0) { @@ -1025,10 +1043,26 @@ function guardedDenyPlan( ) } const denied = new Set(plan.denyPaths) + const added = held.filter(dir => !denied.has(dir)) + if (added.length > 0) { + // A holder is a directory nothing asked to deny: say which link put it + // there, so a subtree that turns read-only can be traced to its cause. + const why = new Set( + plan.chainHops + .filter(hop => hop.holder !== undefined && added.includes(hop.holder)) + .map( + hop => + `${hop.holder} holds ${hop.link}, which ${hop.source} is reached through`, + ), + ) + logForDebugging( + `[Sandbox Linux] Bound read-only to hold a link: ${[...why].join('; ')}. Nothing in such a directory can be written by this command; removing the link, from outside the sandbox, lifts it.`, + ) + } return { ...plan, denyPaths: withoutWorkTreeDenies( - [...plan.denyPaths, ...held.filter(dir => !denied.has(dir))], + [...plan.denyPaths, ...added], allowWritePaths, ), } @@ -1061,8 +1095,14 @@ function guardedDenyPlan( * locks the project silently. The command is REFUSED instead, naming the * directory, which a `chmod` outside the sandbox puts right. * - * Deny paths reach here spelled where they LAND, which is what a bind - * resolves to; see `linuxGitDirTreeDenyPaths`. + * Each deny path is judged by where it LANDS, which is what a bind resolves + * to, and not by how it is spelled. The producers of a whole-directory deny + * spell theirs that way already (see `linuxGitDirTreeDenyPaths`), but the + * guard cannot rest on that: a git directory entry that is itself a link to + * the checkout (`hooks -> ../..`) is an ordinary deny path, spelled inside + * the git directory, and the deny loop binds the checkout for it all the + * same. Dropping only the landing would leave the entry's own path to put the + * bind back. */ function withoutWorkTreeDenies( denyPaths: readonly string[], @@ -1072,12 +1112,13 @@ function withoutWorkTreeDenies( const kept: string[] = [] const dropped: string[] = [] for (const denyPath of denyPaths) { - if (!coversWriteTree(denyPath, cwd, allowWritePaths)) { + const landing = wholeDirDenyLanding(denyPath) + if (!coversWriteTree(landing, cwd, allowWritePaths)) { kept.push(denyPath) continue } try { - fs.readdirSync(denyPath) + fs.readdirSync(landing) } catch (err) { const message = `[Sandbox Linux] ${denyPath} could not be listed (${errorText(err)}), ` + @@ -1089,7 +1130,7 @@ function withoutWorkTreeDenies( logForDebugging(message, { level: 'warn' }) throw new LinuxSandboxProfileError('deny_scan_failed', message, err) } - dropped.push(denyPath) + if (!dropped.includes(landing)) dropped.push(landing) } if (dropped.length > 0) { logForDebugging( @@ -2833,6 +2874,42 @@ function pushReadDenyDirMounts( return { restoredWrites, restoredReads } } +/** + * `plan`'s deny paths at `level`. A level that degrades nothing is the list + * {@link guardedDenyPlan} already went over. One that does adds binds of its + * own - a submodule's git directory, a `modules` directory - spelled as the + * walk found them, and an entry the walk reached through a link can land + * anywhere, the checkout or another write root included + * (`.git/modules/x -> ../..`). So what a collapse adds goes past the same + * guard before it is emitted. + * + * A bind the guard takes back stood for the precise deny paths the collapse + * had folded into it, and those come back with it: they name entries of that + * directory, not the directory, so they take nothing whole, and without them + * the git directory behind the link would keep no deny at all. + */ +function collapsedForWrap( + plan: SubmoduleDenyPlan, + level: CollapseLevel, + allowOnly: readonly string[], +): string[] { + const collapsed = collapsedDenyPaths(plan, level) + if (collapsed === plan.denyPaths) return collapsed + const kept = withoutWorkTreeDenies( + collapsed, + allowOnly.map(p => normalizePathForSandbox(p)), + ) + if (kept.length === collapsed.length) return kept + const keptPaths = new Set(kept) + const takenBack = collapsed.filter(denyPath => !keptPaths.has(denyPath)) + const folded = plan.denyPaths.filter( + denyPath => + !keptPaths.has(denyPath) && + takenBack.some(whole => isStrictlyUnder(denyPath, whole)), + ) + return [...kept, ...folded] +} + /** * Generate filesystem bind mount arguments for bwrap, inside `wordsAvailable` * of bubblewrap's cap on parsed words. @@ -2888,7 +2965,9 @@ async function generateFilesystemArgs( writeConfig, maskedFileBinds, maskedFileStoreDir, - plan === undefined ? [] : collapsedDenyPaths(plan, level), + plan === undefined + ? [] + : collapsedForWrap(plan, level, writeConfig?.allowOnly ?? []), ) if (plan === undefined) return args let degraded = diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts new file mode 100644 index 000000000..01e2ce159 --- /dev/null +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -0,0 +1,415 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + cleanupBwrapMountPoints, + LinuxSandboxProfileError, + linuxGetCwdMandatoryDenyPaths, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux } from '../helpers/platform.js' + +/** + * A deny path is bound where it LANDS: bubblewrap resolves a destination, so + * a git directory entry that is itself a link to the checkout + * (`hooks -> ../..`) binds the checkout, however the entry is spelled. The + * guard that keeps a whole-directory deny off the work tree has to judge every + * deny path that way, and not only the ones a producer spelled by landing: + * otherwise the landing is dropped and the entry's own path puts the same bind + * back. + */ +describe.if(isLinux)('a git entry that is a link to the work tree', () => { + const LIVE = bwrapCanNamespace() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'work-tree-guard-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + mkdirSync(join(checkout, 'src')) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + writeFileSync(join(checkout, 'src', 'file.c'), 'int x;\n') + return checkout + } + + /** What a few ordinary writes leave: a nested git directory, found by the + * scan, whose `hooks` is a link to `target`. */ + function plantNestedRepository(checkout: string, target: string): string { + const nestedGitDir = join(checkout, 'nested', '.git') + mkdirSync(nestedGitDir, { recursive: true }) + writeFileSync(join(nestedGitDir, 'HEAD'), 'ref: refs/heads/main\n') + symlinkSync(target, join(nestedGitDir, 'hooks')) + return nestedGitDir + } + + function wrapIn( + checkout: string, + command = 'true', + allowOnly: string[] = [checkout], + ): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly, denyWithinAllow: [] }, + }) + } + + /** Every `--ro-bind X X` of the wrapped command, by what it binds. */ + function readOnlyBinds(command: string): string[] { + return [...command.matchAll(/--ro-bind (\S+) (\S+)/g)] + .filter(([, source, dest]) => source === dest) + .map(([, source]) => source!) + } + + function landing(denyPath: string): string { + try { + return realpathSync(denyPath) + } catch { + return denyPath + } + } + + it('does not deny the checkout for its own hooks linked at it', () => { + const checkout = makeCheckout('repo') + rmSync(join(checkout, '.git', 'hooks'), { recursive: true }) + symlinkSync('..', join(checkout, '.git', 'hooks')) + + process.chdir(checkout) + const denyPaths = linuxGetCwdMandatoryDenyPaths(false, [checkout]) + + expect(denyPaths.map(landing)).not.toContain(checkout) + // The link is held by the directory around it, which is what holds any + // symlinked entry. + expect(denyPaths).toContain(join(checkout, '.git')) + }) + + it('does not bind the checkout for a nested entry linked at it', async () => { + const checkout = makeCheckout('repo') + const nestedGitDir = plantNestedRepository(checkout, '../..') + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + const binds = readOnlyBinds(command) + expect(binds).not.toContain(checkout) + expect(binds).toContain(nestedGitDir) + expect(binds).toContain(join(checkout, '.git', 'hooks')) + // Said once, since no bind is there to say it. + expect( + warnings.filter(w => w.includes('would have been denied whole')), + ).toHaveLength(1) + }) + + it('does not bind a second write root an entry is linked at', async () => { + const checkout = makeCheckout('repo') + const other = join(dir, 'other-root') + mkdirSync(other) + writeFileSync(join(other, 'existing.txt'), 'kept\n') + const nestedGitDir = plantNestedRepository(checkout, other) + + const binds = readOnlyBinds( + await wrapIn(checkout, 'true', [checkout, other]), + ) + + expect(binds).not.toContain(other) + expect(binds).toContain(nestedGitDir) + }) + + it('does not bind a directory that holds the checkout', async () => { + const checkout = makeCheckout('repo') + // `dir` holds the checkout and is not itself writable here. + plantNestedRepository(checkout, dir) + + const binds = readOnlyBinds(await wrapIn(checkout)) + + expect(binds).not.toContain(dir) + expect(binds).not.toContain(checkout) + }) + + it('still binds an ordinary directory an entry is linked at', async () => { + const checkout = makeCheckout('repo') + mkdirSync(join(checkout, 'shared-hooks')) + plantNestedRepository(checkout, '../../shared-hooks') + + const binds = readOnlyBinds(await wrapIn(checkout)) + + expect(binds).toContain(join(checkout, 'shared-hooks')) + expect(binds).not.toContain(checkout) + }) + + it('does not bind the checkout where a collapse folds a linked modules entry', async () => { + // Past the argument cap a submodule's git directory is bound whole, as + // the walk spelled it. One the walk reached through `modules/zz -> ../..` + // is the checkout, which has an entry named `hooks` and so passes for a + // git directory. The collapse adds its bind after the plan was guarded, + // so what it adds is guarded again. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + for (let i = 0; i < 3000; i++) { + const submodule = join( + gitDir, + 'modules', + `s${String(i).padStart(5, '0')}`, + ) + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main') + } + symlinkSync('nowhere', join(checkout, 'hooks')) + symlinkSync('../..', join(gitDir, 'modules', 'zz-linked')) + + const binds = readOnlyBinds(await wrapIn(checkout)) + + // The submodules are behind one bind, of `modules` or of what holds it. + expect( + binds.includes(join(gitDir, 'modules')) || binds.includes(gitDir), + ).toBe(true) + expect(binds).not.toContain(checkout) + }, 120000) + + it('says which link a directory is bound read-only to hold', async () => { + // A link chain through `src` makes `src` the only handle on the link in + // it, so `src` is bound read-only: a directory nothing asked to deny. The + // debug log is where that can be traced back to the link. + const checkout = makeCheckout('repo') + mkdirSync(join(checkout, 'tgt')) + symlinkSync('../tgt', join(checkout, 'src', 'a')) + plantNestedRepository(checkout, '../../src/a') + + const lines: string[] = [] + const savedDebug = process.env.SRT_DEBUG + process.env.SRT_DEBUG = '1' + const spies = [ + spyOn(console, 'error').mockImplementation((...parts: unknown[]) => { + lines.push(parts.map(String).join(' ')) + }), + spyOn(console, 'warn').mockImplementation(() => {}), + ] + let command: string + try { + command = await wrapIn(checkout) + } finally { + for (const spy of spies) spy.mockRestore() + if (savedDebug === undefined) delete process.env.SRT_DEBUG + else process.env.SRT_DEBUG = savedDebug + } + + expect(readOnlyBinds(command)).toContain(join(checkout, 'src')) + const told = lines.filter(l => l.includes('Bound read-only to hold a link')) + expect(told).toHaveLength(1) + expect(told[0]).toContain( + `${join(checkout, 'src')} holds ${join(checkout, 'src', 'a')}`, + ) + expect(told[0]).toContain(join(checkout, 'nested', '.git', 'hooks')) + }) + + it.if(LIVE)( + 'leaves the project writable for the command after the one that made the link', + async () => { + const checkout = makeCheckout('repo') + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 20000 }) + + const planted = run( + await wrapIn( + checkout, + "mkdir -p nested/.git && printf 'ref: refs/heads/main\\n' > nested/.git/HEAD && ln -s ../.. nested/.git/hooks && echo PLANTED", + ), + ) + expect(planted.stdout).toContain('PLANTED') + expect(planted.status).toBe(0) + cleanupBwrapMountPoints({ force: true }) + + const next = run( + await wrapIn( + checkout, + 'echo BOOTED; echo w > work.txt && echo edit >> src/file.c && echo WROTE; rm -f nested/.git/hooks; echo "rm=$?"', + ), + ) + + expect(next.stdout).toContain('BOOTED') + expect(next.stdout).toContain('WROTE') + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe('w\n') + expect(readFileSync(join(checkout, 'src', 'file.c'), 'utf8')).toBe( + 'int x;\nedit\n', + ) + // The link itself is still held, by the git directory around it. + expect(next.stdout).toContain('rm=1') + expect( + lstatSync(join(checkout, 'nested', '.git', 'hooks')).isSymbolicLink(), + ).toBe(true) + expect(existsSync(join(checkout, 'nested', '.git', 'HEAD'))).toBe(true) + }, + 60000, + ) +}) + +/** + * A git directory entry whose link chain reaches nothing (a loop) and goes + * through a link the working directory holds has no bind that holds it and no + * far end to deny. For an entry of the working directory's own repository that + * is the user's layout, and the command is refused. For anything a command may + * have made - a nested repository, a `modules` that was not there - refusing + * would let a few ordinary writes lock every later command out, so the wrap + * warns and runs. + */ +describe.if(isLinux)('a git entry whose link chain is a loop', () => { + const LIVE = bwrapCanNamespace() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'loop-guard-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + /** `a -> b -> a` in the checkout's root. */ + function makeLoop(checkout: string): void { + symlinkSync('b', join(checkout, 'a')) + symlinkSync('a', join(checkout, 'b')) + } + + function wrapIn(checkout: string, command = 'true'): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + function readOnlyBinds(command: string): string[] { + return [...command.matchAll(/--ro-bind (\S+) (\S+)/g)] + .filter(([, source, dest]) => source === dest) + .map(([, source]) => source!) + } + + it('refuses for an entry of the working directory own repository', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + rmSync(join(checkout, '.git', 'hooks'), { recursive: true }) + symlinkSync('../a', join(checkout, '.git', 'hooks')) + + const refusal = await wrapIn(checkout).then( + () => undefined, + (error: unknown) => error, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'deny_unresolvable_git_entry', + ) + }) + + it('warns and wraps for a nested repository', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + const nestedGitDir = join(checkout, 'nested', '.git') + mkdirSync(nestedGitDir, { recursive: true }) + writeFileSync(join(nestedGitDir, 'HEAD'), 'ref: refs/heads/main\n') + symlinkSync('../../a', join(nestedGitDir, 'hooks')) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + // The link itself is held by the git directory around it. + expect(readOnlyBinds(command)).toContain(nestedGitDir) + expect(readOnlyBinds(command)).not.toContain(checkout) + expect( + warnings.filter( + w => + w.includes(join(nestedGitDir, 'hooks')) && + w.includes(join(checkout, 'a')), + ).length, + ).toBeGreaterThan(0) + }) + + it('warns and wraps for a modules that was not there', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + symlinkSync('../a', join(checkout, '.git', 'modules')) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + expect(readOnlyBinds(command)).toContain(join(checkout, '.git')) + expect(readOnlyBinds(command)).not.toContain(checkout) + expect( + warnings.filter(w => w.includes(join(checkout, '.git', 'modules'))) + .length, + ).toBeGreaterThan(0) + }) + + it.if(LIVE)( + 'runs the command after one that left a loop behind a nested repository', + async () => { + const checkout = makeCheckout('repo') + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 20000 }) + + const planted = run( + await wrapIn( + checkout, + "ln -s b a && ln -s a b && mkdir -p nested/.git && printf 'ref: refs/heads/main\\n' > nested/.git/HEAD && ln -s ../../a nested/.git/hooks && echo PLANTED", + ), + ) + expect(planted.stdout).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const next = run( + await wrapIn(checkout, 'echo BOOTED; echo w > work.txt && echo WROTE'), + ) + + expect(next.stdout).toContain('BOOTED') + expect(next.stdout).toContain('WROTE') + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe('w\n') + }, + 60000, + ) +}) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 897ef8fb1..ed1740461 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -5032,9 +5032,11 @@ describe.if(isSupportedPlatform)( readConfig: undefined, writeConfig: { allowOnly: [checkout, outside], denyWithinAllow: [] }, }) - // The whole tree behind one bind, and the linked entry - which that - // bind does not cover, since the deny lands where the link really - // points - behind one of its own. + // The whole tree behind one bind. The linked entry is not covered by + // it, since a deny lands where the link really points, and what it + // points at is a write root: binding that whole would take a + // directory the command was given away from it, so it keeps the + // denies of its own entries instead. const words = mountWords(command) const mounted = (source: string, dest: string): boolean => words.some( @@ -5046,7 +5048,13 @@ describe.if(isSupportedPlatform)( expect(mounted(join(gitDir, 'modules'), join(gitDir, 'modules'))).toBe( true, ) - expect(mounted(outside, outside)).toBe(true) + expect(mounted(outside, outside)).toBe(false) + expect(mounted(join(outside, 'hooks'), join(outside, 'hooks'))).toBe( + true, + ) + expect(mounted(join(outside, 'config'), join(outside, 'config'))).toBe( + true, + ) const result = spawnSync(command, { shell: true, From a7caee2caba751eb90675facf50f116b04d3f9af Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:09:42 +0000 Subject: [PATCH 069/141] README: what the latest round changed, and six claims corrected Linked worktrees, a git directory by location, the modules walk past a marker, the narrowed refusal, a deny judged by its landing, the repair through a descriptor, and the time budgets. Corrected: the macOS profile is bounded by ARG_MAX; git init and git clone into a subdirectory succeed on Linux; the scan is anchored at the working directory; the walk after an unreadable directory takes a budget of its own. Added as known limits: core.hooksPath into the work tree, a bare-repository layout in a subdirectory, and sequencer state. --- README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 62064f961..0de24b5e1 100644 --- a/README.md +++ b/README.md @@ -776,6 +776,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Which git directories are covered.** - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). +- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. They are listed for the working directory's own repository and, on Linux, for each nested repository the scan finds. A session whose working directory is itself a linked worktree reaches its own git directory through its `.git` file; its sibling worktrees' are not listed from that end. +- A directory a pointer or a `commondir` names is a git directory by what it holds (`HEAD`, `objects` or `commondir`) or by where it is: `.git/worktrees/`, and anything under a `.git/modules`, is one with every marker moved aside. Where it is is judged on the path the kernel lands on, so a symlinked `.git` cannot lend that shape to an ordinary directory. Renaming `HEAD` inside a linked worktree's git directory therefore drops no deny. - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. A pointer or a `commondir` whose VALUE reaches nothing is not denied whole anywhere either — neither the directory it would fold to nor the deepest one still reachable towards it, which for a value like `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself. git opens nothing through such a value, and a `.git` file is something a sandboxed command is deliberately allowed to create, so failing closed there would let one command leave every command after it unable to write the project. What is denied is the pointer file and the links the walk did go through, with one `SRT_DEBUG` warning naming the file. @@ -783,29 +785,33 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **`modules` is held as a link, and is no deny path of its own.** A git directory keeps its submodules' git directories under `modules`, each with precise denies of its own, so denying that directory whole would take every one of them read-only. But it is an entry like the others in the one respect that matters: remove the link, put a directory of your own where it was, and every submodule git directory the repository keeps is one nothing denied - which is a hook the host's git runs. So it is held the way a chain hop is, by its own name where a filter matches the name a rename uses, and by the directory holding it - that git directory, denied whole - where a deny resolves. This applies to the top-level `.git/modules` as much as to a submodule's own. **What it costs, and that a command can arrange it:** an ordinary repository has no `.git/modules` at all, and an absent one is deliberately not denied (a mount there would stop `git submodule add`), so one `ln -s` inside the sandbox makes `.git` a git directory with a symlinked entry - and from the next wrap on `git add`, `git commit` and `git checkout -b` fail read-only in that repository, with nothing inside the sandbox able to remove the link. `git status`, `log` and `diff` keep working, and removing the link from outside the sandbox puts it back. That is the same family as the other costs a command can plant, and it is the price of holding the link at all. - **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. **A chain that reaches NOTHING is held by its hops alone.** A loop, or a chain longer than the kernel follows, has no landing: git gives up on it exactly as this does, so the deny the landing would get stands on nothing, and every link the walk visited before it gave up is the whole of what a command can move — put a directory in one of their places and the chain reaches it, and the host's git reads the hooks or config inside (tested against real git). Each is therefore denied like any other hop, named once however many times the loop met it. A loop that stays inside the git directory needs nothing more: the whole-directory deny that a symlinked entry already brings covers its links. One that runs out through the work tree is held by the directories holding those links — and where THAT cannot be done, because such a directory is the working directory or a write root, Linux **refuses the command** (`deny_unresolvable_git_entry`), since the warning that stands in for a bind elsewhere leans on both ends of the chain being denied and here there is no far end. That refusal is what such a repository got before any of this existed, when the deny path simply could not be resolved. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. - **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either — as long as the chain reaches something, which is what leaves both its ends denied; a chain that reaches nothing through such a hop is refused instead (see above), unless it is a pointer's value, where the refusal would be the lockout it exists to avoid and the warning is the whole answer. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. -- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. +- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The same budget bounds the listing of `.git/worktrees`, the walk of a symlink chain a git directory entry leads into, and the walk of the path a `.git` pointer or a `commondir` names: a link target runs to 4095 bytes and a pointer file to 1 MiB, and every component of either is an `lstat`. The `.git` pointer files one scan lists share a single such budget between them. The budgets add up rather than overlap: at worst a wrap spends one on the working directory's own repository before the scan, the scan's own timeout, and then one each on the walk after a scan that could not read a directory, the git-directory walk `allowGitConfig` adds, all the pointer files together, and every nested repository found. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. +- A directory that looks like a git directory does not end the `.git/modules` walk. What makes it look like one is the name of a single entry, and a segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, so a `HEAD` left in `vendor` would otherwise take the denies off `vendor/lib` from the next command on. The directory is denied as a git directory and what else it holds is walked as well. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and one of those is walked after all where that listing shows a `config` or a `hooks`, which a submodule named `vendor/lfs` has and git's own directory of that name does not. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while the profile fits; 2. a submodule git directory that does not fit is denied WHOLE, one read-only bind — git writes inside that submodule fail read-only, its `objects`, `refs` and `index` with them, and nothing outside it changes. A checked-out submodule's `.git` pointer names the same git directory, so its copy of those denies goes with them; the pointer file keeps its own deny, which names a file and has nothing to collapse into, and so does what an entry that is a symlink leads to, which lies outside the git directory and no bind of it covers; 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule really under it goes with it. An entry the walk reached through a symlink can be anywhere, and one whose real path is outside keeps the deny it had: a bind of `modules` does not cover it. + Linked worktrees' git directories are degraded by the same two steps, before the same repository's submodules: a worktree's git directory bound whole costs cross-worktree housekeeping (`git gc` expiring that worktree's reflog), and `.git/worktrees` bound whole costs `git worktree add`. A step that gives nothing back, because the git directories it took lie outside every write root and the read-only root holds them without a mount, ends the stepping: the next build is the last level, so the number of builds does not grow with how many submodules a repository holds. + With one write root and no network restrictions, that starts at about 590 submodule git directories, or about 425 where each is checked out. A `SRT_DEBUG` warning says what was degraded, at which level, and for which repositories; there is no other wrap-time channel to report it on. The Linux violation monitor works the same denies out for itself, once for the session and with nothing degraded, so a command that forces a degrade later leaves it naming the path inside a submodule git directory the wrap has by then denied whole — the write is refused either way, and it is the path in the report that is stale, never what bubblewrap enforces. `too_many_arguments` is what is left for a profile that does not fit with every submodule deny degraded: measured at about 1475 CHECKED-OUT submodules, whose pointer files and pins no degrading saves, and at a few hundred separate nested repositories, whose own four denies are not submodule denies. Bare submodule git directories do not reach it at any count, multi-segment (`vendor/lib`) and nested names included: the one bind over `.git/modules` is where they end. - On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. -- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there: a Seatbelt profile is a text file with no cap on how many entries it may carry, so every submodule keeps its precise denies however many there are. +- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there, so every submodule keeps its precise denies however many there are. A Seatbelt profile has no cap on entries of its own, but it travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, from the caller's own `spawn` and with no code from this library. **The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. - The file is removed after the command. One left behind by a killed process is claimed and removed by the next wrap — recognised by its content alone, so a `commondir` holding exactly `.` that somebody else put there is taken away with it — and an empty `commondir`, which git refuses outright, is repaired the same way; an empty `commondir` that still has write bits belongs to a host `git` caught between creating the file and writing it, and is left exactly as found. - For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. -- Where the git directory takes no mount point at all — one this process cannot write, such as a root-owned vendored checkout or a read-only mount — the git directory is bound read-only whole instead. That needs nothing written to the host, costs what was already unwritable, and is also what stops the command from making the directory writable and creating the file itself. +- Where the git directory takes no mount point at all — one this process cannot write, such as a root-owned vendored checkout or a read-only mount — the git directory is bound read-only whole instead. That needs nothing written to the host, costs what was already unwritable, and is also what stops the command from making the directory writable and creating the file itself. That bind is then the only mount the wrap puts inside the directory: a placeholder for another absent entry of it (a `config` or a `hooks` that is not there) would need a mount point made where none can be, or made by bubblewrap where this process could never remove it, so those entries are left to the bind, which denies them with everything else in the directory. +- A name found taken is read, and an empty one repaired, through the file that was looked at and never through its name: the git directory can be one a command already running writes, and a check of the path followed by a write to it would write wherever that command had pointed the name in between. Where the name has been given to something else meanwhile (a link, another file, a FIFO) that is left exactly as found and the git directory is bound read-only whole for the command instead; the cleanup likewise leaves a mount point whose name no longer leads to the file it checked. - Cleanup is deferred per process, not across processes: two processes wrapping commands in one repository can each claim the same mount point, and the one that cleans up first removes it while the other's sandbox is still running, which detaches that bind and stops that one deny applying inside it. The bind's source is the placeholder in the temporary directory rather than the mount point, so the sandbox still starts. **Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: - `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. -- `deny_unresolvable_git_entry` — an entry of a git directory under the working directory is a symlink whose chain reaches nothing (a loop, or a name no file can occupy), and one of the links it goes through sits in the working directory or a path this command may write, where no read-only bind can hold it. There is no landing to deny instead, so the command is refused rather than run with the link free; the message names the entry, the link and the directory holding it. Repairing the chain, moving the links inside the git directory, or a `denyWrite` entry naming the directory holding them, each lifts it. A pointer's value that reaches nothing is NOT this: see the bullet above. +- `deny_unresolvable_git_entry` — an entry of the working directory's OWN repository (`hooks`, `config`, `commondir` or `config.worktree` of the git directory its `.git` is or names) is a symlink whose chain reaches nothing (a loop, or a name no file can occupy), and one of the links it goes through sits in the working directory or a path this command may write, where no read-only bind can hold it. There is no landing to deny instead, so the command is refused rather than run with the link free; the message names the entry, the link and the directory holding it. Repairing the chain, moving the links inside the git directory, or a `denyWrite` entry naming the directory holding them, each lifts it. A pointer's value that reaches nothing is NOT this: see the bullet above. Neither is the same chain behind anything a command may have made (a nested repository the scan found, a submodule's git directory, a `modules` that was not there): those get the warning an unheld hop gets and the command runs, because refusing there would let a few ordinary writes lock every later command out, and would protect nothing the command could not already reach by creating a nested repository outright. The four entries of the working directory's own repository are denied at their own paths from the first wrap that saw it, so a link found at one is the caller's layout; what is left to plant is a repository created and linked this way by one command in a directory that had none. - `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. - `deny_scan_failed` where a `.git/modules` tree takes longer to walk than the scan's own budget: a tree wide or slow enough is one a command can make, and the walk refuses rather than hand back a listing that stops somewhere unknown. Removing what makes it slow lifts it. On macOS the same condition is a `SubmoduleWalkBudgetError`, exported from the package root. @@ -813,20 +819,26 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. -**No whole-directory deny is ever emitted for the work tree.** Every one of them is a stand-in read off paths a sandboxed command can write - a `.git` pointer naming the checkout (`gitdir: .`, with one entry of the checkout a symlink, makes the checkout a git directory denied whole), a `.git/modules` entry linked at `../..`, a git directory entry pointing at the checkout, a directory left unreadable - and one that lands on the working directory, an ancestor of it, a write root or a directory holding one leaves every later command unable to write the project, with nothing inside the sandbox able to undo it. So: a directory that can be LISTED is dropped with a warning, since what the deny was holding keeps the deny paths of its own two ends; a directory that cannot be listed stands in for a tree nothing else covers, so the command is REFUSED instead (`deny_scan_failed`, naming the directory), which one `chmod` outside the sandbox lifts. The same call is made on macOS, where a literal entry is a subpath deny. +**No whole-directory deny is ever emitted for the work tree.** Every one of them is a stand-in read off paths a sandboxed command can write - a `.git` pointer naming the checkout (`gitdir: .`, with one entry of the checkout a symlink, makes the checkout a git directory denied whole), a `.git/modules` entry linked at `../..`, a git directory entry pointing at the checkout, a directory left unreadable - and one that lands on the working directory, an ancestor of it, a write root or a directory holding one leaves every later command unable to write the project, with nothing inside the sandbox able to undo it. So: a directory that can be LISTED is dropped with a warning, since what the deny was holding keeps the deny paths of its own two ends; a directory that cannot be listed stands in for a tree nothing else covers, so the command is REFUSED instead (`deny_scan_failed`, naming the directory), which one `chmod` outside the sandbox lifts. Every deny path is judged by where it LANDS, since a bind resolves its destination: an entry that is itself a link to the checkout (`nested/.git/hooks -> ../..`) is dropped the same way, and the git directory around it, denied whole, is what holds the link. What a degrade adds is judged again, and a bind taken back there leaves the precise denies it stood for. The same call is made on macOS, where a literal entry is a subpath deny. -**A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. +**A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. An `SRT_DEBUG` line names, for each directory bound this way, the link it is bound for and the entry that is reached through it. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: - removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; -- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason; +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason, and `git worktree prune` (and the prune `git gc` runs) for a worktree the repository still keeps, whose `.git/worktrees//` entries are denied; - `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; -- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`; +- on macOS, `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. On Linux both succeed, since nothing is mounted where no `.git` was when the command was wrapped, and the repository they make is not covered until the next wrap; - on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**; - on Linux, every write inside a directory that HOLDS a hop of such a chain — an entry's or a `gitdir:` pointer value's — for the same reason: with `hooks -> ../links/hop`, or with `gitdir: links/hop/gd`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. +**Known limit: a `core.hooksPath` or `include.path` into the work tree.** The denied `config` is never read. Where the repository's own config already sets `core.hooksPath` to a directory in the work tree (husky's `.husky/_`, generated and gitignored), git never looks in `.git/hooks`, and what it does run is ordinary writable content; the same goes for a file an `include.path` names. A sandboxed command cannot create that state, since `config` is denied, so it matters where the caller already has it. + +**Known limit: a bare-repository layout in a subdirectory.** `HEAD`, `objects/`, `refs/` and `config` directly in a directory make it a repository git finds before the parent's `.git` when it is run from there (`safe.bareRepository` defaults to `all`). Nothing looks for that layout, so none of these denies apply to it. + +**Known limit: sequencer state.** `/rebase-merge/git-rebase-todo` takes `exec` lines that `git rebase --continue` runs, and it stays writable. It needs that specific command run on the host afterwards. + **Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`, `modules` itself included. A `.git` that is itself a symlink to a git directory elsewhere is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. **Known limit: a hook FILE that is a symlink into the work tree.** The install idiom `ln -s ../../scripts/pre-commit .git/hooks/pre-commit`, with `hooks` itself a real directory, is not one of the entries above: the hooks directory is denied, so no name can be added there and none re-linked, but the script the link leads to is ordinary project content, and its CONTENT is writable — directly and through the link — on every build. That is the sandbox doing its job: a tracked script in the work tree is exactly what a command is there to edit, and the sandbox has no way to tell the script the user chose to run as a hook from any other file in the repository. A `denyWrite` entry naming the script is the remedy where that matters. @@ -838,7 +850,7 @@ A directory the command makes unreadable does **not** refuse anything: it is den **Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: - every code `ENOENT`: entries that went away while the scan walked. Nothing is at those paths to deny, and the rest of the listing still counts. -- any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and inside the scan's own time budget, not by reading the paths off stderr. A directory that is readable but not SEARCHABLE (mode 0400) lists its entries and lets nothing through them, so it is that directory, not the child whose own listing failed, that is denied: naming the child would ask bubblewrap for a file mount at what is really a directory and fail every later wrap in the tree. +- any code `EACCES`: a directory the scan could not read holds an unknown tree, so it is denied whole and the rest of the listing still counts. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and with a time budget of its own of the scan's length, not by reading the paths off stderr. A directory that is readable but not SEARCHABLE (mode 0400) lists its entries and lets nothing through them, so it is that directory, not the child whose own listing failed, that is denied: naming the child would ask bubblewrap for a file mount at what is really a directory and fail every later wrap in the tree. - anything else, no code at all, or an `EACCES` the walk cannot find a directory for: the command is refused, with the raw stderr (truncated) in the message. A directory that sits at the very depth the scan stops at is a case of its own: ripgrep's walker opens one to list it and reports it when it cannot, while this walk stops a level above, so such an EACCES is accounted for with nothing to deny - nothing below it is in the scan's reach on either side. Without that, a project holding a directory another user owns exactly that far down (a `docker/data/postgres`, mode 0700) had every command refused. @@ -853,7 +865,7 @@ With `allowWrite: ["/"]` the pins reach every ancestor, including any other allo A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`: +**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories of the working directory. Every mandatory deny is anchored there: a repository under a second allowed write path outside it gets none of them. By default, it searches up to 3 levels deep for performance, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`: ```json { From 6b5adcae602f9eaf9afd8b9e7fc42dada1560879 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 17:13:21 +0000 Subject: [PATCH 070/141] test: fit the long-chain fixture into a macOS link target The time-budget suite builds a symlink chain out of targets holding eight hundred `a/../` detours each, about 4000 bytes. Linux takes a target up to 4095 bytes; macOS stops at 1023 and refused to make the link, so the first case failed in its set-up there. The fixture now uses two hundred detours per link on macOS, which still leaves over a million lstats against a 100 ms budget. --- .../mandatory-deny-time-budget.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index 26fa5007a..92f11fbf5 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -317,12 +317,19 @@ describe.if(!isWindows)( const DETOUR = 'a/../' /** - * Sixteen links in `holder`, each leading to the next through eight hundred + * As many detours as a link's target has room for: a target runs to 4095 + * bytes on Linux and to 1023 on macOS, and a longer one is refused with + * ENAMETOOLONG when the link is made. + */ + const DETOURS_PER_LINK = process.platform === 'darwin' ? 200 : 800 + + /** + * Sixteen links in `holder`, each leading to the next through that many * detours and the last to a real directory: a chain the kernel follows in - * one stat, and a walk by hand takes some thirteen thousand lstats for. - * Sixteen is well inside the forty links the kernel follows, so that a - * stat through the chain answers the same way every time. Returns the - * first link. + * one stat, and a walk by hand takes thousands of lstats for (some + * thirteen thousand on Linux). Sixteen is well inside the forty links the + * kernel follows, so that a stat through the chain answers the same way + * every time. Returns the first link. */ function makeLongChain(holder: string): string { const links = 16 @@ -330,7 +337,8 @@ describe.if(!isWindows)( mkdirSync(join(holder, 'end')) for (let i = 0; i < links; i++) { symlinkSync( - DETOUR.repeat(800) + (i + 1 < links ? `l${i + 1}` : 'end'), + DETOUR.repeat(DETOURS_PER_LINK) + + (i + 1 < links ? `l${i + 1}` : 'end'), join(holder, `l${i}`), ) } From f614f845291c39cefc4910f4b2864bbb5f520ee2 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:22:11 +0000 Subject: [PATCH 071/141] Deny a submodule's linked worktrees too, and walk an entry link once `git worktree add` inside a submodule keeps the worktree's git directory under the SUBMODULE's, `.git/modules//worktrees/`, and its `commondir` redirects git there as one under the repository's own `.git/worktrees` does. Only the repository's own were listed. Each submodule git directory's `worktrees` is now listed as well, and what is found joins that submodule's deny paths: it is all under its git directory, so the one bind a degrade puts there covers it, except what a `commondir` names, which is kept as escaping. The `.git/modules` walk goes on past a git directory and looks at what else it holds. A link at `config`, `commondir` or `config.worktree` was followed there a second time, as a possible submodule, although what denies the git directory already reads it as that directory's entry and holds both ends of it. For a tree of long link chains that was seven walks of a chain per git directory where four were needed, and enough to take a layout that fitted the time budget out of it. Such a link is left to the git directory that owns it. The warning for a chain that reaches nothing said the entry "reaches what it denies" through the link. It says the entry leads nowhere. --- src/sandbox/linux-sandbox-utils.ts | 4 +- src/sandbox/mandatory-deny-paths.ts | 55 +++++++++++++++-- test/sandbox/linux-work-tree-guard.test.ts | 17 +++--- .../mandatory-deny-modules-walk.test.ts | 24 ++++++++ test/sandbox/mandatory-deny-worktrees.test.ts | 59 +++++++++++++++++++ 5 files changed, 146 insertions(+), 13 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 29933ff43..63b6cfc52 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1033,7 +1033,9 @@ function guardedDenyPlan( .map(hop => hop.kind === 'pointer' ? `the git directory ${hop.source} names is reached through ${hop.link}` - : `${hop.source} reaches what it denies through ${hop.link}`, + : hop.chain === 'unresolvable' + ? `${hop.source} leads nowhere, through ${hop.link}` + : `${hop.source} reaches what it denies through ${hop.link}`, ) .join('; ') const holders = [...new Set(unheld.map(hop => hop.holder))].join(', ') diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index f3d854760..986b7b92c 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -91,6 +91,13 @@ const GIT_OWN_DIRECTORIES = new Set([ */ const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) +/** + * The entries of a git directory that are files: `config`, and the two that + * redirect git to another directory. `hooks` is the fourth entry denied, and a + * directory, which {@link GIT_OWN_DIRECTORIES} has. + */ +const GIT_ENTRY_FILES = new Set(['config', 'commondir', 'config.worktree']) + /** * The narrower set {@link gitDirKind} goes by, for a directory that file * content names; see there for why `config` and `hooks` are not in it and @@ -955,10 +962,39 @@ export function gitDirTreeDenies( options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS const modules = submoduleGitDirs(modulesDir, deadline) const own = gitDirDenies(gitDir, allowGitConfig, deadline) - const submodules = modules.gitDirs.map(submodule => ({ - gitDir: submodule, - ...gitDirDenies(submodule, allowGitConfig, deadline), - })) + const submoduleUnreadableDirs: string[] = [] + const submodules = modules.gitDirs.map(submodule => { + const own = gitDirDenies(submodule, allowGitConfig, deadline) + // `git worktree add` inside a submodule keeps the worktree's git directory + // under the SUBMODULE's, and its `commondir` redirects git there as one + // under the repository's own `worktrees` does. What is found belongs to + // the submodule: it is all under its git directory, so a bind of that + // stands in for it where the profile is degraded, except what a + // `commondir` names, which is kept as escaping. + const kept = worktreeGitDirs(submodule, allowGitConfig, deadline) + submoduleUnreadableDirs.push(...kept.unreadableDirs) + return { + gitDir: submodule, + denyPaths: [ + ...own.denyPaths, + ...kept.gitDirs.flatMap(worktree => worktree.denyPaths), + ], + escapingDenyPaths: [ + ...own.escapingDenyPaths, + ...kept.gitDirs.flatMap(worktree => worktree.escapingDenyPaths), + ], + linkedEntryDirs: [ + ...own.linkedEntryDirs, + ...kept.linkedEntryDirs, + ...kept.gitDirs.flatMap(worktree => worktree.linkedEntryDirs), + ], + chainHops: [ + ...own.chainHops, + ...kept.chainHops, + ...kept.gitDirs.flatMap(worktree => worktree.chainHops), + ], + } + }) const worktrees = worktreeGitDirs(gitDir, allowGitConfig, deadline) return { ownDenyPaths: own.denyPaths, @@ -966,7 +1002,11 @@ export function gitDirTreeDenies( submodules, worktreesDir: worktrees.worktreesDir, worktrees: worktrees.gitDirs, - unreadableDirs: [...modules.unreadableDirs, ...worktrees.unreadableDirs], + unreadableDirs: [ + ...modules.unreadableDirs, + ...submoduleUnreadableDirs, + ...worktrees.unreadableDirs, + ], linkedEntryDirs: [ ...own.linkedEntryDirs, ...modules.linkedEntryDirs, @@ -1172,6 +1212,11 @@ export function submoduleGitDirs( if (item.gitDir === true) { // Queued when the git directory was recorded. if (entry.name === 'modules') continue + // A link at `config`, `commondir` or `config.worktree` is read as + // this git directory's entry by what denies it, which holds both ends + // of it (see gitDirDenies). Following it here as well would walk the + // same chain a second time and take what it leads to for a submodule. + if (entry.isSymbolicLink() && GIT_ENTRY_FILES.has(entry.name)) continue // One of git's own directories by its name: walked on only where one // listing of it shows something this file protects, which is what a // submodule's git directory that has such a name shows and git's own diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts index 01e2ce159..c1104435b 100644 --- a/test/sandbox/linux-work-tree-guard.test.ts +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -360,13 +360,16 @@ describe.if(isLinux)('a git entry whose link chain is a loop', () => { // The link itself is held by the git directory around it. expect(readOnlyBinds(command)).toContain(nestedGitDir) expect(readOnlyBinds(command)).not.toContain(checkout) - expect( - warnings.filter( - w => - w.includes(join(nestedGitDir, 'hooks')) && - w.includes(join(checkout, 'a')), - ).length, - ).toBeGreaterThan(0) + const told = warnings.filter( + w => + w.includes(join(nestedGitDir, 'hooks')) && + w.includes(join(checkout, 'a')), + ) + expect(told.length).toBeGreaterThan(0) + // Said as what it is: this chain reaches nothing, so there is nothing it + // "reaches what it denies through". + expect(told[0]).toContain('leads nowhere') + expect(told[0]).not.toContain('reaches what it denies') }) it('warns and wraps for a modules that was not there', async () => { diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 5e3575351..8c0ca8ce9 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -226,6 +226,30 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }, ) + it.if(!isWindows)( + 'leaves a link at one of a git directory own entries to that directory', + () => { + // `config`, `commondir` and `config.worktree` are read as the git + // directory's entries, a link at one included, by what denies them, and + // both ends of such a link are held there. Followed here as well, the + // same chain would be walked a second time and what it leads to taken + // for a submodule of its own. + const sub = makeGitDir(join(modules, 'sub')) + const elsewhere = makeGitDir(join(dir, 'elsewhere')) + rmSync(join(sub, 'config')) + symlinkSync(elsewhere, join(sub, 'config')) + symlinkSync(elsewhere, join(sub, 'commondir')) + symlinkSync(elsewhere, join(sub, 'config.worktree')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + it('walks what else a git directory holds, and finds nothing in an ordinary one', () => { // Directories that are neither git's own large ones nor `modules`: the // empty `branches` of an older template, a rebase in progress. diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index e787af97a..c19661dc6 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -596,6 +596,65 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ).toThrow(SubmoduleWalkBudgetError) }) + it("denies the same for a linked worktree a submodule's git directory keeps", () => { + // `git worktree add` inside a submodule puts the worktree's git + // directory under the SUBMODULE's, `.git/modules//worktrees/`, + // and its `commondir` is as writable there as under the repository's + // own `.git/worktrees`. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + submodule, + 'wt', + join(dir, 'lib-wt'), + ) + + const denies = gitDirTreeDenies(mainGitDir, false) + const denyPaths = gitDirTreeDenyPaths(denies) + + for (const name of ['commondir', 'gitdir', 'config.worktree']) { + expect(denyPaths).toContain(join(worktreeGitDir, name)) + } + // They belong to the submodule, so a bind of its git directory, which + // they are all under, stands in for them where the profile is degraded. + const found = denies.submodules.find(each => each.gitDir === submodule) + expect(found?.denyPaths).toContain(join(worktreeGitDir, 'commondir')) + expect(found?.escapingDenyPaths).not.toContain( + join(worktreeGitDir, 'commondir'), + ) + }) + + it("carries a submodule's linked worktrees into the macOS entries", () => { + const checkout = join(dir, 'main') + const mainGitDir = makeGitDir(join(checkout, '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + submodule, + 'wt', + join(dir, 'lib-wt'), + ) + + process.chdir(checkout) + const literals = macGetMandatoryDenyEntries(false, [checkout]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toContain(join(worktreeGitDir, 'commondir')) + expect(literals).toContain(join(worktreeGitDir, 'gitdir')) + }) + + it('costs a submodule with no linked worktrees nothing', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(gitDirTreeDenyPaths(denies)).toEqual([ + ...gitDirDenyPaths(mainGitDir, false), + ...gitDirDenyPaths(submodule, false), + ]) + }) + it('costs a repository with no linked worktrees nothing', () => { // An absent `.git/worktrees` is never denied: a mount point planted at // one stops `git worktree add` working in that repository. From 62efa8deae9333304dd9dd535414e6dccc053c45 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:22:11 +0000 Subject: [PATCH 072/141] README: a submodule's linked worktrees, and what git worktree prune does The linked worktrees a submodule's git directory keeps are denied like the repository's own. Inside the sandbox `git worktree prune` on a stale entry prints one error line, exits 0 and leaves the entry for the next prune outside, and a detached `git gc --auto` leaves no gc.log over it (measured with git 2.55): the README said only that it fails. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0de24b5e1..966ff6611 100644 --- a/README.md +++ b/README.md @@ -776,7 +776,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Which git directories are covered.** - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). -- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. They are listed for the working directory's own repository and, on Linux, for each nested repository the scan finds. A session whose working directory is itself a linked worktree reaches its own git directory through its `.git` file; its sibling worktrees' are not listed from that end. +- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. They are listed for the working directory's own repository and each of its submodules' git directories (`.git/modules//worktrees//`, where `git worktree add` inside a submodule puts one) and, on Linux, for each nested repository the scan finds. A session whose working directory is itself a linked worktree reaches its own git directory through its `.git` file; its sibling worktrees' are not listed from that end. - A directory a pointer or a `commondir` names is a git directory by what it holds (`HEAD`, `objects` or `commondir`) or by where it is: `.git/worktrees/`, and anything under a `.git/modules`, is one with every marker moved aside. Where it is is judged on the path the kernel lands on, so a symlinked `.git` cannot lend that shape to an ordinary directory. Renaming `HEAD` inside a linked worktree's git directory therefore drops no deny. - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. @@ -826,7 +826,7 @@ A directory the command makes unreadable does **not** refuse anything: it is den **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: - removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; -- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason, and `git worktree prune` (and the prune `git gc` runs) for a worktree the repository still keeps, whose `.git/worktrees//` entries are denied; +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason, and `git worktree prune` (and the prune `git gc` runs) for a STALE worktree entry the repository still keeps, whose `.git/worktrees//` files are denied: it prints one `failed to delete` line, exits 0 all the same, removes what it can of that entry and leaves the rest for the next prune outside the sandbox; a detached `git gc --auto` leaves no `gc.log` behind over it (measured with git 2.55); - `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; - on macOS, `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. On Linux both succeed, since nothing is mounted where no `.git` was when the command was wrapped, and the repository they make is not covered until the next wrap; From a928ebe391466c9871b67c3b3b3034b97f93210c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 12:42:16 +0000 Subject: [PATCH 073/141] linux: only a manifest makes a path a mount point, and the library's own directories are pinned Six changes to how mount points are recognised, kept and protected. A wrap names an existing directory at a deny path whenever any manifest names it, a finished one as much as a live one. It used to ask only the live ones: after a killed process left an empty directory and its manifest, a later wrap that denied that directory bound it without naming it, and the next clean-up removed it from under the running sandbox. An existing empty read-only file at a deny path is a mount point only when a manifest names it. It used to be taken for a leftover by its shape alone, so a file the user keeps empty and read-only there was covered, named and removed after the command. One that no manifest names is now bound read-only where it is and never removed. A mount point left by a killed process of a release that wrote no manifest therefore stays until someone deletes it. The manifest directories, the store of fake files and the empty directory that directory mount points bind from are seeds of the ancestor pins. With the temp dir strictly inside a write root a command could rename it aside, make the name again with a manifest directory of its own, and have the next clean-up in another process remove whatever a forged manifest named. The same three are bound where they really are. bubblewrap makes a bind's destination by name inside the new root, so with the temp dir reached through a link whose target is absolute no sandbox that restricts writes started. A manifest is read only if it is a regular file of the user's of at most 1 MiB, through a descriptor opened without following a link and without blocking. liveMountPoints() is exported from the package root: a snapshot of the mount points a sandbox that may still be running relies on, each checked to still be an empty placeholder on disk, for a caller that removes paths of its own accord after a command. It makes nothing on the host. --- src/index.ts | 3 + src/sandbox/bwrap-mount-manifests.ts | 221 ++++++++++++++++++++++++--- src/sandbox/linux-sandbox-utils.ts | 173 ++++++++++++++------- 3 files changed, 318 insertions(+), 79 deletions(-) diff --git a/src/index.ts b/src/index.ts index d0a06954e..767f16f76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,9 @@ export { LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, } from './sandbox/linux-sandbox-utils.js' +// The mount points a running sandbox relies on (Linux), as a snapshot, for an +// embedder that removes paths itself after a command to leave out. +export { liveMountPoints } from './sandbox/bwrap-mount-manifests.js' // Windows install/status API export { diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index a4733d2ed..12117aaf2 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -29,10 +29,15 @@ * restricts writes at all, whether or not its own invocation names a manifest * in it, and so is the other name a process of the same user would keep its * manifests under (see {@link mountPointManifestDirectories}), so a sandboxed - * command can neither delete nor rewrite one, its own or another sandbox's. + * command can neither delete nor rewrite one, its own or another sandbox's; + * and the directories between each of them and the write root it lies in are + * pinned there, so that the command cannot rename one aside and make the + * directory again under the old name, with manifests of its own in it. * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with the directory writable, and a process that looks - * for its runtime or temp directory somewhere this one does not. + * release of it did, with the directory writable, a process that looks + * for its runtime or temp directory somewhere this one does not, and a + * runtime or temp directory whose name is a link inside a write root, or not + * there yet below one: neither can be pinned. * * Both of the kernel's answers are relative to a PID namespace: /proc/locks * lists a lock only when its holder has a pid in the namespace of the /proc @@ -118,6 +123,13 @@ const LISTING_GOOD_FOR_MS = 0.25 */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 +/** + * The largest file that is read as a manifest. One names a wrap's mount + * points, some dozens of paths, and is a few kilobytes; whatever is larger + * than this is not one, and is not read into memory to find that out. + */ +const MANIFEST_MAX_BYTES = 1024 * 1024 + /** * What a manifest, and the directory lock, is written as before it is moved * into place. A process killed between the two leaves the file behind, and a @@ -537,9 +549,12 @@ function isOurDirectory(dir: string): boolean { * it first and fill it before the process that will believe it comes along; * one that is there and the user's is listed even when it cannot be written * from here. The last resort is not made for this: there is nothing to keep - * out of reach in a directory nobody has made. + * out of reach in a directory nobody has made. A wrap that is about to record + * says so with `recording`, and the directory its manifest will go to is then + * settled first, the last resort included: the wrap has to know every one of + * these before it publishes, to pin what lies above them. */ -export function mountPointManifestDirectories(): string[] { +export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { return [] } @@ -550,6 +565,9 @@ export function mountPointManifestDirectories(): string[] { dirs.add(candidate) } } + if (recording) { + ensureManifestDirectory() + } if (manifestDirectory !== undefined && isOurDirectory(manifestDirectory)) { dirs.add(manifestDirectory) } @@ -764,14 +782,33 @@ function withDirectoryLock( } } +/** + * What is at a manifest's name, read as one, or `undefined` when it is not a + * manifest this version can read. Only a regular file of the user's, no + * larger than {@link MANIFEST_MAX_BYTES}, is read at all, and it is looked at + * through the descriptor it is then read from, opened without following a + * link and without blocking: opening a FIFO to read waits for a writer, a + * link leads wherever whoever planted it likes, and nothing but such a file + * is a manifest this library wrote. + */ function readManifest(file: string): Manifest | undefined { let inode: string let device: string let text: string try { - const fd = fs.openSync(file, 'r') + const fd = fs.openSync( + file, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ) try { const stat = fs.fstatSync(fd, { bigint: true }) + if ( + !stat.isFile() || + Number(stat.uid) !== process.getuid?.() || + stat.size > MANIFEST_MAX_BYTES + ) { + return undefined + } inode = String(stat.ino) device = deviceOf(stat.dev) text = fs.readFileSync(fd, 'utf8') @@ -984,9 +1021,8 @@ export type MountPointManifest = { * written, in which case the caller must not track those mount points at all: * what this process cannot record it does not remove. That is as far as it * goes. Nothing on disk then says a sandbox relies on them, so a wrap in - * another process that denies the same path takes the file for a leftover, - * covers it, names it, and removes it after its own command, whether or not - * the sandbox that could not be recorded is still running. + * another process that denies the same path takes the file for the caller's + * own: it binds it onto itself and names it nowhere, and nobody removes it. */ export function publishMountPointManifest( mountPoints: readonly string[], @@ -1058,38 +1094,173 @@ export function discardMountPointManifest(file: string): void { } /** - * The mount points named by a manifest whose sandbox may still be running. + * The mount points the manifests name, on one reading of the directory: + * `live`, by a manifest whose sandbox may still be running, and `named`, by + * any manifest at all. What only a finished manifest names is what a sandbox + * that has ended left behind and no pass has collected yet, and the next pass + * in any process removes it on that manifest's word. * - * An empty set when the directory or /proc/locks cannot be read: the shape of - * the file on the host still recognises a mount point an earlier sandbox left - * behind, and this only adds the ones a live manifest vouches for. + * `live` is empty when /proc/locks cannot be read, and both are when the + * directory cannot be. A wrap takes an existing path for a mount point on a + * manifest's word only, never by the look of it, so it then takes every + * existing path for the caller's own. */ -export function liveMountPoints(): Set { +export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() - if (!onLinux()) { - return live - } - const dir = ensureManifestDirectory() - const locks = dir === undefined ? undefined : readLocks(dir) - if (dir === undefined || locks === undefined) { - return live + const named = new Set() + const dir = onLinux() ? ensureManifestDirectory() : undefined + if (dir === undefined) { + return { live, named } } + const locks = readLocks(dir) let names: string[] try { names = fs.readdirSync(dir) } catch { - return live + return { live, named } } for (const name of names) { if (!name.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(path.join(dir, name)) - if (manifest !== undefined && isLive(manifest, locks)) { - for (const mountPoint of manifest.paths) { + if (manifest === undefined) continue + const isLiveOne = locks !== undefined && isLive(manifest, locks) + for (const mountPoint of manifest.paths) { + named.add(mountPoint) + if (isLiveOne) { + live.add(mountPoint) + } + } + } + return { live, named } +} + +/** + * The directory this process keeps its manifests in, if it is there already: + * the one it has settled on, else the first of the shared names that is a + * directory of the user's alone. Nothing is made, and nothing is settled: for + * a look that has to leave the host as it found it. + */ +function existingManifestDirectory(): string | undefined { + return manifestDirectory !== undefined && + isOurPrivateDirectory(manifestDirectory) + ? manifestDirectory + : sharedManifestDirectoryNames().find(isOurPrivateDirectory) +} + +/** + * Whether what is at `p` now is still something a clean-up would take away on + * a manifest's word: the empty file bubblewrap made (see + * {@link isBwrapFileMountPoint}), or an empty directory of the user's. + */ +function isEmptyMountPoint(p: string): boolean { + if (isBwrapFileMountPoint(p)) { + return true + } + try { + const stat = fs.lstatSync(p) + return ( + stat.isDirectory() && + stat.uid === process.getuid?.() && + fs.readdirSync(p).length === 0 + ) + } catch { + return false + } +} + +/** + * The mount points a sandbox that may still be running relies on, as the + * manifests say at the moment of the call: for a caller that removes paths of + * its own accord after a command, to leave out the ones in this set. Removing + * a mount point from under a running sandbox lifts the deny there. + * + * It is a snapshot, taken without a lock, from one listing of the manifest + * directory and one reading of /proc/locks, and it may be out of date by the + * time the caller acts on it: a sandbox can start on a path the moment after. + * Its only safe use is to SKIP a removal. That a path is not in it never + * means the path is free to be written, or that nothing will rely on it. + * + * A path is in the set only when both of these hold: + * - a manifest names it, and that manifest is live by the rule the clean-up + * goes by, or its liveness cannot be told. It cannot be told when + * /proc/locks cannot be read, or while a manifest that cannot be read is + * locked or new; every path that any readable manifest names then counts. + * What a manifest that cannot be read names cannot be listed at all; + * - what is at the path now still has the shape of a mount point: an empty + * regular file with no write bit and one link, or an empty directory, and + * the user's. So whatever a caller spares on the word of this set is an + * empty placeholder, and a manifest that is out of date, or forged, never + * makes it spare a file with something in it. + * + * The paths are as the wraps recorded them: absolute, with the links in the + * directories above the last component resolved and the last component as it + * stands. A caller compares its own paths in that form. + * + * The manifests are believed as found. Their directory can be written by the + * user's own processes outside any sandbox; every sandbox that restricts + * writes has it bound read-only, with the directories between it and the + * write root it lies in pinned, which does not reach a runtime or temp + * directory whose name is a link inside a write root, nor a runtime directory + * that is not there yet below one. It answers for one manifest directory, + * the one this process keeps its manifests in or, before it has kept any, the + * first of the shared names that is there, so a sandbox of a process that + * keeps its manifests elsewhere, or of something that writes none, is not + * seen. + * + * Makes nothing on the host, the manifest directory included. Empty where + * there is no manifest directory, and anywhere but on Linux. + */ +export function liveMountPoints(): ReadonlySet { + const spared = new Set() + const dir = onLinux() ? existingManifestDirectory() : undefined + if (dir === undefined) { + return spared + } + const locks = readLocks(dir) + let names: string[] + try { + names = fs.readdirSync(dir) + } catch { + return spared + } + const live = new Set() + const named = new Set() + let cannotTell = locks === undefined + for (const name of names) { + if (!name.endsWith(MANIFEST_SUFFIX)) continue + const file = path.join(dir, name) + const manifest = readManifest(file) + if (manifest === undefined) { + // Gone since the listing, or not to be read: held to what the clean-up + // holds it to. + try { + const stat = fs.statSync(file, { bigint: true }) + cannotTell ||= + locks === undefined || + holdsLock(locks, { + inode: String(stat.ino), + device: deviceOf(stat.dev), + }) || + Date.now() - Number(stat.mtimeMs) < MANIFEST_GRACE_MS + } catch { + // Gone. + } + continue + } + const isLiveOne = locks !== undefined && isLive(manifest, locks) + for (const mountPoint of manifest.paths) { + named.add(mountPoint) + if (isLiveOne) { live.add(mountPoint) } } } - return live + for (const mountPoint of cannotTell ? named : live) { + if (isEmptyMountPoint(mountPoint)) { + spared.add(mountPoint) + } + } + return spared } /** diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 9ec6424b9..7af3e3ea8 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -13,8 +13,8 @@ import { discardMountPointManifest, isBwrapFileMountPoint, kindOfMountPoint, - liveMountPoints, mountPointManifestDirectories, + namedMountPoints, publishMountPointManifest, removePrivateManifestDirectory, type MountPointManifest, @@ -1412,7 +1412,8 @@ const KERNEL_TOP_LEVEL_DIRS = ['/proc', '/dev', '/sys'] /** * A self-bind ("pin") for every directory between a seed (where a deny bind, - * file mask or read-deny tmpfs lands) and the allowed write root covering it, + * file mask or read-deny tmpfs lands, or a directory of this library's own + * that is bound read-only) and the allowed write root covering it, * shallow-first, which makes each one a mountpoint the kernel refuses to * rename or remove. A directory that is not there is skipped: bwrap cannot * bind a missing source and there is nothing to rename. One that exists but @@ -1632,9 +1633,10 @@ async function generateFilesystemArgs( // wrap covers again. They are named in a manifest below, and nothing removes // them while a sandbox that named them runs (see bwrap-mount-manifests.ts). const mountPoints: string[] = [] - // The mount points a manifest says a running sandbox relies on, read once - // per wrap: they are mount points whatever their mode on the host says. - let liveMountPointsCache: Set | undefined + // The mount points the manifests name, read once per wrap. The ones a + // manifest says a running sandbox relies on are mount points whatever their + // mode on the host says. + let namedMountPointsCache: ReturnType | undefined // Collect normalized allowed write paths. Populated in the writeConfig // block, read again in the denyRead loop to re-bind writes under tmpfs. @@ -2358,49 +2360,80 @@ async function generateFilesystemArgs( // A mount point another sandbox made is an absent deny path in all but // name. Bound onto itself as an existing path it would never be named in // this wrap's manifest, so it would go, with this sandbox still bound - // over it, as soon as the sandbox that made it was cleaned up after; - // and a leftover nobody names would never go at all, where on the host - // its existence can be the whole meaning (a lockfile's). There are two - // ways to know one, and what is done with it depends on its kind: - // - a FILE, by its shape (see isBwrapFileMountPoint), or because a live - // manifest names the path and it is still an empty regular file. It is - // covered with /dev/null like the absent leaf below, and named. Under - // a read-only denied directory nothing needs covering (the file is - // already unwritable there), but it is still named: it is no more the - // caller's file for being there; - // - a DIRECTORY, only because a live manifest names it: an empty - // directory looks like anyone's. It is the first missing component of - // some other sandbox's deny. /dev/null cannot be bound over a - // directory (bubblewrap refuses to start), so it is named and then - // treated as the existing directory it is, further down: bound onto - // itself read-only, which is also what the pre-pass above recorded it - // as; - // - anything else a live manifest names (a file that has been written - // to since, a link) is the caller's by now, and is neither. + // over it, as soon as the sandbox that made it was cleaned up after. + // Only a manifest says that a path is one. An empty read-only file + // looks like anyone's, and so does an empty directory: taken for a + // leftover by its shape alone, a file its owner keeps empty and + // read-only was covered, named, and removed after the command. What no + // manifest names is the caller's, bound onto itself further down like + // any existing path and never removed, which leaves on the host what a + // killed process of a release that wrote no manifest left there. Any + // manifest will do, a finished one as much as a live one: a finished + // manifest is what a killed process leaves, the next clean-up in any + // process removes what it names on its word unless a live manifest + // names it too, and this wrap's is then the only one that can. What is + // done with a path a manifest names depends on its kind: + // - a FILE that has the shape bubblewrap leaves (see + // isBwrapFileMountPoint), or, where the manifest is live, that is + // still an empty regular file. It is covered with /dev/null like the + // absent leaf below, and named. Under a read-only denied directory + // nothing needs covering (the file is already unwritable there), but + // it is still named: it is no more the caller's file for being there; + // - a DIRECTORY, which is the first missing component of some other + // sandbox's deny. /dev/null cannot be bound over a directory + // (bubblewrap refuses to start), so it is named and then treated as + // the existing directory it is, further down: bound onto itself + // read-only, which is also what the pre-pass above recorded it as; + // - anything else (a file that has been written to since, a link) is + // the caller's by now, and is neither. // The shape is asked before the existence: another process's collect can - // take a leftover away at any moment, and a path found to exist and then - // found not to be a mount point because it has just gone would be bound - // onto itself as a file that is not there. And once more after it, when - // the two disagree: another sandbox starting on the same path makes its - // mount point at any moment too, and one made between the two questions - // would be bound onto itself as the caller's own file, named by this - // wrap nowhere, and gone from under it at that sandbox's clean-up. - let isFileMountPoint = isBwrapFileMountPoint(normalizedPath) - // Whether anything is at the path, asked once for the branches below. - const pathExists = isFileMountPoint || fs.existsSync(normalizedPath) - if (pathExists && !isFileMountPoint) { - isFileMountPoint = isBwrapFileMountPoint(normalizedPath) + // take a mount point away at any moment, and a path found to exist and + // then found not to have the shape because it has just gone would be + // bound onto itself as a file that is not there. And once more after it, + // when the two disagree: another sandbox starting on the same path makes + // its mount point at any moment too. + let hasFileShape = isBwrapFileMountPoint(normalizedPath) + // Whether anything is at the path, asked once for the branches below, + // and once more where the manifests say the file is nobody's. + let pathExists = hasFileShape || fs.existsSync(normalizedPath) + if (pathExists && !hasFileShape) { + hasFileShape = isBwrapFileMountPoint(normalizedPath) } if ( isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath) ) { - const kind = isFileMountPoint - ? 'file' - : pathExists && - (liveMountPointsCache ??= liveMountPoints()).has(normalizedPath) - ? kindOfMountPoint(normalizedPath) - : undefined + // The manifests are read once per wrap, and a reading that names the + // path is believed whenever it was taken. One that does not name a + // file of that shape is believed only if it was taken after the file + // was seen: a sandbox publishes its manifest before bubblewrap makes + // the file, so such a reading names every mount point that is there, + // and an older one misses that of a sandbox that has started since, + // which would be bound onto itself as the caller's own file, named by + // this wrap nowhere, and gone from under it at that sandbox's + // clean-up. A file of that shape which such a reading does not name + // is the caller's, or has gone since it was seen, its manifest with + // it: whether it is still there is asked again. + let named = pathExists ? namedMountPointsCache : undefined + if ( + pathExists && + (named === undefined || + (hasFileShape && !named.named.has(normalizedPath))) + ) { + named = namedMountPointsCache = namedMountPoints() + if (hasFileShape && !named.named.has(normalizedPath)) { + pathExists = fs.existsSync(normalizedPath) + } + } + const kind = !named?.named.has(normalizedPath) + ? undefined + : hasFileShape + ? 'file' + : named.live.has(normalizedPath) + ? kindOfMountPoint(normalizedPath) + : kindOfMountPoint(normalizedPath) === 'directory' + ? 'directory' + : undefined if (kind === 'file') { if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) @@ -2757,11 +2790,29 @@ async function generateFilesystemArgs( // each: a symlink spelling would pin the chain of the link, not of where // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. + // + // The directories of this library's own that are bound read-only at the end + // of this function are seeds as well: the manifest directories, the store of + // fake files and the empty directory the placeholders bind from. What is + // found at each of those names is believed on the host, by the next + // clean-up or the next wrap, and the read-only bind keeps a command out of + // the directory only for as long as the name leads to it: with the temp dir + // strictly inside a write root, a command could rename the temp dir aside + // and make the name again, with a manifest directory of its own in it. + const manifestDirs = + writeConfig !== undefined + ? mountPointManifestDirectories( + mountPoints.length > 0 || emptySource !== undefined, + ) + : [] const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), + ...[...manifestDirs, maskedFileStoreDir, emptySource].flatMap(dir => + dir === undefined ? [] : [canonicalForm(dir)], + ), ], { rootIsWriteRoot: isAllowedWriteRoot('/'), @@ -2916,13 +2967,20 @@ async function generateFilesystemArgs( // under the system temp dir wherever there is no $XDG_RUNTIME_DIR, which a // caller's allowWrite commonly covers, and what is in it then belongs to // OTHER sandboxes. So every wrap that restricts writes binds every one of - // them read-only (see mountPointManifestDirectories), and a wrap with mount + // them read-only (see mountPointManifestDirectories), with what lies above + // them pinned (they are seeds of the ancestor pins), and a wrap with mount // points of its own adds the lock. Emitted after every allow and deny bind, // for the same reason as the two stores below (they are all directories of // their own, so their order among themselves does not matter). It is also // what lets bwrap open the manifest: it does that inside the sandbox, after // the mounts, so a profile with a tmpfs over the temp dir would otherwise // leave nothing there to open. + // + // Each is bound where it really is, like a pin, and not at the name the + // environment gave for it: bubblewrap makes a bind's destination by name + // inside the new root before it mounts, and a link on the way whose target + // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir + // reached through such a link no command that restricts writes started. const manifest = publishMountPointManifest( mountPoints, emptySource === undefined ? [] : [emptySource], @@ -2931,12 +2989,13 @@ async function generateFilesystemArgs( if (manifest !== undefined) { // This wrap needs the directory there: bubblewrap opens the manifest in // it, after the mounts. + const dir = canonicalForm(manifest.dir) args.push( '--ro-bind', - manifest.dir, - manifest.dir, + dir, + dir, '--lock-file', - manifest.file, + path.join(dir, path.basename(manifest.file)), ) registerExitCleanupHandler() } @@ -2944,9 +3003,10 @@ async function generateFilesystemArgs( // The others need only be kept out of the command's reach, so one that has // gone by the time the command starts - a temp dir whose parent was // replaced - is not an error. - for (const manifestDir of mountPointManifestDirectories()) { + for (const manifestDir of manifestDirs) { if (manifestDir !== manifest?.dir) { - args.push('--ro-bind-try', manifestDir, manifestDir) + const dir = canonicalForm(manifestDir) + args.push('--ro-bind-try', dir, dir) } } } @@ -2956,9 +3016,12 @@ async function generateFilesystemArgs( // symlink at a fake path and a later host-side write() would follow it, // or replace a fake's content so the bind exposes attacker bytes. Emit // last so it overlays any earlier --bind that covers the store dir - // (e.g. allowWrite: ['/tmp'] when the store is under os.tmpdir()). + // (e.g. allowWrite: ['/tmp'] when the store is under os.tmpdir()). Bound + // where it really is, like the manifest directories above and for the same + // reason. if (maskedFileStoreDir !== undefined) { - args.push('--ro-bind', maskedFileStoreDir, maskedFileStoreDir) + const dir = canonicalForm(maskedFileStoreDir) + args.push('--ro-bind', dir, dir) } // INVARIANT, for the same reason: the empty directory the placeholders above @@ -2966,11 +3029,13 @@ async function generateFilesystemArgs( // the system temp dir, so a caller's allowWrite or a host $TMPDIR under a // default write path makes it writable — and a placeholder binds it onto a // DENIED destination, so a write to the source lands at the deny. Emit last, - // like the store, to overlay any earlier --bind that covers it. A same-uid - // process on the host can still swap the source between wrap and run, which - // is outside what this library defends against. + // like the store, to overlay any earlier --bind that covers it, and where it + // really is, like the store. A same-uid process on the host can still swap + // the source between wrap and run, which is outside what this library + // defends against. if (emptySource !== undefined) { - args.push('--ro-bind', emptySource, emptySource) + const dir = canonicalForm(emptySource) + args.push('--ro-bind', dir, dir) } return { args, manifest } From 8136d90a81ac5f6a8e4434ec9f03fa03a085bd2d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 12:42:16 +0000 Subject: [PATCH 074/141] test: mount points known by their manifest, pinned library directories, a linked temp dir Real bubblewrap runs for each: a directory mount point stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up; the temp dir cannot be moved aside from inside a sandbox to plant a manifest directory; the user's own empty read-only file is unwritable during the command and still there after it; a sandbox starts with the temp dir behind a link, absolute or relative, inside or outside the write root, with a directory mount point and a store of fake files as well. The cases that planted an empty read-only file without a manifest and expected it collected now write the killed process's manifest beside it, which is what such a process leaves. --- test/public-exports.test.ts | 1 + test/sandbox/mount-point-liveness.test.ts | 843 ++++++++++++++++++++- test/sandbox/mount-point-manifests.test.ts | 384 ++++++++++ test/sandbox/stale-mount-point.test.ts | 299 +++++++- 4 files changed, 1510 insertions(+), 17 deletions(-) diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts index 38b341b27..f743cda24 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -51,6 +51,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'installWindowsSandbox', 'installWindowsSandboxAsync', 'isUncPath', + 'liveMountPoints', 'parseWindowsBinShell', 'parseWindowsSandboxError', 'resolveSrtWin', diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index e0c60058a..878f71936 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -9,17 +9,21 @@ import { mkdtempSync, readFileSync, readdirSync, + readlinkSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { liveMountPoints } from '../../src/index.js' import { cleanupBwrapMountPoints, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -41,7 +45,7 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo describe.if(isLinux)('A mount point a running sandbox relies on', () => { // These tests list the manifests, attack them from inside a sandbox and // assert on what is left: in a directory of their own. - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), @@ -137,6 +141,44 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return file! } + /** + * The manifest of a wrap that named `paths`, written a minute ago by + * `writer`: a process that is gone, which makes it a finished one, unless + * told otherwise. + */ + function manifestNaming(paths: string[], writer?: number): string { + const dir = runtime.manifestDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + let pid = 4194000 + let start = '1' + if (writer === undefined) { + while (existsSync(`/proc/${pid}`)) pid-- + } else { + const stat = readFileSync(`/proc/${writer}/stat`, 'utf8') + pid = writer + start = stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19]! + } + const file = join(dir, `${pid}-0123456789abcdef.json`) + writeFileSync( + file, + JSON.stringify({ + version: 1, + pid, + start, + ns: readlinkSync('/proc/self/ns/pid'), + created: Date.now() - 60_000, + paths, + sources: [], + }), + { mode: 0o600 }, + ) + return file + } + it.if(BWRAP_CAN_NAMESPACE)( 'survives another process cleaning up in the same directory, and the deny holds', async () => { @@ -221,6 +263,104 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + it.if(BWRAP_CAN_NAMESPACE)( + 'is in the set another process is given of the live mount points, for as long as the sandbox runs', + async () => { + // For a caller that removes paths itself after a command: what it is + // about to remove may be what another process's sandbox is bound over. + expect([...liveMountPoints()]).toEqual([]) + const holder = spawn( + process.execPath, + [writeScript('holder.ts', wrapperScript(heldCommand(), OUT))], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + await waitFor(SIGNAL) + // Past the grace, so that only the sandbox's lock and its writer + // speak for the manifest. + await sleep(600) + expect([...liveMountPoints()]).toEqual([LOCK]) + } finally { + writeFileSync(GO, '') + } + await holderExited + await sleep(600) + expect([...liveMountPoints()]).toEqual([]) + }, + 90000, + ) + + it('is given in that set as the wrap recorded it: where the directories above it really are', async () => { + // A caller that holds its own paths against the set has to spell them the + // same way. + const through = join(AREA, 'through') + symlinkSync(join(AREA, 'repo', '.git'), through) + const command = await wrap('true', [join(through, 'config.lock')]) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + // What bubblewrap makes for it, once the command runs. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + + expect([...liveMountPoints()]).toEqual([LOCK]) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while a second sandbox that denies it runs, after the one it was made for has ended and been cleaned up after', + async () => { + // The second wrap finds the file there. It is a mount point because a + // manifest names it, so the wrap covers it and names it too, and its + // own manifest keeps it once the first sandbox's is gone. + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const holder = spawn( + process.execPath, + [ + writeScript( + 'holder.ts', + wrapperScript(heldCommand(), OUT, ['cleanupBwrapMountPoints()']), + ), + ], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + let said = '' + try { + await waitFor(SIGNAL) + const command = await wrap( + `echo up > ${upSecond}; while [ ! -e ${goSecond} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?`, + ) + expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + const second = spawn(command, { shell: true }) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => second.on('exit', resolve)) + await waitFor(upSecond) + + writeFileSync(GO, '') + await holderExited // and its own clean-up has run + await sleep(700) + cleanupBwrapMountPoints() + expect(lstatSync(LOCK).size).toBe(0) + + writeFileSync(goSecond, '') + await secondExited + expect(said).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + } finally { + writeFileSync(GO, '') + writeFileSync(goSecond, '') + await holderExited + } + }, + 90000, + ) + it.if(BWRAP_CAN_NAMESPACE)( 'is collected, manifest and all, when the process that made it never cleans up', async () => { @@ -620,15 +760,16 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { // Absent when the wrap asked what shape it has, there when it asked // whether anything is: another sandbox started on the same path in - // between. Bound onto itself as the caller's own file it was named by - // this wrap nowhere, and went from under its sandbox at the other's - // clean-up. + // between, its manifest published and its mount point made. Bound onto + // itself as the caller's own file it was named by this wrap nowhere, and + // went from under its sandbox at the other's clean-up. const exists = fs.existsSync let made = false const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { if (file === LOCK && !made) { made = true expect(exists(LOCK)).toBe(false) + manifestNaming([LOCK], process.pid) writeFileSync(LOCK, '') chmodSync(LOCK, 0o444) } @@ -763,10 +904,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it('lets go of the mount points of a wrap that produced no command', async () => { - // Left where a process killed before its cleanup would leave it, so that - // both processes below name it. + // Left where a process killed before its cleanup would leave it, with the + // manifest it would leave, so that both processes below name it. writeFileSync(LOCK, '') chmodSync(LOCK, 0o444) + manifestNaming([LOCK]) const wrapper = spawn( process.execPath, @@ -1230,7 +1372,7 @@ describe.if(isLinux)( describe.if(isLinux)( 'A directory mount point a running sandbox relies on', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), @@ -1422,6 +1564,693 @@ describe.if(isLinux)( }, 90000, ) + + // ---- left by a process that was killed ------------------------------ + // + // Its manifest is finished, not live: the writer is gone, nothing holds + // the lock, the grace is over. The next clean-up in any process removes + // the directory on that manifest's word, so a wrap that denies the + // directory in the meantime has to name it for itself. + + /** What a process killed a minute ago left in the manifest directory. */ + function manifestOfAKilledProcess(paths: string[]): string { + const dir = runtime.manifestDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + let pid = 4194000 + while (existsSync(`/proc/${pid}`)) pid-- + const file = join(dir, `${pid}-0123456789abcdef.json`) + writeFileSync( + file, + JSON.stringify({ + version: 1, + pid, + start: '1', + ns: readlinkSync('/proc/self/ns/pid'), + created: Date.now() - 60_000, + paths, + sources: [], + }), + { mode: 0o600 }, + ) + return file + } + + /** The mount points the wrap named in its manifest, none without one. */ + function namedBy(command: string): string[] { + const file = /--lock-file (\S+)/.exec(command)?.[1] + return file === undefined + ? [] + : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths + } + + it('is named by a wrap that denies it, and stays until that wrap is cleaned up after', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + + // The list that does this: the directory itself, beside something + // beneath it, which needs no mount point of its own under a directory + // that is read-only. So the directory stays empty on the host. + const command = await wrap('true', [DIRECTORY, LEAF]) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(command).not.toContain(LEAF) + expect(namedBy(command)).toEqual([DIRECTORY]) + + // A second command of this process, and the clean-up after it: the + // first is still outstanding, so nothing of this process's is released, + // and what the killed process left is collected. + await wrap('true', []) + cleanupBwrapMountPoints() + expect(existsSync(left)).toBe(false) + expect(existsSync(DIRECTORY)).toBe(true) + + // And it goes like any other mount point, once nothing relies on it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }) + + it('is never removed once something has been put into it', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + const command = await wrap('true', [DIRECTORY]) + expect(namedBy(command)).toEqual([DIRECTORY]) + + writeFileSync(join(DIRECTORY, 'settings.json'), '{}') + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + + expect(readFileSync(join(DIRECTORY, 'settings.json'), 'utf8')).toBe('{}') + // Nothing names it any more: it is the user's directory from here on. + expect(existsSync(left)).toBe(false) + expect(existsSync(/--lock-file (\S+)/.exec(command)![1]!)).toBe(false) + expect(namedBy(await wrap('true', [DIRECTORY]))).toEqual([]) + }) + + it('is left alone, by the wrap and by the clean-up, where no manifest names it', async () => { + // An empty directory looks like anyone's: this one is the user's. + mkdirSync(DIRECTORY) + manifestOfAKilledProcess([join(AREA, 'proj', '.vscode')]) + + const command = await wrap('true', [DIRECTORY, LEAF]) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(namedBy(command)).toEqual([]) + + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(true) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up', + async () => { + // Bound onto itself and not named, it went at the next clean-up in + // any process: the bind came off inside the running sandbox, the + // denied directory could be made again there, and what was written + // under it landed on the host. + const script = (name: string, lines: string[]): string => { + const file = join(BASE, name) + writeFileSync(file, lines.join('\n')) + return file + } + const killed = spawnSync( + process.execPath, + [ + script('killed.ts', [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LEAF)}] },`, + `})`, + `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `process.kill(process.pid, 'SIGKILL')`, + ]), + ], + { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + ) + expect(killed.signal).toBe('SIGKILL') + // Its sandbox ran, and left what bubblewrap made for it. + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + expect(readdirSync(DIRECTORY)).toEqual([]) + expect( + readdirSync(runtime.manifestDir()).filter(name => + name.endsWith('.json'), + ), + ).toHaveLength(1) + // Past its grace: nothing vouches for that manifest any more. + await new Promise(resolve => setTimeout(resolve, 600)) + + const up = join(AREA, 'up-second') + const go = join(AREA, 'go-second') + let said = '' + const second = spawn( + await wrap( + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done; mkdir -p ${LEAF}; echo pwned > ${LEAF}/x.md; echo rc=$?`, + [DIRECTORY], + ), + { shell: true }, + ) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => second.on('exit', resolve)) + try { + await waitFor(up) + const collector = spawnSync( + process.execPath, + [ + script('collector.ts', [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ]), + ], + { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + ) + expect(collector.status).toBe(0) + expect(existsSync(DIRECTORY)).toBe(true) + } finally { + writeFileSync(go, '') + await secondExited + } + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(join(LEAF, 'x.md'))).toBe(false) + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }, + 90000, + ) + }, +) + +/** + * The manifest directories, the store of fake files and the empty directory + * the placeholders bind from are each bound read-only in a sandbox, and what + * is found at their names is believed on the host. The bind keeps a command + * out of the directory; it does not keep the name leading to it. Where the + * temp dir (or the runtime directory) lies strictly inside a write root, the + * directories between the two are the command's to rename, bind and all, and + * to make again: so they are pinned, like those above a deny path. + */ +describe.if(isLinux)( + 'The directories above the ones this library keeps on the host', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + const UID = isLinux ? process.getuid!() : 0 + let BASE: string + let HOME: string // the allowed write root + let TMP: string // the temp dir, strictly inside it + let GUARD: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pins-'))) + HOME = join(BASE, 'home') + TMP = join(HOME, 'tmp') + mkdirSync(TMP, { recursive: true }) + GUARD = join(HOME, 'guard.lock') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** + * Runs `lines` in a process of its own whose temp dir is TMP. An `env` + * entry that is undefined is one that process does not have. + */ + function inAChild( + name: string, + lines: string[], + env: Record = {}, + ): { status: number | null; stdout: string } { + const file = join(BASE, name) + writeFileSync(file, lines.join('\n')) + const merged: Record = { + ...process.env, + TMPDIR: TMP, + ...env, + } + for (const [key, value] of Object.entries(merged)) { + if (value === undefined) delete merged[key] + } + const r = spawnSync(process.execPath, [file], { + env: merged as Record, + encoding: 'utf8', + timeout: 60000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + return { status: r.status, stdout: r.stdout } + } + + /** What a process with that environment wraps `true` as. */ + function wrapped( + options: Record, + env: Record = {}, + ): string { + const child = inAChild( + 'wrap.ts', + [ + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `console.log(await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` ...${JSON.stringify(options)},`, + `}))`, + ], + env, + ) + expect(child.status).toBe(0) + return child.stdout + } + + /** Whether `dir` is pinned, beneath the allow bind of the write root. */ + function expectPinned(command: string, dir: string): void { + const pin = indexOfMount(command, '--ro-bind', dir, dir) + expect(pin).toBeGreaterThan(-1) + expect(pin).toBeLessThan(indexOfMount(command, '--bind', HOME, HOME)) + } + + /** Puts the shared name under TMP out of use: a link is refused there. */ + function takeTheSharedName(): void { + mkdirSync(join(BASE, 'somebody-elses')) + symlinkSync( + join(BASE, 'somebody-elses'), + join(TMP, `srt-mount-points-${UID}`), + ) + } + + it('are pinned above the manifest directory a process with no runtime directory keeps', () => { + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(command).toContain(`--ro-bind-try ${TMP}/srt-mount-points-${UID}`) + expectPinned(command, TMP) + // The write root is a mount point already, and nothing above it is the + // command's to rename. + expect(countMounts(command, '--ro-bind', HOME, HOME)).toBe(0) + expect(countMounts(command, '--ro-bind', BASE, BASE)).toBe(0) + }) + + it('are pinned above the manifest directory under the runtime directory', () => { + const RUN = join(HOME, 'run', 'user') + mkdirSync(RUN, { recursive: true, mode: 0o700 }) + chmodSync(RUN, 0o700) + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR: RUN }, + ) + expect(command).toContain(`--lock-file ${RUN}/srt-mount-points/`) + expectPinned(command, RUN) + expectPinned(command, join(HOME, 'run')) + }) + + it('are pinned above the directory of its own a process settles on as it records', () => { + // Neither shared name will do, so the wrap makes a directory only this + // process knows, and makes it as it publishes: after the pins. + takeTheSharedName() + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(command).toMatch( + new RegExp(`--lock-file ${TMP}/srt-mount-points-[A-Za-z0-9]{6}/`), + ) + expectPinned(command, TMP) + }) + + it('are pinned above the empty directory the placeholders bind from', () => { + // The manifests are out of the way, under a runtime directory outside + // the write root: the temp dir is pinned for the source alone. + takeTheSharedName() + const command = wrapped({ + writeConfig: { + allowOnly: [HOME], + denyWithinAllow: [join(HOME, 'absent', 'leaf')], + }, + }) + expect(command).toMatch( + new RegExp(`--ro-bind ${TMP}/claude-empty-\\S+ ${HOME}/absent `), + ) + expectPinned(command, TMP) + }) + + it('are pinned above the store of fake files, under a wrap that restricts no write', () => { + const store = join(TMP, 'srt-credmask-store') + mkdirSync(store, { mode: 0o700 }) + writeFileSync(join(store, '0.fake'), 'sentinel') + const secret = join(BASE, 'secret') + writeFileSync(secret, 'real') + const command = wrapped({ + maskedFileBinds: [ + { realPath: secret, fakePath: join(store, '0.fake') }, + ], + maskedFileStoreDir: store, + }) + expect(countMounts(command, '--ro-bind', store, store)).toBe(1) + // Everything is writable, so every directory above the store is + // pinned, on top of the root's own bind. + for (const dir of [TMP, HOME]) { + const pin = indexOfMount(command, '--ro-bind', dir, dir) + expect(pin).toBeGreaterThan(indexOfMount(command, '--bind', '/', '/')) + expect(pin).toBeLessThan( + indexOfMount(command, '--ro-bind', store, store), + ) + } + }) + + it('are pinned where the temp dir really is, when its name leads there through a link', () => { + // A pin is a mount, and a mount lands where its path resolves. On the + // name it would be a mount on a link, which bubblewrap refuses from + // 0.12 on. + const real = join(HOME, 'real-tmp') + const through = join(HOME, 'tmp-link') + mkdirSync(real) + symlinkSync('real-tmp', through) + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined, TMPDIR: through }, + ) + expectPinned(command, real) + expect(countMounts(command, '--ro-bind', through, through)).toBe(0) + }) + + it('are not pinned where the temp dir is itself the write root, or outside every one', () => { + const inside = wrapped( + { writeConfig: { allowOnly: [TMP], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(countMounts(inside, '--bind', TMP, TMP)).toBe(1) + expect(countMounts(inside, '--ro-bind', TMP, TMP)).toBe(0) + + const AREA = join(BASE, 'area') + mkdirSync(AREA) + const outside = wrapped( + { writeConfig: { allowOnly: [AREA], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(countMounts(outside, '--ro-bind', TMP, TMP)).toBe(0) + expect(countMounts(outside, '--ro-bind', HOME, HOME)).toBe(0) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', + async () => { + // What the command is after: the next clean-up on the host reads the + // directory at the name, finds no live manifest there that claims + // GUARD, and removes it from under the sandbox that relies on it, on + // the word of a manifest the command wrote. + const theirs = join(TMP, `srt-mount-points-${UID}`) + const up = join(HOME, 'up') + const go = join(HOME, 'go') + const out = join(BASE, 'out.txt') + const forged = JSON.stringify({ + version: 1, + pid: 4194000, + start: '0', + created: 0, + paths: [GUARD], + sources: [], + }) + const command = [ + `mv ${TMP} ${TMP}.aside; echo mv=$?`, + `mkdir -p -m 700 ${theirs}`, + `echo '${forged}' > ${theirs}/forged.json; echo plant=$?`, + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`, + `echo pwned > ${GUARD}; echo rc=$?`, + ].join('; ') + const env: Record = { ...process.env, TMPDIR: TMP } + delete env.XDG_RUNTIME_DIR + const script = join(BASE, 'holder.ts') + writeFileSync( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(HOME)}], denyWithinAllow: [${JSON.stringify(GUARD)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(${JSON.stringify(out)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const holder = spawn(process.execPath, [script], { + env, + stdio: 'ignore', + cwd: import.meta.dir, + }) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + const deadline = Date.now() + 30000 + while (!existsSync(up)) { + if (Date.now() > deadline) throw new Error('the sandbox never ran') + await new Promise(resolve => setTimeout(resolve, 25)) + } + expect(lstatSync(GUARD).size).toBe(0) + + const collector = inAChild( + 'collector.ts', + [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ], + { XDG_RUNTIME_DIR: undefined }, + ) + expect(collector.status).toBe(0) + expect(existsSync(GUARD)).toBe(true) + } finally { + writeFileSync(go, '') + await holderExited + } + + const said = readFileSync(out, 'utf8') + expect(said).toMatch(/mv=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(`${TMP}.aside`)).toBe(false) + expect(existsSync(join(theirs, 'forged.json'))).toBe(false) + expect(existsSync(GUARD) ? readFileSync(GUARD, 'utf8') : '').toBe('') + }, + 90000, + ) + }, +) + +/** + * A bind is made where its destination really is. bubblewrap makes the + * destination by name inside the new root before it mounts on it, and a link + * on the way whose target is absolute leads out of that root, to nothing: + * "Can't mkdir". The manifest directories were bound at the name the + * environment gave for the temp dir, so with a temp dir reached through such + * a link (a `/tmp` that is a link to `/var/tmp`, a `TMPDIR` that is one) no + * command that restricts writes started at all. + */ +describe.if(isLinux)( + 'The manifest directory under a temp dir whose name is a link', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ) + const UID = isLinux ? process.getuid!() : 0 + let BASE: string + let HOME: string // the allowed write root + let GUARD: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-linked-'))) + HOME = join(BASE, 'home') + mkdirSync(HOME) + GUARD = join(HOME, 'guard.lock') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** + * What a process whose temp dir is `through`, and which has no runtime + * directory, wraps `command` as with that deny list, and what the command + * said when run. + */ + function wrappedAndRun( + through: string, + command: string, + deny: string[], + store?: string, + ): { wrapped: string; status: number | null; said: string } { + const file = join(BASE, 'wrap-and-run.ts') + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(HOME)}], denyWithinAllow: ${JSON.stringify(deny)} },`, + ...(store === undefined + ? [] + : [` maskedFileStoreDir: ${JSON.stringify(store)},`]), + `})`, + `const r = ${BWRAP_CAN_NAMESPACE ? `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })` : `{ status: null, stdout: '', stderr: '' }`}`, + `console.log(JSON.stringify({ wrapped, status: r.status, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ].join('\n'), + ) + const env: Record = { ...process.env, TMPDIR: through } + delete env.XDG_RUNTIME_DIR + const child = spawnSync(process.execPath, [file], { + env, + encoding: 'utf8', + timeout: 90000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + expect(child.status).toBe(0) + return JSON.parse(child.stdout) as { + wrapped: string + status: number | null + said: string + } + } + + const variants = (['inside', 'outside'] as const).flatMap(where => + (['absolute', 'relative'] as const).map(target => ({ where, target })), + ) + for (const { where, target } of variants) { + /** The temp dir, and the name that leads to it. */ + const linked = (): { real: string; through: string } => { + const parent = where === 'inside' ? HOME : BASE + const real = join(parent, 'real-tmp') + const through = join(parent, 'tmp') + mkdirSync(real) + symlinkSync(target === 'absolute' ? real : 'real-tmp', through) + return { real, through } + } + + it(`is bound where it really is, with the link ${where} the write root and its target ${target}`, () => { + const { real, through } = linked() + const kept = join(real, `srt-mount-points-${UID}`) + const named = join(through, `srt-mount-points-${UID}`) + + const recording = wrappedAndRun(through, 'true', [GUARD]).wrapped + expect(countMounts(recording, '--ro-bind', kept, kept)).toBe(1) + expect(recording).toContain(`--lock-file ${kept}/`) + expect(recording).not.toContain(named) + + // A wrap with nothing to record keeps the command out of it as well. + const other = wrappedAndRun(through, 'true', []).wrapped + expect(other).toContain(`--ro-bind-try ${kept} ${kept}`) + expect(other).not.toContain(named) + }) + + it(`binds the empty directory and the store of fake files where they really are, with the link ${where} the write root and its target ${target}`, () => { + const { real, through } = linked() + const store = join(through, 'fake-files') + mkdirSync(store) + + // A deny beneath a directory that is not there makes that directory + // the mount point, bound from the empty directory under the temp dir. + const wrapped = wrappedAndRun( + through, + 'true', + [join(HOME, 'absent', 'inner.lock')], + store, + ).wrapped + const empty = /--ro-bind (\S+\/claude-empty-\S+) \1(?= |$)/.exec( + wrapped, + ) + expect(empty?.[1]).toStartWith(`${real}/`) + expect(countMounts(wrapped, '--ro-bind', empty![1]!, empty![1]!)).toBe( + 1, + ) + const kept = join(real, 'fake-files') + expect(countMounts(wrapped, '--ro-bind', kept, kept)).toBe(1) + // Neither is a destination under the name that leads there. + expect(wrapped).not.toMatch( + new RegExp(`--ro-bind \\S+ ${through}/(claude-empty-|fake-files)`), + ) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + `does not keep a sandbox from starting, with the link ${where} the write root and its target ${target}`, + () => { + const { real, through } = linked() + const kept = join(real, `srt-mount-points-${UID}`) + const attempts = [ + `echo started`, + `echo pwned > ${GUARD}; echo rc=$?`, + `touch ${kept}/planted.json; echo real=$?`, + `touch ${through}/srt-mount-points-${UID}/planted.json; echo through=$?`, + ].join('; ') + + const recording = wrappedAndRun(through, attempts, [GUARD]) + expect(recording.said).not.toContain('bwrap:') + expect(recording.status).toBe(0) + expect(recording.said).toContain('started') + // Its deny holds, under the lock on its manifest, and the manifest + // directory is out of its reach by either name. + expect(recording.said).toMatch(/rc=[1-9]/) + expect(recording.said).toMatch(/real=[1-9]/) + expect(recording.said).toMatch(/through=[1-9]/) + expect(existsSync(GUARD) ? readFileSync(GUARD, 'utf8') : '').toBe('') + expect(existsSync(join(kept, 'planted.json'))).toBe(false) + + // And one with no mount point of its own, which binds the directory + // all the same. + const other = wrappedAndRun(through, attempts, []) + expect(other.said).not.toContain('bwrap:') + expect(other.status).toBe(0) + expect(other.said).toContain('started') + expect(other.said).toMatch(/real=[1-9]/) + expect(other.said).toMatch(/through=[1-9]/) + expect(existsSync(join(kept, 'planted.json'))).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + `does not keep a sandbox that needs a directory mount point or a store of fake files from starting, with the link ${where} the write root and its target ${target}`, + () => { + const { real, through } = linked() + const store = join(through, 'fake-files') + mkdirSync(store) + const inner = join(HOME, 'absent', 'inner.lock') + const attempts = [ + `echo started`, + `mkdir -p ${dirname(inner)} 2>/dev/null; echo pwned > ${inner}; echo rc=$?`, + `touch ${join(real, 'fake-files')}/planted; echo store=$?`, + `touch ${store}/planted; echo through=$?`, + ].join('; ') + + const ran = wrappedAndRun(through, attempts, [inner], store) + expect(ran.said).not.toContain('bwrap:') + expect(ran.status).toBe(0) + expect(ran.said).toContain('started') + expect(ran.said).toMatch(/rc=[1-9]/) + expect(ran.said).toMatch(/store=[1-9]/) + expect(ran.said).toMatch(/through=[1-9]/) + expect(existsSync(inner)).toBe(false) + expect(existsSync(join(real, 'fake-files', 'planted'))).toBe(false) + }, + 90000, + ) + } }, ) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 7bbd66250..58fa38c8a 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -24,6 +24,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { collectMountPoints, + liveMountPoints, + namedMountPoints, publishMountPointManifest, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' @@ -940,6 +942,388 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) + // ---- what is read as a manifest ---------------------------------------- + // + // Whatever ends in `.json` in the directory used to be opened as it stood: + // a link was followed to wherever it led, and a FIFO held the thread for + // good, in the wrap, in the clean-up and in the exit handler. Only a + // regular file of the user's, of a size a manifest can have, is one. + + /** Thirty seconds old: nothing can be starting under it any more. */ + function aged(file: string): string { + const halfAMinuteAgo = new Date(Date.now() - 30_000) + utimesSync(file, halfAMinuteAgo, halfAMinuteAgo) + return file + } + + /** What a manifest of a dead process naming `paths` holds. */ + function finishedManifestText(paths: string[]): string { + const file = manifestOfADeadProcess(paths) + const text = readFileSync(file, 'utf8') + rmSync(file) + return text + } + + const notAManifest: [string, (name: string, paths: string[]) => void][] = [ + [ + 'a link to a manifest kept somewhere else', + (name, paths) => { + const elsewhere = join(BASE, 'elsewhere.json') + writeFileSync(elsewhere, finishedManifestText(paths), { mode: 0o600 }) + aged(elsewhere) + symlinkSync(elsewhere, name) + }, + ], + [ + 'a manifest of more than a mebibyte', + (name, paths) => { + writeFileSync( + name, + finishedManifestText(paths) + ' '.repeat(1024 * 1024), + { mode: 0o600 }, + ) + aged(name) + }, + ], + [ + 'a directory', + name => { + mkdirSync(name) + aged(name) + }, + ], + ] + for (const [what, plant] of notAManifest) { + it(`passes over ${what}, and what it names`, () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + plant(join(DIR, '4242-0123456789abcdef.json'), [X]) + manifestOfADeadProcess([Y]) + + expect([...namedMountPoints().named]).toEqual([Y]) + // Nor does it stand in the way of the manifests beside it. + expect(collectMountPoints()).toEqual([Y]) + expect(existsSync(X)).toBe(true) + }) + } + + it("passes over a manifest that is somebody else's", () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const theirs = aged(manifestOfADeadProcess([X])) + const inode = statSync(theirs, { bigint: true }).ino + manifestOfADeadProcess([Y]) + // As another user's would look: nothing here can make one. + const fstat = fs.fstatSync + const spy = spyOn(fs, 'fstatSync').mockImplementation((( + fd: unknown, + options: unknown, + ) => { + const stat = (fstat as (f: unknown, o: unknown) => fs.BigIntStats)( + fd, + options, + ) + if (stat.ino !== inode) return stat + const other = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.BigIntStats + return Object.assign(other, stat, { uid: stat.uid + 1n }) + }) as never) + try { + expect([...namedMountPoints().named]).toEqual([Y]) + expect(collectMountPoints()).toEqual([Y]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + }) + + it('passes over a FIFO nobody writes to, and is not held up by it', () => { + const Y = join(BASE, 'second.lock') + leftover(Y) + manifestOfADeadProcess([Y]) + const fifo = join(DIR, '4242-0123456789abcdef.json') + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + aged(fifo) + // In a process of its own: opening a FIFO to read waits for a writer, on + // the one thread there is. + const looked = inAChild( + `console.log(JSON.stringify({ named: [...m.namedMountPoints().named], live: [...m.liveMountPoints()], collected: m.collectMountPoints() }))`, + ) + expect(looked.status).toBe(0) + expect(looked.ms).toBeLessThan(AT_ONCE_MS) + expect(JSON.parse(looked.stdout)).toEqual({ + named: [Y], + live: [], + collected: [Y], + }) + }, 15000) + + // ---- which mount points a running sandbox relies on -------------------- + // + // A caller that removes paths of its own accord after a command removes by + // path, and removing a mount point from under a running sandbox lifts the + // deny there. So it can ask first which paths to leave out, and what it is + // told to leave out is never more than an empty placeholder. + + /** The set, as a sorted list. */ + function live(): string[] { + return [...liveMountPoints()].sort() + } + + /** Has /proc/locks refuse to be read. */ + function withUnreadableProcLocks(): { restore(): void } { + const readFile = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks') { + throw Object.assign(new Error('EACCES: /proc/locks'), { + code: 'EACCES', + }) + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('holds a mount point while a live manifest names it, file or directory, and no other path', () => { + const directory = join(BASE, 'empty-directory') + const other = join(BASE, 'other.lock') + leftover(X) + mkdirSync(directory) + leftover(other) + // Of this process, and not released: live. + const manifest = publishMountPointManifest( + [X, directory, join(BASE, 'never-there')], + [], + )!.file + + expect(live()).toEqual([X, directory].sort()) + // Asking takes nothing away. + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + + expect(collectMountPoints().sort()).toEqual([X, directory].sort()) + expect(live()).toEqual([]) + }) + + it('no longer holds a path once something has been written to it, though a live manifest still names it', () => { + leftover(X) + publishMountPointManifest([X], []) + expect(live()).toEqual([X]) + + chmodSync(X, 0o644) + writeFileSync(X, 'x') + chmodSync(X, 0o444) + expect(live()).toEqual([]) + }) + + it('holds nothing a live manifest names that does not look like a mount point any more', () => { + const writable = join(BASE, 'writable') + writeFileSync(writable, '') + chmodSync(writable, 0o644) + const linked = join(BASE, 'linked') + leftover(linked) + linkSync(linked, join(BASE, 'linked-again')) + const pointer = join(BASE, 'pointer') + leftover(join(BASE, 'pointed-at')) + symlinkSync(join(BASE, 'pointed-at'), pointer) + const inUse = join(BASE, 'directory-in-use') + mkdirSync(inUse) + writeFileSync(join(inUse, 'file'), '') + const theirs = join(BASE, 'somebody-elses') + leftover(theirs) + const theirDirectory = join(BASE, 'somebody-elses-directory') + mkdirSync(theirDirectory) + leftover(X) + publishMountPointManifest( + [writable, linked, pointer, inUse, theirs, theirDirectory, X], + [], + ) + // As another user's would look: nothing here can make one. + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file === theirs || file === theirDirectory) { + const other = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(other, stat, { uid: stat.uid + 1 }) + } + return stat + }) as never) + try { + expect(live()).toEqual([X]) + } finally { + spy.mockRestore() + } + }) + + it('holds what a finished manifest names only when a sandbox holds the lock on it', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + expect(live()).toEqual([]) + + const onIt = withProcLocks( + `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n`, + ) + try { + expect(live()).toEqual([X]) + } finally { + onIt.restore() + } + }) + + it('holds what any manifest names where /proc/locks cannot be read, and of that only what looks like a mount point', () => { + const written = join(BASE, 'written-to') + writeFileSync(written, 'mine') + chmodSync(written, 0o444) + leftover(X) + leftover(join(BASE, 'other.lock')) + manifestOfADeadProcess([X, written]) + const unreadable = withUnreadableProcLocks() + try { + expect(live()).toEqual([X]) + } finally { + unreadable.restore() + } + }) + + it('holds what any manifest names while a manifest that cannot be read may have a sandbox under it', () => { + // What another version of this library wrote, say. What it names cannot + // be listed, and whether the sandboxes of the others are over cannot be + // told apart from it, for as long as the clean-up takes it to be live: + // while it is new, or a lock is on it. + const written = join(BASE, 'written-to') + writeFileSync(written, 'mine') + chmodSync(written, 0o444) + leftover(X) + manifestOfADeadProcess([X, written]) + const stranger = join(DIR, '1-garbage.json') + writeFileSync(stranger, '{ not json') + expect(live()).toEqual([X]) + + aged(stranger) + expect(live()).toEqual([]) + + const onIt = withProcLocks( + `1: POSIX ADVISORY READ 4242 ${deviceOf(stranger)}:${statSync(stranger).ino} 0 EOF\n`, + ) + try { + expect(live()).toEqual([X]) + } finally { + onIt.restore() + } + }) + + it('is one listing of the directory and one reading of /proc/locks, under no lock', () => { + leftover(X) + publishMountPointManifest([X], []) + manifestOfADeadProcess([join(BASE, 'other.lock')]) + // Somebody holds the directory's lock: this process, which is running. + const start = readFileSync('/proc/self/stat', 'utf8') + .split(') ') + .pop()! + .split(' ')[19] + const held = `${process.pid} ${start} ${OWN_NAMESPACE}\n` + writeFileSync(LOCK, held) + const before = readdirSync(DIR).sort() + + const readFile = spyOn(fs, 'readFileSync') + const readdir = spyOn(fs, 'readdirSync') + let listed: string[] + let looks: number + let listings: number + let tookMs: number + try { + const began = Date.now() + listed = live() + tookMs = Date.now() - began + looks = readFile.mock.calls.filter( + ([file]) => file === '/proc/locks', + ).length + listings = readdir.mock.calls.filter(([dir]) => dir === DIR).length + } finally { + readFile.mockRestore() + readdir.mockRestore() + } + expect(listed).toEqual([X]) + expect(looks).toBe(1) + expect(listings).toBe(1) + // Not waited for, not taken, not broken. + expect(tookMs).toBeLessThan(1000) + expect(readFileSync(LOCK, 'utf8')).toBe(held) + expect(readdirSync(DIR).sort()).toEqual(before) + }) + + it('makes no manifest directory where there is none, and holds nothing then', () => { + const run = join(BASE, 'run') + mkdirSync(run, { mode: 0o700 }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()]))`, + { env: { XDG_RUNTIME_DIR: run } }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual([]) + expect(readdirSync(run)).toEqual([]) + expect(readdirSync(TMP)).toEqual([]) + }) + + it('reads the directory a process without a runtime directory keeps, where that is the one there is', () => { + const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + mkdirSync(theirs, { mode: 0o700 }) + chmodSync(theirs, 0o700) + leftover(X) + const manifest = manifestOfADeadProcess([X], { + pid: process.pid, + start: readFileSync('/proc/self/stat', 'utf8') + .split(') ') + .pop()! + .split(' ')[19], + }) + writeFileSync( + join(theirs, '4242-0123456789abcdef.json'), + readFileSync(manifest, 'utf8'), + { mode: 0o600 }, + ) + rmSync(manifest) + const run = join(BASE, 'run') + mkdirSync(run, { mode: 0o700 }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()]))`, + { env: { XDG_RUNTIME_DIR: run } }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual([X]) + expect(readdirSync(run)).toEqual([]) + }) + + it('holds nothing, and makes nothing, where the platform is not Linux', () => { + leftover(X) + publishMountPointManifest([X], []) + const before = readdirSync(DIR).sort() + const elsewhere = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()]))`, + { + first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, + }, + ) + expect(elsewhere.status).toBe(0) + expect(JSON.parse(elsewhere.stdout)).toEqual([]) + expect(readdirSync(DIR).sort()).toEqual(before) + expect(readdirSync(TMP)).toEqual([]) + }) + // ---- all of it is bubblewrap's, which is Linux's ----------------------- it('does nothing at all where the platform is not Linux', () => { diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 7c81dc9ed..7f3d3e091 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'node:fs' import { chmodSync, existsSync, @@ -6,7 +7,9 @@ import { lstatSync, mkdirSync, mkdtempSync, + readFileSync, readdirSync, + readlinkSync, realpathSync, rmSync, rmdirSync, @@ -51,9 +54,14 @@ function run( * For a path whose existence is its meaning that is a lasting fault on the * host: a leftover `.git/config.lock` makes every `git config` write outside * the sandbox fail with "could not lock config file". + * + * The killed process leaves its manifest as well, and that is what says the + * file is a leftover. The file alone does not: an empty read-only file is + * also what somebody keeps at such a path on purpose, and one taken for a + * leftover by its shape was removed after the first command. */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area @@ -88,22 +96,90 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { }) } - /** What bwrap's ensure_file(dest, 0444) leaves on the host. */ - function plantLeftover(p: string): void { + /** + * What bwrap's ensure_file(dest, 0444) leaves on the host, and what + * somebody's own empty read-only file looks like. + */ + function plantEmptyReadOnlyFile(p: string): void { writeFileSync(p, '') chmodSync(p, 0o444) } - it('is covered with /dev/null like an absent path, and removed after the command', async () => { - plantLeftover(LOCK) + /** + * The manifest of a wrap that named `paths`, written by `pid`: a process + * that is gone, a minute ago, unless told otherwise. Where it is written. + */ + function manifestNaming( + paths: string[], + writer: { pid: number; start: string } = { pid: deadPid(), start: '1' }, + ): string { + const dir = runtime.manifestDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + const file = join( + dir, + `${writer.pid}-${Math.random().toString(16).slice(2).padEnd(16, '0')}.json`, + ) + writeFileSync( + file, + JSON.stringify({ + version: 1, + ...writer, + ns: readlinkSync('/proc/self/ns/pid'), + created: Date.now() - 60_000, + paths, + sources: [], + }), + { mode: 0o600 }, + ) + return file + } + + /** A pid nothing in this PID namespace has. */ + function deadPid(): number { + let pid = 4194000 + while (existsSync(`/proc/${pid}`)) pid-- + return pid + } + + /** This process, as a manifest names its writer: running, so it is live. */ + function thisProcess(): { pid: number; start: string } { + const stat = readFileSync('/proc/self/stat', 'utf8') + return { + pid: process.pid, + start: stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19]!, + } + } + + /** What a killed process leaves: the file, and the manifest that names it. */ + function plantLeftover(p: string): string { + plantEmptyReadOnlyFile(p) + return manifestNaming([p]) + } + + /** The mount points the wrap named in its manifest, none without one. */ + function namedBy(command: string): string[] { + const file = /--lock-file (\S+)/.exec(command)?.[1] + return file === undefined + ? [] + : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths + } + + it('is covered with /dev/null like an absent path, named, and removed after the command', async () => { + const left = plantLeftover(LOCK) const command = await wrap([LOCK]) expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) expect(countMounts(command, '--ro-bind', LOCK, LOCK)).toBe(0) + expect(namedBy(command)).toEqual([LOCK]) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) + expect(existsSync(left)).toBe(false) }) it('stays while a wrap of this process is outstanding, and goes with the last of them', async () => { @@ -127,7 +203,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { // Everything below only LOOKS like a leftover in one respect. Each is // somebody's file: bound onto itself as an existing deny path, and still // there afterwards. - it.each([ + const somebodysFiles: [string, (p: string) => void][] = [ [ 'an empty file with write bits (a lockfile, a file made empty on purpose)', (p: string) => { @@ -145,11 +221,12 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ 'an empty read-only file with a second link', (p: string) => { - plantLeftover(p) + plantEmptyReadOnlyFile(p) linkSync(p, `${p}.other-name`) }, ], - ])('leaves %s alone', async (_what, plant) => { + ] + it.each(somebodysFiles)('leaves %s alone', async (_what, plant) => { plant(LOCK) const command = await wrap([LOCK]) @@ -160,9 +237,211 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(existsSync(LOCK)).toBe(true) }) + // A manifest is only a claim about a path: what it names is a leftover + // only while it also looks like one. + it.each(somebodysFiles)( + 'leaves %s alone though the manifest of a killed process names it', + async (_what, plant) => { + plant(LOCK) + manifestNaming([LOCK]) + + const command = await wrap([LOCK]) + + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + expect(namedBy(command)).toEqual([]) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) + }, + ) + + // ---- an empty read-only file that no manifest names -------------------- + // + // It is the caller's own, whatever it looks like: an empty `.mcp.json` of + // mode 0444, a lockfile somebody keeps in place. It is denied like any + // existing path, by a read-only bind onto itself, and nothing ever removes + // it. + + it("is the caller's own where no manifest names it: bound onto itself, named by the wrap nowhere, and never removed", async () => { + plantEmptyReadOnlyFile(LOCK) + // A manifest that names something else changes nothing about it. + manifestNaming([join(GIT_DIR, 'other.lock')]) + + const command = await wrap([LOCK]) + + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + expect(namedBy(command)).toEqual([]) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + const kept = lstatSync(LOCK) + expect(kept.size).toBe(0) + expect(kept.mode & 0o777).toBe(0o444) + }) + + it("is the caller's own at a mandatory deny path as well, beside the mount points the wrap makes for the others", async () => { + const project = join(AREA, 'repo') + const own = join(project, '.mcp.json') + plantEmptyReadOnlyFile(own) + const cwd = process.cwd() + process.chdir(project) + let command: string + try { + command = await wrap([]) + } finally { + process.chdir(cwd) + } + + expect(lastMountAt(command, own)).toBe(`--ro-bind ${own} ${own}`) + // The mandatory denies that are absent get their mount points, and the + // manifest names those and not the file that was there. + const bashrc = join(project, '.bashrc') + expect(lastMountAt(command, bashrc)).toBe(`--ro-bind /dev/null ${bashrc}`) + expect(namedBy(command)).toContain(bashrc) + expect(namedBy(command)).not.toContain(own) + + cleanupBwrapMountPoints() + expect(lstatSync(own).mode & 0o777).toBe(0o444) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot be written or removed by the command, and is still there, as it was, after the session', + async () => { + const project = join(AREA, 'repo') + const mandatory = join(project, '.mcp.json') + plantEmptyReadOnlyFile(mandatory) + plantEmptyReadOnlyFile(LOCK) + const before = [mandatory, LOCK].map(p => lstatSync(p).ino) + + const attempts = [mandatory, LOCK] + .map( + p => + `echo x >> ${p}; echo "write $?"; rm -f ${p}; echo "rm $?"; ` + + `mv ${p} ${p}.aside; echo "mv $?"; chmod 644 ${p}; echo "chmod $?"`, + ) + .join('; ') + const cwd = process.cwd() + process.chdir(project) + let command: string + try { + command = await wrap([LOCK], `echo ${BOOTED}; ${attempts}`) + } finally { + process.chdir(cwd) + } + expect(namedBy(command)).not.toContain(mandatory) + expect(namedBy(command)).not.toContain(LOCK) + // The wrap has mount points of its own, for the mandatory denies that + // are absent: the clean-up below has something to remove, and removes + // that and nothing else. + expect(namedBy(command)).toContain(join(project, '.bashrc')) + + const session = run(command, project) + expect(session.stdout).toContain(BOOTED) + expect(session.stdout).not.toMatch(/(write|rm|mv|chmod) 0/) + expect(session.stdout.match(/(write|rm|mv|chmod) [1-9]/g)).toHaveLength(8) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(project, '.bashrc'))).toBe(false) + for (const [i, p] of [mandatory, LOCK].entries()) { + const kept = lstatSync(p) + expect(kept.ino).toBe(before[i]!) + expect(kept.size).toBe(0) + expect(kept.mode & 0o777).toBe(0o444) + expect(existsSync(`${p}.aside`)).toBe(false) + } + }, + 30000, + ) + + it('is asked after again, where the manifests were read before another sandbox named it and made it', async () => { + // A wrap reads the manifests once. What was read before the file was + // there cannot say whose it is: a sandbox that started in between + // published its manifest first and had bubblewrap make the file after. + // Taken for the caller's own it was bound onto itself and named by this + // wrap nowhere, and went from under its sandbox at the other's clean-up. + const earlier = join(GIT_DIR, 'earlier.lock') + plantLeftover(earlier) + const exists = fs.existsSync + let made = false + const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { + if (file === LOCK && !made) { + made = true + manifestNaming([LOCK], thisProcess()) + plantEmptyReadOnlyFile(LOCK) + } + return exists(file as string) + }) as never) + let command: string + try { + // `earlier` comes first, and has the manifests read for it. + command = await wrap([earlier, LOCK]) + } finally { + spy.mockRestore() + } + expect(made).toBe(true) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command).sort()).toEqual([LOCK, earlier].sort()) + }) + + it('is taken for absent where it has gone, with the manifest that named it, while the wrap was looking', async () => { + // Seen with the shape of a mount point, and named by no manifest a moment + // later: the sandbox it was made for has ended and been cleaned up after + // in between. Bound onto itself it was a source that is not there, and + // the command did not start. + const left = plantLeftover(LOCK) + const readdir = fs.readdirSync + let collected = false + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + if (dir === runtime.manifestDir() && !collected) { + collected = true + rmSync(LOCK, { force: true }) + rmSync(left) + } + return (readdir as (d: unknown, o: unknown) => unknown)(dir, options) + }) as never) + let command: string + try { + command = await wrap([LOCK]) + } finally { + spy.mockRestore() + } + expect(collected).toBe(true) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command)).toEqual([LOCK]) + }) + + // ---- an empty read-only file that a live manifest names ---------------- + + it('stays, covered and named, for as long as the manifest of a running sandbox names it, and goes once none does', async () => { + plantEmptyReadOnlyFile(LOCK) + // Of another wrap, whose writer is running: live. + const live = manifestNaming([LOCK], thisProcess()) + + const command = await wrap([LOCK]) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command)).toEqual([LOCK]) + + // This wrap is over; the other sandbox still relies on the file. + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(live)).toBe(true) + + // And that one is over too: what it leaves is a finished manifest. + rmSync(live) + manifestNaming([LOCK]) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }) + it('leaves an empty read-only file alone where no sandbox could have made it: outside every allowed write path', async () => { const outsideFile = join(OUTSIDE, 'config.lock') - plantLeftover(outsideFile) + plantEmptyReadOnlyFile(outsideFile) const command = await wrap([outsideFile]) From 61d3c5f66c6acc1548264bba89589c1d9c14306f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 12:42:16 +0000 Subject: [PATCH 075/141] README: what makes a path a mount point, liveMountPoints(), and what pinning cannot hold --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 569eb08e7..3dcc8d56c 100644 --- a/README.md +++ b/README.md @@ -736,15 +736,17 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone, unless the manifest of a sandbox that may still be running names it: then a wrap that denies that same directory names it too, so that it stays for as long as either sandbox runs. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. +- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that uses the same manifest directory. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of the manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. - **Known limits.** - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. + - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - _Between looking and removing._ The directory lock does not exclude: a holder can be held up past the minute after which its lock is broken, and a wrap that cannot have the lock within two seconds records its manifest without it. A cleanup pass therefore reads `/proc/locks` and lists the directory again immediately before it removes anything, and then again whenever its listing is a quarter of a millisecond old, and what it read from `/proc/locks` two milliseconds old or 64 removals ago. It removes a manifest last, after everything it names, and keeps one whose mount point it could not remove. A pass that is held up for longer than a sandbox takes to start (a few milliseconds) between such a look and a removal can still take away the mount point of a sandbox that started on the same path in that time. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for a leftover, covers it, and removes it after its own command, whether or not the first sandbox is still running. The ones that could not be recorded at all are never removed by the process that made them either. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -784,9 +786,9 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` once no running sandbox relies on them — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. -**Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. +**Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. -With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`). They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. +With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. From 632eb2071a03fa8bdd9e0e96a8a8304f401f0aaa Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 076/141] linux: the manifest directory is worked out from the user id, not the environment Two processes of one user keep each other's mount points only when each reads what the other writes. Where the manifests went followed XDG_RUNTIME_DIR and TMPDIR, which differ between a login shell and what a service, an IDE or cron starts: such processes did not see each other, the second took the first's mount point for the user's own file, and the first's clean-up removed it from under the second sandbox. The candidates are now /run/user//srt-mount-points, where /run/user/ is a real directory of the user's alone, then /tmp/srt-mount-points-, and only then the two names the environment gives. A process writes to the first it can use. It believes the manifests under the first two, and under the environment's names only when it can use neither of the first two, which is when it writes there, and in the directory it has settled on. What the environment names is kept out of a sandbox's reach only by processes with that environment, so a manifest found there is not taken on trust by a process that has somewhere else to look. Every candidate is still made when missing, bound read-only and pinned by every wrap that restricts writes, whether or not the process writes there: a command that may write /tmp would otherwise make the directory itself and be believed. A clean-up makes one pass per directory under that directory's lock, and a live manifest in any of them keeps a path. --- src/sandbox/bwrap-mount-manifests.ts | 496 +++++++++++++++++++-------- src/sandbox/linux-sandbox-utils.ts | 12 +- 2 files changed, 355 insertions(+), 153 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 12117aaf2..36f94d2ca 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -25,19 +25,32 @@ * The lock belongs to the sandbox's init process, not to the command: bwrap * opens the manifest with O_CLOEXEC, so the descriptor does not survive the * exec into the command, and a POSIX lock is only dropped by the process that - * took it. The directory is bound read-only into every sandbox that - * restricts writes at all, whether or not its own invocation names a manifest - * in it, and so is the other name a process of the same user would keep its - * manifests under (see {@link mountPointManifestDirectories}), so a sandboxed - * command can neither delete nor rewrite one, its own or another sandbox's; - * and the directories between each of them and the write root it lies in are - * pinned there, so that the command cannot rename one aside and make the - * directory again under the old name, with manifests of its own in it. + * took it. + * + * Two processes of one user keep each other's mount points only when each + * reads what the other writes, so where the manifests go is worked out from + * the user id and the file system before the environment, which differs + * between a login shell and what a service, an IDE or cron starts: see + * {@link manifestDirectoryCandidates}. A process writes to the first of those + * directories it can use, and believes what it finds in the ones that come + * from the user id, or, where it can use none of those, in the ones its + * environment names (see {@link believedManifestDirectories}). + * + * Every one of them is bound read-only into every sandbox that restricts + * writes at all, whether or not its own invocation names a manifest, and + * whichever of them the wrapping process writes to (see + * {@link mountPointManifestDirectories}), so a sandboxed command can neither + * delete nor rewrite a manifest, its own or another sandbox's; one that is + * not there is made first, so that the command cannot make it itself, with + * manifests of its own in it; and the directories between each of them and + * the write root it lies in are pinned there, so that the command cannot + * rename one aside and make the directory again under the old name. * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with the directory writable, a process that looks - * for its runtime or temp directory somewhere this one does not, and a - * runtime or temp directory whose name is a link inside a write root, or not - * there yet below one: neither can be pinned. + * release of it did, with a directory writable, the sandbox of a process with + * another environment where a process has to keep its manifests under a name + * its own environment gives, and a directory whose name, or a name above it, + * is a link inside a write root, or whose parent is not there yet below one: + * neither can be pinned. * * Both of the kernel's answers are relative to a PID namespace: /proc/locks * lists a lock only when its holder has a pid in the namespace of the /proc @@ -289,7 +302,7 @@ function deviceOf(dev: bigint): string { * device of their own - and there only the inode number can be compared. */ const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ - 0x01021994, // tmpfs, which is what $XDG_RUNTIME_DIR is + 0x01021994, // tmpfs, which is what /run/user/UID is 0xef53, // ext2, ext3, ext4 0x58465342, // xfs ]) @@ -308,8 +321,8 @@ type Locks = { inodes: Set /** The same locks as `major:minor:inode`, in decimal. */ files: Set - /** Whether a manifest's device can be held against `files`. */ - comparesDevices: boolean + /** The directories whose manifests' device can be held against `files`. */ + comparesDevices: Set } /** @@ -328,22 +341,22 @@ type Locks = { */ function holdsLock( locks: Locks, - file: { inode: string; device: string }, + file: { file: string; inode: string; device: string }, ): boolean { - return locks.comparesDevices + return locks.comparesDevices.has(path.dirname(file.file)) ? locks.files.has(`${file.device}:${file.inode}`) : locks.inodes.has(file.inode) } /** - * What /proc/locks lists, for the manifests in `dir`. Only the locks whose + * What /proc/locks lists, for the manifests in `dirs`. Only the locks whose * holder has a pid in the PID namespace of the /proc being read are listed, * which is why a manifest from another namespace is never judged by this. * * `undefined` when the list could not be read, which every caller reads as * "every manifest is locked". */ -function readLocks(dir: string): Locks | undefined { +function readLocks(dirs: readonly string[]): Locks | undefined { let text: string try { text = fs.readFileSync('/proc/locks', 'utf8') @@ -371,7 +384,11 @@ function readLocks(dir: string): Locks | undefined { } } } - return { inodes, files, comparesDevices: reportsTheLockedDevice(dir) } + return { + inodes, + files, + comparesDevices: new Set(dirs.filter(reportsTheLockedDevice)), + } } /** Whether `dir` is a directory of ours that nobody else can write, and we can. */ @@ -445,30 +462,141 @@ function isBuriedBySandboxMounts(dir: string): boolean { ) } +/** + * The places the manifest directories are looked for in. The first two are + * the same for every process of a user, whatever started it; the last two are + * what this process's environment says. + */ +export type ManifestDirectoryPlaces = { + /** The user's runtime directory where the system keeps one: /run/user/UID. */ + runtimeDir: string + /** The temp dir every process can name: /tmp. */ + tempDir: string + /** $XDG_RUNTIME_DIR, where it is set. */ + environmentRuntimeDir: string | undefined + /** The system temp dir as this process sees it, which is $TMPDIR's. */ + environmentTempDir: string +} + +// What a test has put in the place of some of those, and nothing else has. +let placesForTesting: Partial | undefined + +function manifestDirectoryPlaces(): ManifestDirectoryPlaces { + return { + runtimeDir: `/run/user/${process.getuid?.() ?? 0}`, + tempDir: '/tmp', + environmentRuntimeDir: process.env['XDG_RUNTIME_DIR'], + environmentTempDir: tmpdir(), + ...placesForTesting, + } +} + +/** + * Whether `dir` is a runtime directory of this user's as the system makes + * one: a real directory, not a link to one, the user's, and nobody else's to + * look into. + */ +function isTheUsersRuntimeDirectory(dir: string): boolean { + try { + const stat = fs.lstatSync(dir) + return ( + stat.isDirectory() && + stat.uid === process.getuid?.() && + (stat.mode & 0o077) === 0 + ) + } catch { + return false + } +} + +/** + * Where the directory `name` names really is, as far as that can be told + * before it is made: under its parent as the links above it resolve, by its + * own last component. + */ +function whereItReallyIs(name: string): string { + try { + return path.join(fs.realpathSync(path.dirname(name)), path.basename(name)) + } catch { + return path.resolve(name) + } +} + /** * The names under which the processes of this user keep their manifests where - * several can find them, in the order they are tried: under $XDG_RUNTIME_DIR - * when there is one, else under the system temp dir. One that a sandbox's own - * mounts would bury is left out. + * several can find them, in the order they are tried for writing: + * - `/run/user/UID/srt-mount-points`, where `/run/user/UID` is a runtime + * directory of the user's (see {@link isTheUsersRuntimeDirectory}); + * - `/tmp/srt-mount-points-UID`; + * - `$XDG_RUNTIME_DIR/srt-mount-points`, where that is set; + * - `srt-mount-points-UID` under the system temp dir, which is `$TMPDIR`'s. + * + * The user id and the file system come first because they are the same for + * every process of the user. The environment is not: a login shell and what a + * service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, + * two shells can differ in `TMPDIR`, and two processes that each wrote where + * their own environment said never read each other's manifests. The second + * wrap then took the first's mount point for the user's own file, and the + * first's clean-up removed it from under the second sandbox. The names from + * the environment stay, last, for a process that can use neither of the first + * two. + * + * Two names for one directory are one, the earlier, by where they really are. + * One that a sandbox's own mounts would bury is left out. */ +function manifestDirectoryCandidates(): { + name: string + fromTheEnvironment: boolean +}[] { + const places = manifestDirectoryPlaces() + const perUser = `srt-mount-points-${process.getuid?.() ?? 0}` + const names: { name: string; fromTheEnvironment: boolean }[] = [] + if (isTheUsersRuntimeDirectory(places.runtimeDir)) { + names.push({ + name: path.join(places.runtimeDir, 'srt-mount-points'), + fromTheEnvironment: false, + }) + } + names.push({ + name: path.join(places.tempDir, perUser), + fromTheEnvironment: false, + }) + if ( + places.environmentRuntimeDir !== undefined && + path.isAbsolute(places.environmentRuntimeDir) + ) { + names.push({ + name: path.join(places.environmentRuntimeDir, 'srt-mount-points'), + fromTheEnvironment: true, + }) + } + names.push({ + name: path.join(places.environmentTempDir, perUser), + fromTheEnvironment: true, + }) + const found = new Set() + return names.filter(candidate => { + const where = whereItReallyIs(candidate.name) + if (isBuriedBySandboxMounts(candidate.name) || found.has(where)) { + return false + } + found.add(where) + return true + }) +} + +/** The names of {@link manifestDirectoryCandidates}, in their order. */ function sharedManifestDirectoryNames(): string[] { - const runtimeDir = process.env['XDG_RUNTIME_DIR'] - const shared = - runtimeDir !== undefined && path.isAbsolute(runtimeDir) - ? [path.join(runtimeDir, 'srt-mount-points')] - : [] - shared.push( - path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), - ) - return shared.filter(candidate => !isBuriedBySandboxMounts(candidate)) + return manifestDirectoryCandidates().map(candidate => candidate.name) } /** - * The directory manifests live in: under $XDG_RUNTIME_DIR when there is one, - * else a per-user directory under the system temp dir, else - when neither can - * be made ours alone and written, as in a sandbox that binds them read-only - - * a private directory only this process knows, which keeps the guarantee - * within this process and loses only what other processes would have read. + * The directory this process writes its manifests to: the first of the shared + * names (see {@link sharedManifestDirectoryNames}) that is, or can be made, a + * directory of ours alone that we can write, else - when none can, as in a + * sandbox that binds them read-only - a private directory only this process + * knows, which keeps the guarantee within this process and loses only what + * other processes would have read. * * Revalidated on every use: the temp dir is somewhere sandboxed commands * commonly write, and a directory swapped for a symlink between two wraps @@ -489,11 +617,14 @@ function ensureManifestDirectory(): string | undefined { } } manifestDirectory = undefined - if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(tmpdir())) { + const systemTempDir = manifestDirectoryPlaces().environmentTempDir + if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(systemTempDir)) { return undefined } try { - const private_ = fs.mkdtempSync(path.join(tmpdir(), 'srt-mount-points-')) + const private_ = fs.mkdtempSync( + path.join(systemTempDir, 'srt-mount-points-'), + ) if (!makeOurPrivateDirectory(private_)) { // A temp dir that keeps no modes or ownership: nothing made in it can be // told from somebody else's, now or on the next call. @@ -534,25 +665,72 @@ function isOurDirectory(dir: string): boolean { } } +/** + * Every directory whose manifests this process believes, for a wrap, a + * clean-up and {@link liveMountPoints} to read: + * - each of the names that come from the user id that is there and is a + * directory of ours alone that we can write, whichever of them this process + * writes to; + * - the names the environment gives, held to the same, only where none of + * the first kind will do, which is where this process writes to one of + * them; + * - the one this process has settled on, whatever it is. + * + * What the environment names is kept out of a sandboxed command's reach by + * the processes whose environment names it, and by no other: a process with + * another temp dir does not know of it, and its command may write there. A + * manifest planted that way would keep a path, or have one removed, on the + * word of a process that never relied on the directory, so it is believed + * only by a process that has nowhere else to keep its own. Nothing is lost by + * that: every process that can use a name from the user id meets every other + * one there. + * + * One that is a link, somebody else's, open to others or not to be written is + * passed over: what is in it is not believed, and nothing fails for it. + * Nothing is made. + */ +function believedManifestDirectories(): string[] { + const usable = manifestDirectoryCandidates().filter(candidate => + isOurPrivateDirectory(candidate.name), + ) + const fromTheUserId = usable.filter( + candidate => !candidate.fromTheEnvironment, + ) + const dirs = (fromTheUserId.length > 0 ? fromTheUserId : usable).map( + candidate => candidate.name, + ) + if ( + manifestDirectory !== undefined && + !dirs.includes(manifestDirectory) && + isOurPrivateDirectory(manifestDirectory) + ) { + dirs.push(manifestDirectory) + } + return dirs +} + /** * Every directory a sandbox must not be able to write, because some process * of this user keeps manifests in it and a collect on the host believes what - * it finds there: both shared names, as this process sees them, and the one - * this process has settled on where that is neither. For a wrap to bind + * it finds there: every shared name, as this process sees them, and the one + * this process has settled on where that is none of them. For a wrap to bind * read-only, whether or not it has a manifest of its own. * - * Both names, and not only the one this process uses: a process started with - * $XDG_RUNTIME_DIR uses the first and one started without it (from cron, over - * a plain ssh, as a service) the second, and a sandbox of the first kind with - * the temp dir writable could otherwise delete and forge the manifests of the - * second. Each is made if it can be, so that a sandboxed command cannot make - * it first and fill it before the process that will believe it comes along; - * one that is there and the user's is listed even when it cannot be written - * from here. The last resort is not made for this: there is nothing to keep - * out of reach in a directory nobody has made. A wrap that is about to record - * says so with `recording`, and the directory its manifest will go to is then - * settled first, the last resort included: the wrap has to know every one of - * these before it publishes, to pin what lies above them. + * Every name, and not only the one this process writes to: every process of + * the user believes what is under the ones that come from the user id, and + * one with this environment that can use none of those what is under the + * ones the environment gives, so a sandbox with the temp dir writable could + * otherwise delete and forge manifests in the one under it while its own + * process writes under the runtime directory. Each is made if it can be, so + * that a sandboxed command cannot make it first and fill it before a process + * that will believe it comes along: the command runs as the user, so what it + * makes passes every check of owner and mode, which keep other users out and + * not the command. One that is there and the user's is listed even when it + * cannot be written from here. The last resort is not made for this: there is + * nothing to keep out of reach in a directory nobody has made. A wrap that is + * about to record says so with `recording`, and the directory its manifest + * will go to is then settled first, the last resort included: the wrap has to + * know every one of these before it publishes, to pin what lies above them. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -1094,34 +1272,44 @@ export function discardMountPointManifest(file: string): void { } /** - * The mount points the manifests name, on one reading of the directory: - * `live`, by a manifest whose sandbox may still be running, and `named`, by - * any manifest at all. What only a finished manifest names is what a sandbox - * that has ended left behind and no pass has collected yet, and the next pass - * in any process removes it on that manifest's word. + * The path of every file in `dirs`, for a reading on which nothing is + * removed: a directory that cannot be listed adds nothing. + */ +function filesIn(dirs: readonly string[]): string[] { + return dirs.flatMap(dir => { + try { + return fs.readdirSync(dir).map(name => path.join(dir, name)) + } catch { + return [] + } + }) +} + +/** + * The mount points the manifests name, on one reading of every directory this + * process believes (see {@link believedManifestDirectories}): `live`, by a + * manifest whose sandbox may still be running, and `named`, by any manifest at + * all. What only a finished manifest names is what a sandbox that has ended + * left behind and no pass has collected yet, and the next pass in any process + * removes it on that manifest's word. * - * `live` is empty when /proc/locks cannot be read, and both are when the - * directory cannot be. A wrap takes an existing path for a mount point on a - * manifest's word only, never by the look of it, so it then takes every - * existing path for the caller's own. + * `live` is empty when /proc/locks cannot be read, and a directory that + * cannot be listed adds to neither. A wrap takes an existing path for a mount + * point on a manifest's word only, never by the look of it, so with none to + * be read it takes every existing path for the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() const named = new Set() - const dir = onLinux() ? ensureManifestDirectory() : undefined - if (dir === undefined) { - return { live, named } - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { + if (!onLinux()) { return { live, named } } - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const manifest = readManifest(path.join(dir, name)) + ensureManifestDirectory() + const dirs = believedManifestDirectories() + const locks = dirs.length > 0 ? readLocks(dirs) : undefined + for (const file of filesIn(dirs)) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue + const manifest = readManifest(file) if (manifest === undefined) continue const isLiveOne = locks !== undefined && isLive(manifest, locks) for (const mountPoint of manifest.paths) { @@ -1134,19 +1322,6 @@ export function namedMountPoints(): { live: Set; named: Set } { return { live, named } } -/** - * The directory this process keeps its manifests in, if it is there already: - * the one it has settled on, else the first of the shared names that is a - * directory of the user's alone. Nothing is made, and nothing is settled: for - * a look that has to leave the host as it found it. - */ -function existingManifestDirectory(): string | undefined { - return manifestDirectory !== undefined && - isOurPrivateDirectory(manifestDirectory) - ? manifestDirectory - : sharedManifestDirectoryNames().find(isOurPrivateDirectory) -} - /** * Whether what is at `p` now is still something a clean-up would take away on * a manifest's word: the empty file bubblewrap made (see @@ -1174,7 +1349,7 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command, to leave out the ones in this set. Removing * a mount point from under a running sandbox lifts the deny there. * - * It is a snapshot, taken without a lock, from one listing of the manifest + * It is a snapshot, taken without a lock, from one listing of each manifest * directory and one reading of /proc/locks, and it may be out of date by the * time the caller acts on it: a sandbox can start on a path the moment after. * Its only safe use is to SKIP a removal. That a path is not in it never @@ -1196,39 +1371,33 @@ function isEmptyMountPoint(p: string): boolean { * directories above the last component resolved and the last component as it * stands. A caller compares its own paths in that form. * - * The manifests are believed as found. Their directory can be written by the - * user's own processes outside any sandbox; every sandbox that restricts - * writes has it bound read-only, with the directories between it and the - * write root it lies in pinned, which does not reach a runtime or temp - * directory whose name is a link inside a write root, nor a runtime directory - * that is not there yet below one. It answers for one manifest directory, - * the one this process keeps its manifests in or, before it has kept any, the - * first of the shared names that is there, so a sandbox of a process that - * keeps its manifests elsewhere, or of something that writes none, is not - * seen. + * The manifests are believed as found. Their directories can be written by + * the user's own processes outside any sandbox; every sandbox that restricts + * writes has each of them bound read-only, with the directories between it + * and the write root it lies in pinned, which does not reach a directory + * whose name, or a name above it, is a link inside a write root, nor one + * whose parent is not there yet below one. It answers for every manifest + * directory this process believes (see {@link believedManifestDirectories}), + * whichever of them it writes to, so a sandbox of a process that keeps its + * manifests under a name its environment gives, or in a directory of its own, + * for want of a name from the user id that it can use, or of something that + * writes none, is not seen. * - * Makes nothing on the host, the manifest directory included. Empty where + * Makes nothing on the host, a manifest directory included. Empty where * there is no manifest directory, and anywhere but on Linux. */ export function liveMountPoints(): ReadonlySet { const spared = new Set() - const dir = onLinux() ? existingManifestDirectory() : undefined - if (dir === undefined) { - return spared - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { + const dirs = onLinux() ? believedManifestDirectories() : [] + if (dirs.length === 0) { return spared } + const locks = readLocks(dirs) const live = new Set() const named = new Set() let cannotTell = locks === undefined - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of filesIn(dirs)) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(file) if (manifest === undefined) { // Gone since the listing, or not to be read: held to what the clean-up @@ -1238,6 +1407,7 @@ export function liveMountPoints(): ReadonlySet { cannotTell ||= locks === undefined || holdsLock(locks, { + file, inode: String(stat.ino), device: deviceOf(stat.dev), }) || @@ -1268,6 +1438,11 @@ export function liveMountPoints(): ReadonlySet { * over, and remove every mount point no live manifest names, from any process, * at any time, any number of times. Returns the mount points removed. Does * nothing anywhere but on Linux. + * + * One pass for each directory this process believes, under that directory's + * lock: a pass takes off the disk what the finished manifests of its own + * directory name, and those manifests, and goes by the manifests of every + * directory for what is still relied on. */ export function collectMountPoints( release: OwnManifestRelease = 'all', @@ -1287,24 +1462,32 @@ export function collectMountPoints( own.over = true } } - const dir = ensureManifestDirectory() - if (dir === undefined) { - return [] - } - const collected = withDirectoryLock(dir, () => collectUnderLock(dir)) - if (!collected.ran) { + ensureManifestDirectory() + const dirs = believedManifestDirectories() + const removed: string[] = [] + for (const dir of dirs) { + const collected = withDirectoryLock(dir, () => collectUnderLock(dir, dirs)) + if (collected.ran) { + removed.push(...collected.value) + continue + } logForDebugging( collected.held ? `[Sandbox Linux] The lock on the mount point directory could not be had, leaving this pass to whoever has it: ${dir}` : `[Sandbox Linux] No lock can be taken on the mount point directory from here, so nothing is collected from it until that is put right: ${dir}`, ) - return [] } - return collected.value + return removed } /** - * One pass, which the caller makes under the directory lock where it can. + * One pass over `dir`, which the caller makes under that directory's lock + * where it can. `dirs` is every directory this process believes, `dir` among + * them. The pass reads the manifests of all of them, and a mount point stays + * while a live manifest in any of them names it; what it takes off the disk + * is what the finished manifests of `dir` name, and those manifests. What the + * finished manifests of another directory name goes at the pass over that + * one, under its lock. * * Nothing is taken off the disk until the end. A manifest is all that names * its mount points, so it goes last, after them, and only when every one of @@ -1317,7 +1500,7 @@ export function collectMountPoints( * The lock does not exclude (see {@link withDirectoryLock}), so the pass does * not rely on the directory staying as first listed. Immediately before it * removes anything, and again whenever its last listing is more than - * {@link LISTING_GOOD_FOR_MS} old, it lists the directory again, where a + * {@link LISTING_GOOD_FOR_MS} old, it lists every directory again, where a * manifest that was not there at first keeps everything it names whatever * else is true of it: that is how a sandbox about to start on the same path * shows. And before the first removal, and again every @@ -1332,10 +1515,17 @@ export function collectMountPoints( * longer than that between looking and removing, with a sandbox starting on * the same path in that time, still takes that sandbox's mount point away. */ -function collectUnderLock(dir: string): string[] { - let names: string[] +function collectUnderLock(dir: string, dirs: readonly string[]): string[] { + // Every file in every directory, or nothing: a directory that is believed + // and cannot be listed may hold the manifest of a running sandbox. + const listEveryDirectory = (): string[] => + dirs.flatMap(each => + fs.readdirSync(each).map(name => path.join(each, name)), + ) + const isHere = (file: string): boolean => path.dirname(file) === dir + let files: string[] try { - names = fs.readdirSync(dir) + files = listEveryDirectory() } catch (e) { logForDebugging( `[Sandbox Linux] The mount point manifests could not be listed (${String(e)}) - nothing removed`, @@ -1345,9 +1535,8 @@ function collectUnderLock(dir: string): string[] { } const manifests: Manifest[] = [] const unreadable: string[] = [] - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of files) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(file) if (manifest === undefined) { unreadable.push(file) @@ -1355,17 +1544,17 @@ function collectUnderLock(dir: string): string[] { manifests.push(manifest) } } - const locks = readLocks(dir) + const locks = readLocks(dirs) if (locks === undefined) { return [] } // What the pass knows of: the first listing, so that everything found // later is a newcomer. - const known = new Set(names) + const known = new Set(files) // A manifest of this process that something else has taken off the disk is // nothing to remember. for (const file of ownManifests.keys()) { - if (path.dirname(file) === dir && !known.has(path.basename(file))) { + if (isHere(file) && !known.has(file)) { ownManifests.delete(file) } } @@ -1387,13 +1576,14 @@ function collectUnderLock(dir: string): string[] { const age = Date.now() - Number(stat.mtimeMs) if ( holdsLock(locks, { + file, inode: String(stat.ino), device: deviceOf(stat.dev), }) || age < MANIFEST_GRACE_MS ) { unreadableIsLive = true - } else if (age > UNREADABLE_MANIFEST_MAX_AGE_MS) { + } else if (isHere(file) && age > UNREADABLE_MANIFEST_MAX_AGE_MS) { try { fs.unlinkSync(file) } catch { @@ -1411,9 +1601,8 @@ function collectUnderLock(dir: string): string[] { // What a process killed between writing a file and moving it into place // left. Nothing reads these, and nothing else removes them. - for (const name of names) { - if (!name.endsWith(TEMPORARY_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of files) { + if (!isHere(file) || !file.endsWith(TEMPORARY_SUFFIX)) continue try { if ( Date.now() - fs.lstatSync(file).mtimeMs > @@ -1450,8 +1639,11 @@ function collectUnderLock(dir: string): string[] { } } // Each path once, and as a mount point where a manifest names it as one. + // Only what the finished manifests of this directory name: the others are + // kept in `finished` to be asked after again, not to be acted on. const candidates = new Map() for (const manifest of finished) { + if (!isHere(manifest.file)) continue for (const mountPoint of manifest.paths) { if (!claimed.has(mountPoint)) { candidates.set(mountPoint, false) @@ -1479,7 +1671,7 @@ function collectUnderLock(dir: string): string[] { const listingIsFresh = (): boolean => listedAt !== undefined && performance.now() - listedAt < LISTING_GOOD_FOR_MS const readTheLocksAgain = (): boolean => { - const locksNow = readLocks(dir) + const locksNow = readLocks(dirs) if (locksNow === undefined) { return false } @@ -1494,16 +1686,15 @@ function collectUnderLock(dir: string): string[] { return true } const listTheDirectoryAgain = (): boolean => { - let namesNow: string[] + let filesNow: string[] try { - namesNow = fs.readdirSync(dir) + filesNow = listEveryDirectory() } catch { return false } - for (const name of namesNow) { - if (!name.endsWith(MANIFEST_SUFFIX) || known.has(name)) continue - known.add(name) - const file = path.join(dir, name) + for (const file of filesNow) { + if (!file.endsWith(MANIFEST_SUFFIX) || known.has(file)) continue + known.add(file) const arrived = readManifest(file) if (arrived !== undefined) { // Published since the pass began: a sandbox is about to start under @@ -1551,6 +1742,7 @@ function collectUnderLock(dir: string): string[] { // The manifests themselves, last. Only a lock taken since can speak for one // of these; a manifest somebody else has published in the meantime cannot. for (const manifest of finished) { + if (!isHere(manifest.file)) continue if (!locksAreFresh() && !readTheLocksAgain()) { return removed } @@ -1579,13 +1771,23 @@ function collectUnderLock(dir: string): string[] { } /** - * Forget which directory the manifests are kept in, so that the next use - * works it out again from the environment. Test seam: a test gives itself a - * runtime directory of its own, so that what it lists, attacks and collects - * is never another process's, and this module may have settled on the real - * one before the test was loaded. + * Put directories of a test's own in the place of those the manifest + * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, + * with nothing, put the real ones back. Test seam, and nothing else may call + * it: the real places are every other process's of the user, and a test that + * lists, attacks or collects there does so to the sandboxes that user is + * running. A place that is left out stays what it really is, so a test can + * stand in for `/run/user/UID` and `/tmp` and leave the environment to say the + * rest. + * + * Which directory the manifests are written to is forgotten with it, so that + * the next use works it out again: this module may have settled on one before + * the test that wants another was loaded. */ -export function forgetMountPointManifestDirectory(): void { +export function setMountPointManifestPlacesForTesting( + places?: Partial, +): void { + placesForTesting = places manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7af3e3ea8..72c274b8a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2963,10 +2963,10 @@ async function generateFilesystemArgs( // a manifest could name any path at all for a collect on the host to // remove. That holds for a sandbox whose own wrap names no manifest as much // as for one that does, and for the directory some other process of this - // user keeps its manifests in as much as for this one's: the directory sits - // under the system temp dir wherever there is no $XDG_RUNTIME_DIR, which a - // caller's allowWrite commonly covers, and what is in it then belongs to - // OTHER sandboxes. So every wrap that restricts writes binds every one of + // user keeps its manifests in as much as for this one's: one of them sits + // under /tmp whether or not this process writes there, where a caller's + // allowWrite commonly reaches, and what is in it then belongs to OTHER + // sandboxes. So every wrap that restricts writes binds every one of // them read-only (see mountPointManifestDirectories), with what lies above // them pinned (they are seeds of the ancestor pins), and a wrap with mount // points of its own adds the lock. Emitted after every allow and deny bind, @@ -2976,8 +2976,8 @@ async function generateFilesystemArgs( // the mounts, so a profile with a tmpfs over the temp dir would otherwise // leave nothing there to open. // - // Each is bound where it really is, like a pin, and not at the name the - // environment gave for it: bubblewrap makes a bind's destination by name + // Each is bound where it really is, like a pin, and not at the name it + // was worked out as: bubblewrap makes a bind's destination by name // inside the new root before it mounts, and a link on the way whose target // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir // reached through such a link no command that restricts writes started. From 4c904f4737a55fa1588feb6248474d139af41518 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 077/141] test: processes with different environments see each other; no suite touches the user's own manifest directories Real bubblewrap runs with two real processes, in a mount namespace where a private directory stands at /tmp and at /run/user: the deny holds in a second sandbox after the first has ended, with different XDG_RUNTIME_DIR and with different TMPDIR; a command that may write /tmp can neither make, write nor move aside the directory under it; a manifest forged under one process's TMPDIR by the sandbox of another gets nothing removed. bunfig.toml preloads stand-ins for the four places before any test file is loaded, and children are handed a module or a program that does the same, so no suite reads, writes or collects where the user's own sandboxes keep their manifests. Several did. --- bunfig.toml | 5 + test/cli.test.ts | 9 +- test/control-fd.test.ts | 7 +- test/helpers/private-manifest-directory.ts | 315 ++++++++- test/preload.ts | 12 + test/sandbox/default-write-paths.test.ts | 7 +- test/sandbox/linux-bwrap-args-file.test.ts | 28 +- test/sandbox/mount-point-liveness.test.ts | 683 ++++++++++++++++--- test/sandbox/mount-point-manifests.test.ts | 751 ++++++++++++++++++++- test/sandbox/path-canonicalization.test.ts | 13 +- test/sandbox/stale-mount-point.test.ts | 10 +- 11 files changed, 1671 insertions(+), 169 deletions(-) create mode 100644 bunfig.toml create mode 100644 test/preload.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..60638959a --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Directories of the run's own stand in for the ones the mount point manifests +# are kept in, before any test file is loaded: see +# test/helpers/private-manifest-directory.ts. +preload = ["./test/preload.ts"] diff --git a/test/cli.test.ts b/test/cli.test.ts index 654490796..c5be11f20 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -9,12 +9,17 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { isolatedProgram } from './helpers/private-manifest-directory.js' + +let cliPath: string | undefined /** - * Get the path to the CLI entry point + * Get the path to the CLI entry point: the tool itself, started with + * directories of the run's own in place of the ones it keeps its mount point + * manifests in, which are every other srt process's of the user. */ function getCliPath(): string { - return join(process.cwd(), 'src', 'cli.ts') + return (cliPath ??= isolatedProgram(join(process.cwd(), 'src', 'cli.ts'))) } /** diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 770d6c2e0..2e7f0fd73 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -10,9 +10,12 @@ import * as os from 'os' import * as path from 'path' import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' +import { isolatedProgram } from './helpers/private-manifest-directory.js' -// Get the path to the built CLI -const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js') +// Get the path to the built CLI: the tool itself, started with directories of +// the run's own in place of the ones it keeps its mount point manifests in, +// which are every other srt process's of the user. +const CLI_PATH = isolatedProgram(path.join(process.cwd(), 'dist', 'cli.js')) // srt is expected to exit on its own shortly after the wrapped command // (which runs for well under a second) finishes; a hang is a failure, not diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 897287435..2180c3373 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,21 +1,258 @@ import { afterAll, beforeAll } from 'bun:test' -import { chmodSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' +import { dirname, extname, join } from 'node:path' +import { + type ManifestDirectoryPlaces, + setMountPointManifestPlacesForTesting, +} from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' +const REPOSITORY = join(import.meta.dir, '../..') +const SEAM = join(REPOSITORY, 'src/sandbox/bwrap-mount-manifests.ts') + /** - * Gives the enclosing `describe` a runtime directory of its own for the mount - * point manifests, for as long as its tests run. + * The manifests of every srt process of a user live in a few directories that + * are worked out from the user id: `/run/user/UID/srt-mount-points` and + * `/tmp/srt-mount-points-UID`, and after those what the environment names. + * Every wrap makes and binds them, and every clean-up reads, judges and + * removes what is in them. A test that did so in the real ones would be doing + * it to whatever else that user is running on the machine, and be failed by + * it in turn, and nothing in the environment moves a process away from them. + * So no test looks there: * - * The manifests of every srt process of a user live in one directory, and a - * collect reads, judges and removes all of them. A test that lists that - * directory, attacks it from inside a sandbox or asserts on what is left in it - * would otherwise be doing so to whatever else that user is running on the - * machine, and be failed by it in turn. `$XDG_RUNTIME_DIR` is where the library - * looks first, and child processes inherit it, so pointing it at a fresh - * directory moves this process and everything it spawns at once. + * - in the test process, directories of the test's own stand in for all four + * places, through the seam the module has for it + * ({@link isolateManifestDirectoriesForTheRun} for every test file of the + * run, {@link usePrivateManifestDirectory} for a `describe` that wants them + * to itself); + * - a child process that loads the library from a script is given a module to + * load that puts the same stand-ins in place first ({@link isolatedModule}), + * and one that runs the command line tool a program that does + * ({@link isolatedProgram}); + * - a child process that is to work the directories out for itself, from the + * real names, runs in a mount namespace of its own in which the stand-ins + * are bound over `/tmp` and `/run/user` ({@link inPrivateNamespace}). + * + * All of it is bubblewrap's, which is Linux's: on any other platform the + * library keeps no manifest anywhere, nothing is put in the place of + * anything, and a child process is given the module or the program itself. + */ +type Places = ManifestDirectoryPlaces + +const onLinux = process.platform === 'linux' + +type PrivatePlaces = { + /** Holds the stand-ins, and what is made for child processes to load. */ + base: string + places: Places +} + +// Every base that was made and not removed yet, for the end of the run: a +// `describe` that is skipped is read all the same, and never torn down. +const bases = new Set() + +/** + * Directories of the caller's own to stand in for the four places. The + * runtime directory is named after the user id under its parent, as the real + * one is, so that the parent can be bound over `/run/user`. Nothing stands in + * for `$XDG_RUNTIME_DIR`, which is as if it were not set, and the temp dir + * the environment names is the one that stands in for `/tmp`. + */ +function makePlaces(): PrivatePlaces { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-places-'))) + bases.add(base) + const runtimeDir = join(base, 'run', String(process.getuid?.() ?? 0)) + const tempDir = join(base, 'tmp') + mkdirSync(runtimeDir, { recursive: true }) + chmodSync(runtimeDir, 0o700) + mkdirSync(tempDir) + chmodSync(tempDir, 0o1777) + return { + base, + places: { + runtimeDir, + tempDir, + environmentRuntimeDir: undefined, + environmentTempDir: tempDir, + }, + } +} + +function removePlaces(made: PrivatePlaces): void { + rmSync(made.base, { recursive: true, force: true }) + bases.delete(made.base) +} + +/** `places` as source text, with what is not set spelled out. */ +function sourceOf(places: Partial): string { + const fields = Object.entries(places).map( + ([name, value]) => + `${name}: ${value === undefined ? 'undefined' : JSON.stringify(value)}`, + ) + return `{ ${fields.join(', ')} }` +} + +let filesMade = 0 + +/** + * A file for a child process to load in place of `target`, which puts + * `places` in place and then is `target`: a module of the sources, which it + * gives again, or the command line tool, from the sources or as it was built, + * which it runs. The tool's seam is the one beside it. + * + * The stand-ins are in place before the child does anything with what it has + * loaded. A module's imports are evaluated before its own body, and the body + * is the call to the seam; a program starts work as it is loaded, so it is + * loaded after the call. + */ +function isolatedFile( + made: PrivatePlaces, + target: string, + places: Partial, + kind: 'module' | 'program', +): string { + const built = extname(target) !== '.ts' + const seam = + kind === 'module' + ? SEAM + : join( + dirname(target), + 'sandbox', + `bwrap-mount-manifests${extname(target)}`, + ) + const file = join( + made.base, + `isolated-${filesMade++}${built ? '.mjs' : '.ts'}`, + ) + writeFileSync( + file, + [ + `import { setMountPointManifestPlacesForTesting } from ${JSON.stringify(seam)}`, + `setMountPointManifestPlacesForTesting(${sourceOf(places)})`, + kind === 'module' + ? `export * from ${JSON.stringify(target)}` + : `await import(${JSON.stringify(target)})`, + '', + ].join('\n'), + ) + return file +} + +// What stands in for the real places in every test file of the run, which a +// `describe` with stand-ins of its own puts back when it is done. +let placesOfTheRun: PrivatePlaces | undefined + +/** + * Puts stand-ins in place for the whole run: most suites wrap a command + * somewhere, and none of them is to make, bind or collect in the user's real + * directories for it. The test runner's preload does it before any test file + * is loaded, and takes the stand-ins away after the last one + * ({@link removeManifestDirectoriesOfTheRun}). + */ +export function isolateManifestDirectoriesForTheRun(): void { + if (onLinux && placesOfTheRun === undefined) { + placesOfTheRun = makePlaces() + setMountPointManifestPlacesForTesting(placesOfTheRun.places) + } +} + +/** + * Removes every stand-in that is still there. They stay in place in the + * module, so that nothing that still runs finds the real ones. + */ +export function removeManifestDirectoriesOfTheRun(): void { + for (const left of bases) { + rmSync(left, { recursive: true, force: true }) + } + bases.clear() +} + +/** The stand-ins of the run, which are put in place if they were not. */ +function ofTheRun(): PrivatePlaces { + isolateManifestDirectoriesForTheRun() + return placesOfTheRun! +} + +/** + * The path of a module for a child process to load in place of `module`, with + * the stand-ins of the run in place, or with `places` where the child is to + * look somewhere else. A place that is left out of `places` is the real one + * for that child: what its environment says, for the last two. + */ +export function isolatedModule( + module: string, + places?: Partial, +): string { + if (!onLinux) { + return module + } + const run = ofTheRun() + return isolatedFile(run, module, places ?? run.places, 'module') +} + +/** + * The path of a program for a child process to run in place of `program`, the + * command line tool as it is in the sources or as it was built, with the + * stand-ins of the run in place. + */ +export function isolatedProgram(program: string): string { + if (!onLinux) { + return program + } + const run = ofTheRun() + return isolatedFile(run, program, run.places, 'program') +} + +/** + * The start of a command line that runs what follows it in a mount namespace + * of its own, in which `places.tempDir` is bound over `/tmp` and the parent of + * `places.runtimeDir` over `/run/user`: for a child process that is to work + * the directories out for itself, from the real names, without reaching the + * real ones. Whatever is in `keep` is bound back where it is, for what the + * child needs that lies under the real `/tmp`, and so are this repository and + * the runtime where they lie there. No PID namespace, so `/proc/locks` and + * the pids in it are the test's own. + * + * Needs a bubblewrap that can make a namespace (see `bwrapCanNamespace`). + */ +export function inPrivateNamespace( + places: Pick, + keep: readonly string[] = [], +): string[] { + const needed = [REPOSITORY, dirname(process.execPath)].filter(dir => + dir.startsWith('/tmp/'), + ) + return [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--bind', + places.tempDir, + '/tmp', + // Where there is no /run/user there is no runtime directory to reach. + ...(existsSync('/run/user') + ? ['--bind', dirname(places.runtimeDir), '/run/user'] + : []), + ...[...needed, ...keep].flatMap(kept => ['--bind', kept, kept]), + '--', + ] +} + +/** + * Gives the enclosing `describe` directories of its own for the mount point + * manifests, for as long as its tests run: a test that lists the manifests, + * attacks them from inside a sandbox or asserts on what is left of them needs + * to be the only one that writes there. * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so. The library counts the wraps it has handed out and releases @@ -24,37 +261,51 @@ import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.j * without cleaning up. A test that expects one clean-up to take its mount point * away would otherwise pass or fail by which file ran before it. * - * Call it inside the `describe` callback. Returns where the manifests go. + * Call it inside the `describe` callback. Returns: + * - `manifestDir()`, where this process's manifests go: under the runtime + * directory, the first place looked in; + * - `places()`, the stand-ins themselves; + * - `isolated(module, places)`, which is {@link isolatedModule} with the + * stand-ins of this `describe`. */ -export function usePrivateManifestDirectory(): { manifestDir(): string } { - const saved = process.env.XDG_RUNTIME_DIR - let runtimeDir: string | undefined +export function usePrivateManifestDirectory(): { + manifestDir(): string + places(): Places + isolated(module: string, places?: Partial): string +} { + let made: PrivatePlaces | undefined + // Made on first use, which may be while the `describe` is being read, for + // the module a child is to load. + const own = (): PrivatePlaces => (made ??= makePlaces()) beforeAll(() => { - // In the directory the earlier suites used, before it is left behind. + if (!onLinux) { + return + } + // In the directories the earlier suites used, before they are left + // behind. cleanupBwrapMountPoints({ force: true }) - runtimeDir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-runtime-'))) - chmodSync(runtimeDir, 0o700) - process.env.XDG_RUNTIME_DIR = runtimeDir - forgetMountPointManifestDirectory() + setMountPointManifestPlacesForTesting(own().places) }) afterAll(() => { + if (!onLinux) { + return + } cleanupBwrapMountPoints({ force: true }) - if (saved === undefined) delete process.env.XDG_RUNTIME_DIR - else process.env.XDG_RUNTIME_DIR = saved - forgetMountPointManifestDirectory() - if (runtimeDir !== undefined) { - rmSync(runtimeDir, { recursive: true, force: true }) + setMountPointManifestPlacesForTesting(placesOfTheRun?.places) + if (made !== undefined) { + removePlaces(made) + made = undefined } }) return { - manifestDir: () => { - if (runtimeDir === undefined) { - throw new Error('asked for before the tests began') - } - return join(runtimeDir, 'srt-mount-points') - }, + manifestDir: () => join(own().places.runtimeDir, 'srt-mount-points'), + places: () => own().places, + isolated: (module, places) => + onLinux + ? isolatedFile(own(), module, places ?? own().places, 'module') + : module, } } diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 000000000..a6c4ef0be --- /dev/null +++ b/test/preload.ts @@ -0,0 +1,12 @@ +import { afterAll } from 'bun:test' +import { + isolateManifestDirectoriesForTheRun, + removeManifestDirectoriesOfTheRun, +} from './helpers/private-manifest-directory.js' + +// Before any test file is loaded, so that no wrap and no clean-up of the run +// reaches the directories the user's own sandboxes keep their manifests in. A +// hook here belongs to the run and not to a file: the stand-ins go after the +// last test file. +isolateManifestDirectoriesForTheRun() +afterAll(removeManifestDirectoriesOfTheRun) diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index c6fd91384..2b4c181d6 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -9,6 +9,7 @@ import { isAtOrUnder, } from '../../src/sandbox/sandbox-utils.js' import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' +import { isolatedModule } from '../helpers/private-manifest-directory.js' const REPO_ROOT = join(import.meta.dir, '../..') const NPM_LOGS = '.npm/_logs' @@ -193,7 +194,11 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const { allowOnly, wrapped, reopened } = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + // The manager as a child is to load it: it wraps, and the directories + // the manifests are kept in are not this test's to reach. + `const { SandboxManager } = await import(${JSON.stringify( + isolatedModule(join(REPO_ROOT, 'src/sandbox/sandbox-manager.ts')), + )}) const filesystem = { denyRead: ['~/.npm'], allowWrite: [], denyWrite: [] } await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 98fa867d0..851f519b0 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -52,7 +52,7 @@ describe('the bwrap profile error at the package root', () => { * command line. */ describe.if(isLinux)('bwrap --args for over-long profiles', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const MAX_ARG_STRLEN = 32 * Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) // The largest rendering kept on the command line: the kernel's limit less @@ -170,8 +170,23 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { launcher: string[] = [], ): unknown { const files = overLongProfile() + // A tmpdir of its own, so what a scenario leaves there goes with BASE, + // and no runtime directory: the mount point manifests are then kept under + // that tmpdir, which is the arrangement several scenarios are about. The + // child's own tmpdir is what stands in for /tmp, and nothing for + // /run/user/UID. + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TMPDIR: join(BASE, 'tmp'), + ...env, + } + delete childEnv.XDG_RUNTIME_DIR + const library = runtime.isolated(MODULE, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: childEnv.TMPDIR, + }) const script = ` - import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, LinuxSandboxProfileError } from ${JSON.stringify(MODULE)} + import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, LinuxSandboxProfileError } from ${JSON.stringify(library)} import * as fs from 'node:fs' const overLong = ${JSON.stringify(files)} const small = overLong.slice(0, 1) @@ -197,16 +212,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // fit one argument itself. const scriptFile = join(BASE, 'isolated.ts') writeFileSync(scriptFile, script) - // A tmpdir of its own, so what a scenario leaves there goes with BASE, - // and no runtime directory: the mount point manifests are then kept under - // that tmpdir, which is the arrangement several scenarios are about. mkdirSync(join(BASE, 'tmp'), { recursive: true }) - const childEnv: NodeJS.ProcessEnv = { - ...process.env, - TMPDIR: join(BASE, 'tmp'), - ...env, - } - delete childEnv.XDG_RUNTIME_DIR const argv = [...launcher, process.execPath, 'run', scriptFile] const run = spawnSync(argv[0]!, argv.slice(1), { cwd: BASE, diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 878f71936..2ce6447ac 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -16,8 +16,12 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { liveMountPoints } from '../../src/index.js' +import { + type ManifestDirectoryPlaces, + setMountPointManifestPlacesForTesting, +} from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints, wrapCommandWithSandboxLinux, @@ -26,7 +30,10 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + inPrivateNamespace, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * A denyWrite path that does not exist is blocked by binding /dev/null over @@ -47,9 +54,12 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // assert on what is left: in a directory of their own. const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string // the allowed write area let LOCK: string // the denyWrite path, absent to begin with @@ -86,16 +96,20 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` } - /** A child process that wraps `command`, runs it and writes what it said. */ + /** + * A child process that wraps `command`, runs it and writes what it said, + * with the library as `library` names it. + */ function wrapperScript( command: string, out: string, after: string[] = [], + library = LIBRARY, ): string { return [ `import { spawnSync } from 'node:child_process'`, `import { writeFileSync } from 'node:fs'`, - `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${library}`, `const out = ${JSON.stringify(out)}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(command)},`, @@ -146,8 +160,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { * `writer`: a process that is gone, which makes it a finished one, unless * told otherwise. */ - function manifestNaming(paths: string[], writer?: number): string { - const dir = runtime.manifestDir() + function manifestNaming( + paths: string[], + writer?: number, + dir = runtime.manifestDir(), + ): string { mkdirSync(dir, { recursive: true, mode: 0o700 }) chmodSync(dir, 0o700) let pid = 4194000 @@ -361,6 +378,401 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + for (const differing of ['XDG_RUNTIME_DIR', 'TMPDIR'] as const) { + it.if(BWRAP_CAN_NAMESPACE)( + `stays while the sandbox of a process with another ${differing} runs, after the one it was made for has ended and been cleaned up after`, + async () => { + // Two processes as they really are, each working the directories out + // for itself from the real names, in a mount namespace where this + // test's stand-ins are what is at those names. They differ in what + // the environment says, as a login shell and what a service or an IDE + // starts do, and each kept its manifests where its own environment + // said: the second wrap took the first's mount point for the user's + // own file, and it went from under the second sandbox when the first + // process cleaned up. The two that differ in their temp dir have no + // runtime directory, and meet under /tmp. + const noRuntimeDirectory = join(BASE, 'run-user') + mkdirSync(noRuntimeDirectory) + const launcher = inPrivateNamespace( + differing === 'TMPDIR' + ? { + runtimeDir: join(noRuntimeDirectory, 'nobody'), + tempDir: runtime.places().tempDir, + } + : runtime.places(), + [BASE], + ) + const environment = (n: number): Record => { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + mkdirSync(own, { recursive: true }) + chmodSync(own, 0o700) + const env: Record = { + ...process.env, + XDG_RUNTIME_DIR: undefined, + TMPDIR: join(BASE, 'tmp'), + [differing]: own, + } + mkdirSync(env.TMPDIR!, { recursive: true }) + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete env[name] + } + return env as Record + } + const asItIs = ( + name: string, + command: string, + out: string, + after: string[], + ): string => + writeScript( + name, + wrapperScript(command, out, after, JSON.stringify(LIBRARY_PATH)), + ) + const started = (script: string, n: number): ChildProcess => + spawn( + launcher[0]!, + [...launcher.slice(1), process.execPath, script], + { + env: environment(n), + stdio: 'ignore', + }, + ) + + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const outSecond = join(BASE, 'out-second.txt') + const first = started( + asItIs( + 'first.ts', + `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done`, + OUT, + ['cleanupBwrapMountPoints()'], + ), + 1, + ) + const firstExited = new Promise(resolve => first.on('exit', resolve)) + let second: ChildProcess | undefined + try { + await waitFor(SIGNAL) + expect(lstatSync(LOCK).size).toBe(0) + + second = started( + asItIs( + 'second.ts', + `echo up > ${upSecond}; n=0; while [ ! -e ${goSecond} ]; do if (echo pwned > ${LOCK}) 2>/dev/null; then n=$((n+1)); fi; sleep 0.05; done; echo writes=$n`, + outSecond, + [], + ), + 2, + ) + await waitFor(upSecond) + + writeFileSync(GO, '') + await firstExited // and its own clean-up has run + // Past the grace, and with a third process cleaning up as well. + await sleep(700) + const third = spawnSync( + launcher[0]!, + [ + ...launcher.slice(1), + process.execPath, + asItIs('third.ts', 'true', join(BASE, 'out-third.txt'), [ + 'cleanupBwrapMountPoints()', + ]), + ], + { env: environment(3), encoding: 'utf8', timeout: 60000 }, + ) + expect(third.status).toBe(0) + expect(lstatSync(LOCK).size).toBe(0) + // For the command to have tried for a while longer. + await sleep(300) + } finally { + writeFileSync(GO, '') + writeFileSync(goSecond, '') + await firstExited + if (second !== undefined) { + const running = second + await new Promise(resolve => running.on('exit', resolve)) + } + } + + expect(readFileSync(OUT, 'utf8')).not.toContain('bwrap:') + expect(readFileSync(outSecond, 'utf8')).toBe('writes=0\n') + expect(contentAt(LOCK)).toBe('') + // Both wrote where the user id says, which is where this process + // looks too, and neither where its own environment says. + for (const n of [1, 2]) { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + for (const name of readdirSync(own)) { + expect(readdirSync(join(own, name))).toEqual([]) + } + } + + // And goes once nothing runs under it. + await sleep(600) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + } + + it('is taken for one on the word of a manifest in a directory this process does not write to', async () => { + // What a killed process that had no runtime directory left, and the + // manifest it left under /tmp. This process writes under the runtime + // directory, and reads both. + const theirs = join( + runtime.places().tempDir, + `srt-mount-points-${process.getuid!()}`, + ) + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const left = manifestNaming([LOCK], undefined, theirs) + + const command = await wrap('true') + expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) + expect(command).not.toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(dirname(manifestOf(command))).toBe(runtime.manifestDir()) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + + // It goes when this wrap is cleaned up after, and the manifest that named + // it first with it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(left)).toBe(false) + }) + + it('is not taken for one on the word of a manifest where the environment says, by a process that can use a directory that comes from the user id', async () => { + // What the environment names is out of reach of the sandboxes of the + // processes with that environment, and of no others. While this process + // has a directory every process of the user looks in, what is found + // there is nobody's word: the file is the user's own, empty and + // read-only as it is. + const xdg = join(BASE, 'xdg') + mkdirSync(xdg) + chmodSync(xdg, 0o700) + const theirs = join(xdg, 'srt-mount-points') + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const planted = manifestNaming([LOCK], undefined, theirs) + setMountPointManifestPlacesForTesting({ + ...runtime.places(), + environmentRuntimeDir: xdg, + }) + try { + const command = await wrap('true') + expect(command).toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(command).not.toContain(`--ro-bind /dev/null ${LOCK}`) + expect(command).not.toContain('--lock-file') + // Out of the command's reach all the same: a process with this + // environment that can use nothing else writes there. + expect(command).toContain(`--ro-bind-try ${theirs} ${theirs} `) + + cleanupBwrapMountPoints() + expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(planted)).toBe(true) + } finally { + cleanupBwrapMountPoints({ force: true }) + setMountPointManifestPlacesForTesting(runtime.places()) + } + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is not removed on the word of a manifest that the sandbox of a process with another temp dir wrote under this one', + () => { + // Two processes as they really are, in a mount namespace where this + // test's stand-ins are at the real names. The first has a temp dir of + // its own, inside what the second lets its command write. The second + // does not know of that temp dir, so its sandbox has nothing bound + // there, and its command writes a manifest under it that names + // somebody's empty read-only file. The first has a directory under + // /tmp to keep its manifests in, and believes nothing under its temp + // dir. + const uid = process.getuid!() + const HOME = join(BASE, 'home') + const theirTmp = join(HOME, 'tmp') + const theirs = join(theirTmp, `srt-mount-points-${uid}`) + const BYSTANDER = join(BASE, 'outside', 'bystander.lock') + mkdirSync(theirTmp, { recursive: true }) + mkdirSync(dirname(BYSTANDER)) + writeFileSync(BYSTANDER, '') + chmodSync(BYSTANDER, 0o444) + const launcher = inPrivateNamespace(runtime.places(), [BASE]) + const inTheNamespace = ( + name: string, + lines: string[], + env: Record, + ): { status: number | null; stdout: string } => { + const merged: Record = { + ...process.env, + XDG_RUNTIME_DIR: undefined, + TMPDIR: undefined, + ...env, + } + for (const [key, value] of Object.entries(merged)) { + if (value === undefined) delete merged[key] + } + return spawnSync( + launcher[0]!, + [ + ...launcher.slice(1), + process.execPath, + writeScript(name, lines.join('\n')), + ], + { + env: merged as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + } + const wrapping = ( + command: string, + allowOnly: string[], + deny: string[], + ): string[] => [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY_PATH)}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ${JSON.stringify(allowOnly)}, denyWithinAllow: ${JSON.stringify(deny)} },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ] + type Ran = { wrapped: string; said: string } + + // The first process wraps a command: it makes the directory under its + // temp dir, and writes under /tmp or the runtime directory. + const first = inTheNamespace( + 'first.ts', + wrapping('true', [AREA], [LOCK]), + { TMPDIR: theirTmp }, + ) + expect(first.status).toBe(0) + const firstRan = JSON.parse(first.stdout) as Ran + expect(firstRan.said).not.toContain('bwrap:') + expect(firstRan.wrapped).toContain(`--ro-bind-try ${theirs} ${theirs} `) + expect(firstRan.wrapped).not.toContain(`--lock-file ${theirs}/`) + expect(lstatSync(theirs).isDirectory()).toBe(true) + + // The command of the second, which may write the first's temp dir. + const forged = JSON.stringify({ + version: 1, + pid: 4194000, + start: '0', + created: 0, + paths: [BYSTANDER], + sources: [], + }) + const second = inTheNamespace( + 'second.ts', + wrapping( + `mkdir -p -m 700 ${theirs} 2>/dev/null; echo '${forged}' > ${theirs}/forged.json; echo plant=$?`, + [HOME], + [], + ), + {}, + ) + expect(second.status).toBe(0) + const secondRan = JSON.parse(second.stdout) as Ran + // Nothing of the second's sandbox is bound there. + expect(secondRan.wrapped).not.toContain(`-bind ${theirs} `) + expect(secondRan.wrapped).not.toContain(`-bind-try ${theirs} `) + expect(secondRan.said).toContain('plant=0') + expect(readFileSync(join(theirs, 'forged.json'), 'utf8')).toContain( + BYSTANDER, + ) + + // The next clean-up of a process with the first's environment. + const cleaner = inTheNamespace( + 'cleaner.ts', + [ + `import { cleanupBwrapMountPoints } from ${JSON.stringify(LIBRARY_PATH)}`, + `cleanupBwrapMountPoints()`, + ], + { TMPDIR: theirTmp }, + ) + expect(cleaner.status).toBe(0) + expect(lstatSync(BYSTANDER).size).toBe(0) + expect(existsSync(join(theirs, 'forged.json'))).toBe(true) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( + 'cannot be made, written or moved aside under /tmp by a command that may write /tmp, while its process writes under the runtime directory', + () => { + // The directory under /tmp is where a process without a runtime + // directory writes, and every process reads it. A command that may write + // /tmp runs as the same user: a directory it made there itself would + // pass every check of owner and mode, and what it put in it would be + // believed. So the wrap makes it first, and binds it read-only. + const uid = process.getuid!() + const theirs = `/tmp/srt-mount-points-${uid}` + const onTheHost = join(runtime.places().tempDir, basename(theirs)) + // Not there, as on a host where every process so far had a runtime + // directory to write to. + rmSync(onTheHost, { recursive: true, force: true }) + const attack = [ + `mkdir -m 700 ${theirs} 2>/dev/null; echo mkdir=$?`, + `echo forged > ${theirs}/planted.json; echo plant=$?`, + `mv ${theirs} /tmp/aside 2>/dev/null; echo mv=$?`, + `rmdir ${theirs} 2>/dev/null; echo rmdir=$?`, + ].join('; ') + const script = writeScript( + 'attacker.ts', + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY_PATH)}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(attack)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ['/tmp', ${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ].join('\n'), + ) + const env: Record = { ...process.env } + delete env.XDG_RUNTIME_DIR + delete env.TMPDIR + const launcher = inPrivateNamespace(runtime.places(), [BASE]) + const child = spawnSync( + launcher[0]!, + [...launcher.slice(1), process.execPath, script], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + expect(child.status).toBe(0) + const { wrapped, said } = JSON.parse(child.stdout) as { + wrapped: string + said: string + } + expect(wrapped).toContain( + `--lock-file /run/user/${uid}/srt-mount-points/`, + ) + expect(wrapped).toContain(`--ro-bind-try ${theirs} ${theirs} `) + expect(said).not.toContain('bwrap:') + expect(said).toMatch(/mkdir=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/mv=[1-9]/) + expect(said).toMatch(/rmdir=[1-9]/) + // Made by the wrap, the user's alone, and with nothing in it. + expect(lstatSync(onTheHost).isDirectory()).toBe(true) + expect(lstatSync(onTheHost).mode & 0o777).toBe(0o700) + expect(readdirSync(onTheHost)).toEqual([]) + expect(existsSync(join(runtime.places().tempDir, 'aside'))).toBe(false) + }, + 90000, + ) + it.if(BWRAP_CAN_NAMESPACE)( 'is collected, manifest and all, when the process that made it never cleans up', async () => { @@ -480,27 +892,36 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it.if(BWRAP_CAN_NAMESPACE)( - 'are read-only, too, where a process that was started without a runtime directory keeps them', + 'are read-only, too, in the directory under the temp dir, for a process that writes under the runtime directory', async () => { - // A process started with $XDG_RUNTIME_DIR keeps its manifests under it, - // and one started without (from cron, over a plain ssh) under the temp - // dir. A sandbox of the first kind that may write the temp dir could - // delete and forge the manifests of the second, which a collect on the - // host then believed. So every wrap binds both names read-only, and - // makes the one that is not there so that the command cannot make it - // first. - const RUN = join(BASE, 'run') + // A process with a runtime directory keeps its manifests under it, and + // one without (in a container, under another user's `su`) under the + // temp dir, and every process reads both. A sandbox of the first kind + // that may write the temp dir could delete and forge the manifests of + // the second, which a collect on the host then believed. So every wrap + // binds every name read-only, and makes the one that is not there so + // that the command cannot make it first. const TMP = join(BASE, 'tmp') - mkdirSync(RUN, { mode: 0o700 }) - chmodSync(RUN, 0o700) mkdirSync(TMP) const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + // The runtime directory this process has too, and TMP for /tmp. + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: runtime.places().runtimeDir, + tempDir: TMP, + }), + ) + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR const attacker = (script: string, attack: string): string => writeScript( script, [ `import { spawnSync } from 'node:child_process'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(attack)},`, ` needsNetworkRestriction: false,`, @@ -513,7 +934,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) const withARuntimeDirectory = (script: string) => spawnSync(process.execPath, [script], { - env: { ...process.env, XDG_RUNTIME_DIR: RUN, TMPDIR: TMP }, + env: env as Record, encoding: 'utf8', timeout: 60000, }) @@ -836,12 +1257,19 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", async () => { - // Neither shared name will do: no runtime directory, and the name under - // the temp dir is somebody's file. + // None of the shared names will do: no runtime directory, and the name + // under the temp dir, which is what stands in for /tmp too, is + // somebody's file. const TMP = join(BASE, 'tmp') mkdirSync(TMP) const squatted = join(TMP, `srt-mount-points-${process.getuid!()}`) writeFileSync(squatted, '') + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: TMP, + }), + ) const env: Record = { ...process.env, TMPDIR: TMP, @@ -856,7 +1284,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { [ `import { spawnSync } from 'node:child_process'`, `import { readdirSync } from 'node:fs'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, ` needsNetworkRestriction: false,`, @@ -971,11 +1399,15 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { describe.if(isLinux)( 'A mount point whose sandbox outlives the process that wrapped it', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) // Comfortably past the grace a new manifest is given (500 ms): after this // only the writer being alive, or the lock, can make the manifest live. const PAST_GRACE_MS = 1200 @@ -1374,9 +1806,13 @@ describe.if(isLinux)( () => { const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string let DIRECTORY: string // the first missing component of LEAF @@ -1756,16 +2192,32 @@ describe.if(isLinux)( describe.if(isLinux)( 'The directories above the ones this library keeps on the host', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) const UID = isLinux ? process.getuid!() : 0 let BASE: string let HOME: string // the allowed write root - let TMP: string // the temp dir, strictly inside it + let TMP: string // the temp dir, strictly inside it, and what is at /tmp let GUARD: string // a denyWrite path, absent to begin with + let LIBRARY: string // the library, for a child process + + /** + * The library for a child process that has no runtime directory and TMP + * at `/tmp`, or `places` instead of either. + */ + function libraryWith(places: Partial): string { + return JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: TMP, + ...places, + }), + ) + } beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pins-'))) @@ -1773,6 +2225,7 @@ describe.if(isLinux)( TMP = join(HOME, 'tmp') mkdirSync(TMP, { recursive: true }) GUARD = join(HOME, 'guard.lock') + LIBRARY = libraryWith({}) }) afterEach(() => { @@ -1781,8 +2234,9 @@ describe.if(isLinux)( }) /** - * Runs `lines` in a process of its own whose temp dir is TMP. An `env` - * entry that is undefined is one that process does not have. + * Runs `lines` in a process of its own whose temp dir is TMP, and which + * has no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is one that + * process does not have. */ function inAChild( name: string, @@ -1793,6 +2247,7 @@ describe.if(isLinux)( writeFileSync(file, lines.join('\n')) const merged: Record = { ...process.env, + XDG_RUNTIME_DIR: undefined, TMPDIR: TMP, ...env, } @@ -1809,15 +2264,19 @@ describe.if(isLinux)( return { status: r.status, stdout: r.stdout } } - /** What a process with that environment wraps `true` as. */ + /** + * What a process with that environment wraps `true` as, with `places` for + * where it looks. + */ function wrapped( options: Record, env: Record = {}, + places: Partial = {}, ): string { const child = inAChild( 'wrap.ts', [ - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${libraryWith(places)}`, `console.log(await wrapCommandWithSandboxLinux({`, ` command: 'true',`, ` needsNetworkRestriction: false,`, @@ -1848,10 +2307,9 @@ describe.if(isLinux)( } it('are pinned above the manifest directory a process with no runtime directory keeps', () => { - const command = wrapped( - { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const command = wrapped({ + writeConfig: { allowOnly: [HOME], denyWithinAllow: [] }, + }) expect(command).toContain(`--ro-bind-try ${TMP}/srt-mount-points-${UID}`) expectPinned(command, TMP) // The write root is a mount point already, and nothing above it is the @@ -1866,21 +2324,79 @@ describe.if(isLinux)( chmodSync(RUN, 0o700) const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, - { XDG_RUNTIME_DIR: RUN }, + {}, + { runtimeDir: RUN }, ) expect(command).toContain(`--lock-file ${RUN}/srt-mount-points/`) expectPinned(command, RUN) expectPinned(command, join(HOME, 'run')) }) + it('are pinned above every manifest directory, whichever of them the process writes to', () => { + // Four names for four directories, each strictly inside the write + // root: the runtime directory and /tmp, and what the environment has + // for the two. The process writes to the first, and a process of the + // user that writes to one of the others believes what it finds there, + // so the command is kept out of all of them: each is made, bound + // read-only where it is, and what lies above it pinned. + const at = (name: string): string => { + const dir = join(HOME, name, 'deeper') + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + const runtimeDir = at('run-user') + const tempDir = at('tmp-root') + const XDG_RUNTIME_DIR = at('xdg') + const TMPDIR = at('tmpdir') + const every = [ + join(runtimeDir, 'srt-mount-points'), + join(tempDir, `srt-mount-points-${UID}`), + join(XDG_RUNTIME_DIR, 'srt-mount-points'), + join(TMPDIR, `srt-mount-points-${UID}`), + ] + + const recording = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR, TMPDIR }, + { runtimeDir, tempDir }, + ) + expect(recording).toContain(`--lock-file ${every[0]}/`) + expect(countMounts(recording, '--ro-bind', every[0]!, every[0]!)).toBe(1) + for (const dir of every.slice(1)) { + expect(recording).toContain(`--ro-bind-try ${dir} ${dir} `) + } + for (const dir of every) { + expect(lstatSync(dir).isDirectory()).toBe(true) + expect(lstatSync(dir).mode & 0o777).toBe(0o700) + expectPinned(recording, dirname(dir)) + expectPinned(recording, dirname(dirname(dir))) + } + // Nothing was written to any but the first. + for (const dir of every.slice(1)) { + expect(readdirSync(dir)).toEqual([]) + } + + // A wrap with nothing to record writes nowhere, and binds all four. + const other = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR, TMPDIR }, + { runtimeDir, tempDir }, + ) + expect(other).not.toContain('--lock-file') + for (const dir of every) { + expect(other).toContain(`--ro-bind-try ${dir} ${dir} `) + expectPinned(other, dirname(dir)) + } + }) + it('are pinned above the directory of its own a process settles on as it records', () => { // Neither shared name will do, so the wrap makes a directory only this // process knows, and makes it as it publishes: after the pins. takeTheSharedName() - const command = wrapped( - { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const command = wrapped({ + writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] }, + }) expect(command).toMatch( new RegExp(`--lock-file ${TMP}/srt-mount-points-[A-Za-z0-9]{6}/`), ) @@ -1891,12 +2407,17 @@ describe.if(isLinux)( // The manifests are out of the way, under a runtime directory outside // the write root: the temp dir is pinned for the source alone. takeTheSharedName() - const command = wrapped({ - writeConfig: { - allowOnly: [HOME], - denyWithinAllow: [join(HOME, 'absent', 'leaf')], + const command = wrapped( + { + writeConfig: { + allowOnly: [HOME], + denyWithinAllow: [join(HOME, 'absent', 'leaf')], + }, }, - }) + {}, + { runtimeDir: runtime.places().runtimeDir }, + ) + expect(command).toContain(`--lock-file ${runtime.manifestDir()}/`) expect(command).toMatch( new RegExp(`--ro-bind ${TMP}/claude-empty-\\S+ ${HOME}/absent `), ) @@ -1937,26 +2458,25 @@ describe.if(isLinux)( symlinkSync('real-tmp', through) const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined, TMPDIR: through }, + { TMPDIR: through }, + { tempDir: through }, ) expectPinned(command, real) expect(countMounts(command, '--ro-bind', through, through)).toBe(0) }) it('are not pinned where the temp dir is itself the write root, or outside every one', () => { - const inside = wrapped( - { writeConfig: { allowOnly: [TMP], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const inside = wrapped({ + writeConfig: { allowOnly: [TMP], denyWithinAllow: [] }, + }) expect(countMounts(inside, '--bind', TMP, TMP)).toBe(1) expect(countMounts(inside, '--ro-bind', TMP, TMP)).toBe(0) const AREA = join(BASE, 'area') mkdirSync(AREA) - const outside = wrapped( - { writeConfig: { allowOnly: [AREA], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const outside = wrapped({ + writeConfig: { allowOnly: [AREA], denyWithinAllow: [] }, + }) expect(countMounts(outside, '--ro-bind', TMP, TMP)).toBe(0) expect(countMounts(outside, '--ro-bind', HOME, HOME)).toBe(0) }) @@ -2020,14 +2540,10 @@ describe.if(isLinux)( } expect(lstatSync(GUARD).size).toBe(0) - const collector = inAChild( - 'collector.ts', - [ - `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - `cleanupBwrapMountPoints()`, - ], - { XDG_RUNTIME_DIR: undefined }, - ) + const collector = inAChild('collector.ts', [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ]) expect(collector.status).toBe(0) expect(existsSync(GUARD)).toBe(true) } finally { @@ -2060,10 +2576,11 @@ describe.if(isLinux)( describe.if(isLinux)( 'The manifest directory under a temp dir whose name is a link', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -2083,9 +2600,9 @@ describe.if(isLinux)( }) /** - * What a process whose temp dir is `through`, and which has no runtime - * directory, wraps `command` as with that deny list, and what the command - * said when run. + * What a process whose temp dir is `through`, as `/tmp` is, and which has + * no runtime directory, wraps `command` as with that deny list, and what + * the command said when run. */ function wrappedAndRun( through: string, @@ -2094,11 +2611,17 @@ describe.if(isLinux)( store?: string, ): { wrapped: string; status: number | null; said: string } { const file = join(BASE, 'wrap-and-run.ts') + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: through, + }), + ) writeFileSync( file, [ `import { spawnSync } from 'node:child_process'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(command)},`, ` needsNetworkRestriction: false,`, @@ -2265,7 +2788,7 @@ describe.if(isLinux)( describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() // A second PID namespace with a /proc of its own, uid unchanged, no root. const IN_NEW_PID_NAMESPACE = [ 'unshare', @@ -2281,9 +2804,13 @@ describe.if(isLinux)( ...IN_NEW_PID_NAMESPACE.slice(1), 'true', ]).status === 0 - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string let LOCK: string diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 58fa38c8a..c7591ade0 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import * as fs from 'node:fs' import { accessSync, @@ -13,6 +13,7 @@ import { readdirSync, readlinkSync, realpathSync, + renameSync, rmSync, statSync, statfsSync, @@ -21,12 +22,15 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { + type ManifestDirectoryPlaces, collectMountPoints, liveMountPoints, + mountPointManifestDirectories, namedMountPoints, publishMountPointManifest, + setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' @@ -40,8 +44,9 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() - const MODULE = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/bwrap-mount-manifests.ts'), + const MODULE = join( + import.meta.dir, + '../../src/sandbox/bwrap-mount-manifests.ts', ) const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), @@ -55,6 +60,7 @@ describe.if(isLinux)('The mount point manifests', () => { let LOCK: string // the directory's lock let X: string // a mount point an earlier sandbox left let TMP: string // the temp dir of every child process + let THEIRS: string // the directory under /tmp, which this process only reads beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-manifests-'))) @@ -65,9 +71,19 @@ describe.if(isLinux)('The mount point manifests', () => { chmodSync(DIR, 0o700) LOCK = join(DIR, 'directory.lock') X = join(BASE, 'config.lock') + THEIRS = join( + runtime.places().tempDir, + `srt-mount-points-${process.getuid!()}`, + ) }) afterEach(() => { + try { + chmodSync(THEIRS, 0o700) + } catch { + // Not made by this test. + } + rmSync(THEIRS, { recursive: true, force: true }) for (const name of readdirSync(DIR)) { const file = join(DIR, name) try { @@ -102,9 +118,10 @@ describe.if(isLinux)('The mount point manifests', () => { function manifestOfADeadProcess( paths: string[], fields: Record = {}, + dir = DIR, ): string { const pid = deadPid() - const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) + const file = join(dir, `${pid}-${Math.random().toString(16).slice(2)}.json`) const body: Record = { version: 1, pid, @@ -128,6 +145,12 @@ describe.if(isLinux)('The mount point manifests', () => { * the thread it happens on, so a test in this process could not time it out. * `first` runs before the module is loaded, and an `env` entry that is * undefined is one the child does not have. + * + * The child looks where this process does for the runtime directory, and + * has a temp dir of its own, which is also what stands in for `/tmp`: it is + * where the manifests go when the runtime directory will not do, and the + * real one is every other process's. `places` puts something else in the + * place of either. It has no `XDG_RUNTIME_DIR` unless `env` gives it one. */ function inAChild( source: string, @@ -135,32 +158,60 @@ describe.if(isLinux)('The mount point manifests', () => { launcher?: string[] env?: Record first?: string + places?: Partial } = {}, ): { status: number | null; ms: number; stdout: string } { - const script = join(BASE, `child-${Math.random().toString(16).slice(2)}.ts`) + const script = writeChild( + `child-${Math.random().toString(16).slice(2)}.ts`, + source, + options.places, + options.first, + ) + const argv = [...(options.launcher ?? []), process.execPath, script] + const began = Date.now() + const run = spawnSync(argv[0]!, argv.slice(1), { + env: childEnv(options.env), + encoding: 'utf8', + timeout: 6000, + killSignal: 'SIGKILL', + }) + return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + } + + /** The script {@link inAChild} runs, for a child that is started otherwise. */ + function writeChild( + name: string, + source: string, + places: Partial = {}, + first = '', + ): string { + const script = join(BASE, name) + const module = runtime.isolated(MODULE, { + runtimeDir: runtime.places().runtimeDir, + tempDir: TMP, + ...places, + }) writeFileSync( script, - `${options.first ?? ''}\nconst m = await import(${MODULE})\n${source}\n`, + `${first}\nconst m = await import(${JSON.stringify(module)})\n${source}\n`, ) - const argv = [...(options.launcher ?? []), process.execPath, script] - // A temp dir of its own: it is where the manifests go when the runtime - // directory will not do, and the real one is every other process's. + return script + } + + /** The environment {@link inAChild} gives its child. */ + function childEnv( + given: Record = {}, + ): Record { const env: Record = { ...process.env, + XDG_RUNTIME_DIR: undefined, TMPDIR: TMP, - ...options.env, + ...given, } for (const [name, value] of Object.entries(env)) { if (value === undefined) delete env[name] } - const began = Date.now() - const run = spawnSync(argv[0]!, argv.slice(1), { - env: env as Record, - encoding: 'utf8', - timeout: 6000, - killSignal: 'SIGKILL', - }) - return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + return env as Record } const COLLECT = `m.collectMountPoints()` @@ -865,9 +916,13 @@ describe.if(isLinux)('The mount point manifests', () => { const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) chmodSync(shm, 0o700) try { + // Whichever place is under /dev is passed over, the first as much as + // the last. const elsewhere = TMP + const underDev = { runtimeDir: shm, tempDir: shm } const fellBack = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: shm, TMPDIR: elsewhere }, + places: underDev, }) expect(fellBack.status).toBe(0) const manifest = JSON.parse(fellBack.stdout) as { file: string } @@ -876,14 +931,9 @@ describe.if(isLinux)('The mount point manifests', () => { // With nowhere else to go it records nothing, rather than somewhere // bubblewrap cannot reach. - const env: Record = { ...process.env, TMPDIR: shm } - delete env.XDG_RUNTIME_DIR - const script = join(BASE, 'nowhere.ts') - writeFileSync(script, `const m = await import(${MODULE})\n${PUBLISH}\n`) - const nowhere = spawnSync(process.execPath, [script], { - env, - encoding: 'utf8', - timeout: 6000, + const nowhere = inAChild(PUBLISH, { + env: { XDG_RUNTIME_DIR: shm, TMPDIR: shm }, + places: underDev, }) expect(nowhere.status).toBe(0) expect(JSON.parse(nowhere.stdout)).toBe(null) @@ -903,7 +953,9 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(target) chmodSync(target, 0o755) symlinkSync(target, join(TMP, `srt-mount-points-${process.getuid!()}`)) - const published = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: undefined } }) + const published = inAChild(PUBLISH, { + places: { runtimeDir: join(BASE, 'no-runtime-directory') }, + }) expect(published.status).toBe(0) expect(statSync(target).mode & 0o777).toBe(0o755) expect(readdirSync(target)).toEqual([]) @@ -942,6 +994,618 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) + // ---- which directory is written to, and which are read ----------------- + // + // Where the manifests went used to be the environment's to say: under + // $XDG_RUNTIME_DIR, else under $TMPDIR. Two processes of one user that + // differed in either kept their manifests apart, and neither knew of the + // sandboxes of the other. The user id and the file system say it first + // now, the same for every process, and what they name is read by every + // process. + + /** + * Four places of their own under `base`, none of them with a manifest + * directory yet. + */ + function fourPlaces(base = BASE): { + places: { runtimeDir: string; tempDir: string } + env: { XDG_RUNTIME_DIR: string; TMPDIR: string } + /** The four manifest directories, in the order they are tried. */ + dirs: [string, string, string, string] + } { + const at = (name: string, mode: number): string => { + const dir = join(base, name) + mkdirSync(dir, { recursive: true }) + chmodSync(dir, mode) + return dir + } + const places = { + runtimeDir: at('run-user', 0o700), + tempDir: at('tmp-root', 0o1777), + } + const env = { + XDG_RUNTIME_DIR: at('xdg', 0o700), + TMPDIR: at('tmpdir', 0o1777), + } + const perUser = `srt-mount-points-${process.getuid!()}` + return { + places, + env, + dirs: [ + join(places.runtimeDir, 'srt-mount-points'), + join(places.tempDir, perUser), + join(env.XDG_RUNTIME_DIR, 'srt-mount-points'), + join(env.TMPDIR, perUser), + ], + } + } + + /** A manifest directory as the library makes one. */ + function manifestDirectory(dir: string): string { + mkdirSync(dir, { mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + + /** What makes a manifest live for as long as this test runs: its writer. */ + function ofThisProcess(): Record { + return { + pid: process.pid, + start: readFileSync('/proc/self/stat', 'utf8') + .split(') ') + .pop()! + .split(' ')[19], + } + } + + const LOOK = `console.log(JSON.stringify({ named: [...m.namedMountPoints().named].sort(), live: [...m.namedMountPoints().live].sort(), spared: [...m.liveMountPoints()].sort(), collected: m.collectMountPoints().sort() }))` + type Looked = { + named: string[] + live: string[] + spared: string[] + collected: string[] + } + + it('are written to the first directory that can be used: under the runtime directory, under /tmp, and only then where the environment says', () => { + // Every combination of the four being usable or not. One that is not is + // somebody's file at the name. + for (let usable = 0; usable < 16; usable++) { + const four = fourPlaces(join(BASE, `order-${usable}`)) + four.dirs.forEach((dir, index) => { + if ((usable & (1 << index)) === 0) writeFileSync(dir, '') + }) + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect(published.status).toBe(0) + const written = dirname( + (JSON.parse(published.stdout) as { file: string }).file, + ) + const first = four.dirs.find((_, index) => (usable & (1 << index)) !== 0) + if (first !== undefined) { + expect({ usable, written }).toEqual({ usable, written: first }) + } else { + // A directory of the process's own, under its temp dir. + expect(written).toMatch( + new RegExp(`^${four.env.TMPDIR}/srt-mount-points-[A-Za-z0-9]{6}$`), + ) + } + } + }, 60000) + + it('are one directory where two names lead to the same one', () => { + // What the environment says is usually the runtime directory and /tmp + // again, by those names or by others. + const four = fourPlaces() + const runtimeDir = join(BASE, 'runtime-by-another-name') + const tempDir = join(BASE, 'tmp-by-another-name') + symlinkSync(four.places.runtimeDir, runtimeDir) + symlinkSync(four.places.tempDir, tempDir) + const looked = inAChild( + `console.log(JSON.stringify(m.mountPointManifestDirectories()))`, + { + env: { XDG_RUNTIME_DIR: runtimeDir, TMPDIR: tempDir }, + places: four.places, + }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual(four.dirs.slice(0, 2)) + }) + + const notARuntimeDirectory: [string, (dir: string) => void][] = [ + ['is not there', dir => rmSync(dir, { recursive: true })], + [ + 'is a link to one', + dir => { + renameSync(dir, join(BASE, 'linked-to')) + symlinkSync(join(BASE, 'linked-to'), dir) + }, + ], + ['can be looked into by the group', dir => chmodSync(dir, 0o750)], + ['can be looked into by others', dir => chmodSync(dir, 0o705)], + ] + for (const [what, spoil] of notARuntimeDirectory) { + it(`are neither written nor read, nor is anything made, under a runtime directory that ${what}`, () => { + const four = fourPlaces() + // What is there for this user to find, with a manifest in it that would + // keep X. + const planted = join(four.places.runtimeDir, 'srt-mount-points') + manifestDirectory(planted) + leftover(X) + manifestOfADeadProcess([X], ofThisProcess(), planted) + spoil(four.places.runtimeDir) + const before = existsSync(planted) ? readdirSync(planted) : undefined + + const published = inAChild(PUBLISH, { + env: { TMPDIR: four.env.TMPDIR }, + places: four.places, + }) + expect(published.status).toBe(0) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(four.dirs[1]) + const looked = inAChild( + [ + `const directories = m.mountPointManifestDirectories()`, + `const named = [...m.namedMountPoints().named]`, + `console.log(JSON.stringify({ directories, named: named.filter(p => p === ${JSON.stringify(X)}), spared: [...m.liveMountPoints()] }))`, + ].join('\n'), + { env: { TMPDIR: four.env.TMPDIR }, places: four.places }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ + directories: [four.dirs[1], four.dirs[3]], + named: [], + spared: [], + }) + expect(existsSync(planted) ? readdirSync(planted) : undefined).toEqual( + before, + ) + }, 15000) + } + + /** + * Has `dir` look like another user's to whoever asks: nothing here can make + * a directory that is. + */ + function asSomebodyElses(dir: string): { restore(): void } { + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file !== dir) return stat + const other = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(other, stat, { uid: stat.uid + 1 }) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it("are neither written nor read under a runtime directory that is somebody else's", () => { + const four = fourPlaces() + const planted = manifestDirectory( + join(four.places.runtimeDir, 'srt-mount-points'), + ) + leftover(X) + manifestOfADeadProcess([X], ofThisProcess(), planted) + const theirs = asSomebodyElses(four.places.runtimeDir) + setMountPointManifestPlacesForTesting({ + ...four.places, + environmentRuntimeDir: undefined, + environmentTempDir: four.env.TMPDIR, + }) + try { + expect(mountPointManifestDirectories()).toEqual([ + four.dirs[1], + four.dirs[3], + ]) + expect( + dirname(publishMountPointManifest([join(BASE, 'absent')], [])!.file), + ).toBe(four.dirs[1]) + expect([...namedMountPoints().named]).toEqual([join(BASE, 'absent')]) + expect([...liveMountPoints()]).toEqual([]) + expect(readdirSync(planted).length).toBe(1) + } finally { + theirs.restore() + collectMountPoints() + setMountPointManifestPlacesForTesting(runtime.places()) + } + }) + + for (const differing of ['XDG_RUNTIME_DIR', 'TMPDIR'] as const) { + it(`are read by a process whose ${differing} is not the writer's`, async () => { + // A login shell and what a service or an IDE starts, two shells with a + // temp dir each: the second has to find what the first wrote. + const four = fourPlaces() + const environment = (n: number): Record => { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + mkdirSync(own, { recursive: true }) + chmodSync(own, 0o700) + return differing === 'XDG_RUNTIME_DIR' + ? { XDG_RUNTIME_DIR: own, TMPDIR: four.env.TMPDIR } + : { XDG_RUNTIME_DIR: undefined, TMPDIR: own } + } + leftover(X) + const go = join(BASE, 'go') + const up = join(BASE, 'up') + // The first process wraps, and its sandbox runs for as long as it is + // not told to go: its manifest is live for being its own. + const writer = spawn( + process.execPath, + [ + writeChild( + 'writer.ts', + [ + `import { existsSync, writeFileSync } from 'node:fs'`, + `m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + `writeFileSync(${JSON.stringify(up)}, '')`, + `while (!existsSync(${JSON.stringify(go)})) await new Promise(resolve => setTimeout(resolve, 20))`, + ].join('\n'), + four.places, + ), + ], + { env: childEnv(environment(1)), stdio: 'ignore' }, + ) + const writerExited = new Promise(resolve => writer.on('exit', resolve)) + try { + const deadline = Date.now() + 10000 + while (!existsSync(up)) { + if (Date.now() > deadline) throw new Error('the writer never wrote') + await new Promise(resolve => setTimeout(resolve, 20)) + } + const second = inAChild(LOOK, { + env: environment(2), + places: four.places, + }) + expect(second.status).toBe(0) + expect(JSON.parse(second.stdout) as Looked).toEqual({ + named: [X], + live: [X], + spared: [X], + collected: [], + }) + expect(existsSync(X)).toBe(true) + } finally { + writeFileSync(go, '') + await writerExited + } + + // And once the first is gone, and past its grace, the second is the one + // that takes away what it left. + await new Promise(resolve => setTimeout(resolve, 600)) + const after = inAChild(LOOK, { + env: environment(2), + places: four.places, + }) + expect(JSON.parse(after.stdout) as Looked).toEqual({ + named: [X], + live: [], + spared: [], + collected: [X], + }) + expect(existsSync(X)).toBe(false) + for (const dir of four.dirs) { + if (existsSync(dir)) { + expect(readdirSync(dir).filter(n => n.endsWith('.json'))).toEqual([]) + } + } + }, 30000) + } + + it('are believed in a directory this process does not write to', () => { + // This process writes under the runtime directory. What a process + // without one wrote under /tmp counts all the same: for a wrap that finds + // something at a path, and for a clean-up that is about to remove it. + const theirs = manifestDirectory(THEIRS) + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const mine = manifestOfADeadProcess([X, Y]) + const live = manifestOfADeadProcess([X], ofThisProcess(), theirs) + const finished = manifestOfADeadProcess([Y], {}, theirs) + + const named = namedMountPoints() + expect([...named.named].sort()).toEqual([X, Y].sort()) + expect([...named.live]).toEqual([X]) + expect([...liveMountPoints()]).toEqual([X]) + + // What only finished manifests name goes, and they go with it, wherever + // they are; what a live one names stays, wherever that one is. + expect(collectMountPoints()).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(live)).toBe(true) + expect(existsSync(finished)).toBe(false) + expect(existsSync(mine)).toBe(false) + expect(readdirSync(theirs)).toEqual([basename(live)]) + + rmSync(live) + manifestOfADeadProcess([X], {}, theirs) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(theirs)).toEqual([]) + // And all the while this process wrote under the runtime directory. + expect( + dirname(publishMountPointManifest([join(BASE, 'absent')], [])!.file), + ).toBe(DIR) + }) + + it('keeps a mount point named by a manifest that is published in another directory while the pass is under way', () => { + const theirs = manifestDirectory(THEIRS) + leftover(X) + manifestOfADeadProcess([X]) + let arrived: string | undefined + const pass = duringTheNextPass(() => { + arrived = manifestOfADeadProcess([X], { created: Date.now() }, theirs) + }) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(arrived).toBeDefined() + expect(existsSync(X)).toBe(true) + }) + + it('keeps what a finished manifest names when the lock on a manifest in another directory shows on the look before the removals', () => { + const theirs = manifestDirectory(THEIRS) + leftover(X) + manifestOfADeadProcess([X]) + const locked = manifestOfADeadProcess([X], {}, theirs) + const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(locked)}:${statSync(locked).ino} 0 EOF\n` + const readFile = fs.readFileSync + let reads = 0 + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks') { + reads++ + return reads === 1 ? '' : line + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(locked)).toBe(true) + }) + + const notOurs: [string, (dir: string) => { restore(): void } | void][] = [ + [ + 'is a link to a directory', + dir => { + const target = join(BASE, 'linked-to') + renameSync(dir, target) + symlinkSync(target, dir) + }, + ], + ['can be written by others', dir => chmodSync(dir, 0o777)], + ['can be looked into by the group', dir => chmodSync(dir, 0o750)], + ["is somebody else's", asSomebodyElses], + ] + for (const [what, spoil] of notOurs) { + it(`are not believed in a directory that ${what}, which is left as it is`, () => { + const theirs = manifestDirectory(THEIRS) + const Y = join(BASE, 'second.lock') + const Z = join(BASE, 'third.lock') + leftover(X) + leftover(Y) + leftover(Z) + // One that would keep X, and one that would have Y removed. + manifestOfADeadProcess([X], ofThisProcess(), theirs) + manifestOfADeadProcess([Y], {}, theirs) + manifestOfADeadProcess([X, Z]) + const spoilt = spoil(theirs) + try { + const before = readdirSync(theirs).sort() + const mode = statSync(theirs).mode & 0o777 + + const named = namedMountPoints() + expect([...named.named].sort()).toEqual([X, Z].sort()) + expect([...named.live]).toEqual([]) + expect([...liveMountPoints()]).toEqual([]) + expect(collectMountPoints().sort()).toEqual([X, Z].sort()) + expect(existsSync(Y)).toBe(true) + + expect(readdirSync(theirs).sort()).toEqual(before) + expect(statSync(theirs).mode & 0o777).toBe(mode) + } finally { + spoilt?.restore() + } + }) + } + + it('are read in both directories that come from the user id, in none the environment names beside them, and none is made for it', () => { + const four = fourPlaces() + const paths = four.dirs.map((dir, index) => { + const held = join(BASE, `held-${index}.lock`) + leftover(held) + // The last is not there, and is not made. + if (index < 3) { + manifestOfADeadProcess([held], ofThisProcess(), manifestDirectory(dir)) + } + return held + }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()].sort()))`, + { env: four.env, places: four.places }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual(paths.slice(0, 2).sort()) + expect(existsSync(four.dirs[3])).toBe(false) + for (const dir of four.dirs.slice(0, 3)) { + expect(readdirSync(dir).length).toBe(1) + } + }) + + // What the environment names is kept out of a sandboxed command's reach by + // the processes whose environment names it, and by no other: a process with + // another temp dir has its sandboxes write there, if its caller lets them. + // So a manifest found there is somebody's word only to a process that has + // nowhere else to keep its own. + + /** + * In each directory the environment names: the manifest of a sandbox that + * is running, which would keep X, and one a dead process left, which would + * have Y removed. + */ + function whereTheEnvironmentSays( + four: ReturnType, + Y: string, + ): string[] { + return four.dirs.slice(2).flatMap(dir => { + manifestDirectory(dir) + return [ + manifestOfADeadProcess([X], ofThisProcess(), dir), + manifestOfADeadProcess([Y], {}, dir), + ] + }) + } + + for (const usable of ['the runtime directory', '/tmp'] as const) { + it(`are not believed where the environment says, by a process that can use the directory under ${usable}`, () => { + const four = fourPlaces() + if (usable === '/tmp') rmSync(four.places.runtimeDir, { recursive: true }) + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const planted = whereTheEnvironmentSays(four, Y) + // What a process of the user left, where every process looks. + const finished = manifestOfADeadProcess( + [X], + {}, + manifestDirectory(four.dirs[1]), + ) + + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(usable === '/tmp' ? four.dirs[1] : four.dirs[0]) + rmSync((JSON.parse(published.stdout) as { file: string }).file) + + const looked = inAChild(LOOK, { env: four.env, places: four.places }) + expect(looked.status).toBe(0) + // X is kept by nothing that is believed, and goes on the word of the + // one manifest that is; Y is named by nothing that is believed, and + // stays. + expect(JSON.parse(looked.stdout) as Looked).toEqual({ + named: [X], + live: [], + spared: [], + collected: [X], + }) + expect(existsSync(X)).toBe(false) + expect(existsSync(Y)).toBe(true) + expect(existsSync(finished)).toBe(false) + for (const manifest of planted) { + expect(existsSync(manifest)).toBe(true) + } + }, 15000) + } + + it('are believed where the environment says, and collected there, by a process that can use no directory that comes from the user id', () => { + const four = fourPlaces() + rmSync(four.places.runtimeDir, { recursive: true }) + writeFileSync(four.dirs[1], '') + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const planted = whereTheEnvironmentSays(four, Y) + + const looked = inAChild(LOOK, { env: four.env, places: four.places }) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout) as Looked).toEqual({ + named: [X, Y].sort(), + live: [X], + spared: [X], + collected: [Y], + }) + expect(existsSync(X)).toBe(true) + expect(existsSync(Y)).toBe(false) + // The ones that named X for a running sandbox stay, in both; the ones a + // dead process left are gone, from both. + expect(planted.map(manifest => existsSync(manifest))).toEqual([ + true, + false, + true, + false, + ]) + + // And it is where this process writes: to the first of the two. + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(four.dirs[2]) + }, 15000) + + it('are believed and collected in the directory a process settled on where the environment says, after one that comes from the user id has become usable', () => { + // Nothing that comes from the user id will do when the process first + // records, so it settles where its environment says, and stays there: + // its manifests are there, and the sandboxes that hold their locks. + const four = fourPlaces() + rmSync(four.places.runtimeDir, { recursive: true }) + writeFileSync(four.dirs[1], '') + const [settled, other] = [four.dirs[2], four.dirs[3]] + const Y = join(BASE, 'second.lock') + const Z = join(BASE, 'third.lock') + leftover(X) + leftover(Y) + leftover(Z) + manifestOfADeadProcess([Y], {}, manifestDirectory(settled)) + const notBelieved = manifestOfADeadProcess( + [Z], + {}, + manifestDirectory(other), + ) + + const ran = inAChild( + [ + `import { rmSync } from 'node:fs'`, + `import { dirname } from 'node:path'`, + `const first = m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + // Somebody's file at the name under /tmp goes, and the next wrap + // makes the directory. + `rmSync(${JSON.stringify(four.dirs[1])})`, + `const directories = m.mountPointManifestDirectories()`, + `const named = m.namedMountPoints()`, + `const second = m.publishMountPointManifest([${JSON.stringify(join(BASE, 'absent'))}], [])`, + `const collected = m.collectMountPoints().sort()`, + `console.log(JSON.stringify({ first: dirname(first.file), second: dirname(second.file), directories, named: [...named.named].sort(), live: [...named.live], collected }))`, + ].join('\n'), + { env: four.env, places: four.places }, + ) + expect(ran.status).toBe(0) + expect(JSON.parse(ran.stdout)).toEqual({ + first: settled, + second: settled, + directories: [four.dirs[1], settled, other], + named: [X, Y].sort(), + live: [X], + collected: [X, Y].sort(), + }) + expect(existsSync(X)).toBe(false) + expect(existsSync(Y)).toBe(false) + expect(readdirSync(settled).filter(n => n.endsWith('.json'))).toEqual([]) + // What the environment names beside it is nobody's word any more. + expect(existsSync(Z)).toBe(true) + expect(existsSync(notBelieved)).toBe(true) + }, 15000) + // ---- what is read as a manifest ---------------------------------------- // // Whatever ends in `.json` in the directory used to be opened as it stood: @@ -1266,20 +1930,34 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(DIR).sort()).toEqual(before) }) + /** A runtime directory with nothing in it yet. */ + function emptyRuntimeDirectory(name: string): string { + const dir = join(BASE, name) + mkdirSync(dir, { mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + it('makes no manifest directory where there is none, and holds nothing then', () => { - const run = join(BASE, 'run') - mkdirSync(run, { mode: 0o700 }) + const run = emptyRuntimeDirectory('run') + const xdg = emptyRuntimeDirectory('xdg') + const tmp = join(BASE, 'tmp-root') + mkdirSync(tmp) const looked = inAChild( `console.log(JSON.stringify([...m.liveMountPoints()]))`, - { env: { XDG_RUNTIME_DIR: run } }, + { + env: { XDG_RUNTIME_DIR: xdg }, + places: { runtimeDir: run, tempDir: tmp }, + }, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual([]) - expect(readdirSync(run)).toEqual([]) - expect(readdirSync(TMP)).toEqual([]) + for (const place of [run, xdg, tmp, TMP]) { + expect(readdirSync(place)).toEqual([]) + } }) - it('reads the directory a process without a runtime directory keeps, where that is the one there is', () => { + it('reads the directory under the temp dir, where that is the one there is', () => { const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) mkdirSync(theirs, { mode: 0o700 }) chmodSync(theirs, 0o700) @@ -1297,11 +1975,10 @@ describe.if(isLinux)('The mount point manifests', () => { { mode: 0o600 }, ) rmSync(manifest) - const run = join(BASE, 'run') - mkdirSync(run, { mode: 0o700 }) + const run = emptyRuntimeDirectory('run') const looked = inAChild( `console.log(JSON.stringify([...m.liveMountPoints()]))`, - { env: { XDG_RUNTIME_DIR: run } }, + { places: { runtimeDir: run } }, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual([X]) diff --git a/test/sandbox/path-canonicalization.test.ts b/test/sandbox/path-canonicalization.test.ts index 20e676cc0..9ff5d1c49 100644 --- a/test/sandbox/path-canonicalization.test.ts +++ b/test/sandbox/path-canonicalization.test.ts @@ -3,9 +3,11 @@ import { spawnSync } from 'node:child_process' import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import { normalizePathForSandbox } from '../../src/sandbox/sandbox-utils.js' import { isWindows } from '../helpers/platform.js' +import { isolatedModule } from '../helpers/private-manifest-directory.js' /** * Interior non-canonical spellings. @@ -47,9 +49,14 @@ function normalizeWithHome( ): { homedir: string; results: string[]; tildeBindsRoot: boolean } { const module = new URL('../../src/sandbox/sandbox-utils.ts', import.meta.url) .href - const linuxModule = new URL( - '../../src/sandbox/linux-sandbox-utils.ts', - import.meta.url, + // The wrap makes and binds the directories the mount point manifests are + // kept in, which are not this test's to reach. + const linuxModule = pathToFileURL( + isolatedModule( + fileURLToPath( + new URL('../../src/sandbox/linux-sandbox-utils.ts', import.meta.url), + ), + ), ).href const child = spawnSync( process.execPath, diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 7f3d3e091..ec65c6f8a 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -488,7 +488,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + runtime.isolated( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ), )}`, `const command = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, @@ -542,7 +544,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { describe.if(isLinux)( 'Mount points for deny paths that do not exist yet', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area @@ -761,7 +763,9 @@ describe.if(isLinux)( script, [ `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + runtime.isolated( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ), )}`, `process.umask(0o200)`, `for (let i = 0; i < 2; i++) {`, From 99e1b2c39d9f6eddd7bd04c794aa8dfbbdb2b189 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 078/141] README: where the mount point manifests are kept, and whose are believed --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3dcc8d56c..224e2e878 100644 --- a/README.md +++ b/README.md @@ -736,18 +736,19 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of the manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. +- **The manifest directories.** Two processes of one user keep each other's mount points only when each reads what the other writes, so where the manifests go is worked out from the user id and the file system first and from the environment last. The environment is not the same for every process of a user: a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, and two shells can differ in `TMPDIR`. Two processes that each wrote where their own environment said did not see each other's manifests: the second took the first's mount point for the user's own file, and the first's cleanup removed it from under the second sandbox. The candidates, in the order they are tried for writing: (1) `/run/user//srt-mount-points`, where `/run/user/` is a real directory (not a symbolic link), owned by the user, with no permission bits for group or others; (2) `/tmp/srt-mount-points-`, in `/tmp` whatever `TMPDIR` says; (3) `$XDG_RUNTIME_DIR/srt-mount-points`, when `XDG_RUNTIME_DIR` is set; (4) `/srt-mount-points-`. Two names for one directory count once (they are compared by where they really are), and a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). A process writes its manifests to the first candidate that is usable: a real directory (a symlink at the name is refused), owned by the user, mode `0700`, and writable. One that is not there is made, with that mode, when its parent is there; one that is the user's is given that mode; one that is not the user's is left as it is. Every wrap, every cleanup and `liveMountPoints()` go by the manifests of the same directories, whichever of them the process writes to: candidates (1) and (2), each when it is there and passes the same checks; candidates (3) and (4) only when neither (1) nor (2) passes, which is when the process writes to one of them; and the directory the process has settled on, whatever it is. What the environment names is kept out of a sandboxed command's reach only by the processes whose environment names it, so a process that can use a directory every process of the user looks in does not take the word of a manifest found under its own `XDG_RUNTIME_DIR` or `TMPDIR`. A path is relied on when a live manifest in any of the directories the process goes by names it, and is taken for a mount point when a manifest in any of them names it. A candidate that fails a check is ignored: what is in it is not believed, and nothing fails because of it. A cleanup makes one pass over each of those directories, under that directory's lock, and takes away what the finished manifests in it name, and those manifests, when no live manifest in any directory names the same path. When no candidate will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has every candidate bound read-only, as the wrapping process sees them and whichever of them it writes to, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: a sandboxed command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, which keep other users out and not the command, and every process would then believe the manifests it put in it. The directories between each candidate and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes to; the cases this does not reach are listed under the known limits. The `directory.lock` file in each directory keeps srt processes out of each other's way while they collect there; nothing depends on it, and a pass never waits more than two seconds for it. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of each manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories that a wrap and a cleanup of the calling process go by (see above). The set is empty where there is no manifest directory, and always off Linux. - **Known limits.** - - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. - - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. - - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. + - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `/run/user/` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the candidates as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/tmp` can delete and forge manifests in the directory under it. A process that has to write where its environment says, because it can use neither `/run/user/` nor `/tmp`, keeps its manifests in a directory that the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there; a process that can use one of the first two believes nothing under the names its environment gives. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. + - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where a candidate's name, or a directory above it (`/tmp`, `TMPDIR`, `XDG_RUNTIME_DIR`), is a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where a candidate's parent does not exist, below a write root (a `/run/user/` that has not been made yet where the command may write `/run/user`, an `XDG_RUNTIME_DIR` or a `TMPDIR` that names a directory that is not there), the wrap cannot make the manifest directory under it and a sandboxed command can, parent and all: every process that looks there afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - _Between looking and removing._ The directory lock does not exclude: a holder can be held up past the minute after which its lock is broken, and a wrap that cannot have the lock within two seconds records its manifest without it. A cleanup pass therefore reads `/proc/locks` and lists the directory again immediately before it removes anything, and then again whenever its listing is a quarter of a millisecond old, and what it read from `/proc/locks` two milliseconds old or 64 removals ago. It removes a manifest last, after everything it names, and keeps one whose mount point it could not remove. A pass that is held up for longer than a sandbox takes to start (a few milliseconds) between such a look and a removal can still take away the mount point of a sandbox that started on the same path in that time. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. - - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. + - _A `/tmp/srt-mount-points-` that another user made._ Another local user can make `/tmp/srt-mount-points-` before the user's first wrap. It fails the owner check and is passed over, never repaired, and on a host with no `/run/user/` the user's processes then fall back to the names their environments give, where two processes with different environments do not see each other. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when one writes where the other reads. Every process reads the directories under `/run/user/` and `/tmp` when they pass the checks, so what is left is: a process that can use neither, which records where its environment says, where only a process in the same position with the same environment looks; a process that can write no candidate at all (it runs inside another sandbox, where they are read-only), which records in a directory of its own or nowhere; two processes that share a project but neither `/tmp` nor `/run/user/` (a container beside the host with a `/tmp` of its own, a service started with a private `/tmp`), or not `/proc` (see "Another PID namespace"); and a sandbox of a release that wrote no manifest. Nothing the other process reads then says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that reads the manifest directory they are recorded in; with a private directory, no other process does. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -788,7 +789,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. -With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. +With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of `/run/user/`, of `/tmp`, of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. From 7651646e7e721afd5507f59d1739829341154c07 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 11:37:27 +0000 Subject: [PATCH 079/141] Shorten the comments around the mount point manifests Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. Kept: every invariant and what a sandboxed command could do without it, the documentation of exported names, and the reasons for what is not done the obvious way. Cut: how the code came to be as it is, narration of the lines that follow, one explanation repeated at several call sites, and the arithmetic behind constants. --- src/sandbox/bwrap-mount-manifests.ts | 633 ++++++++------------- src/sandbox/linux-sandbox-utils.ts | 180 ++---- src/sandbox/sandbox-manager.ts | 14 +- test/helpers/private-manifest-directory.ts | 17 +- test/sandbox/linux-bwrap-args-file.test.ts | 3 +- test/sandbox/mount-point-liveness.test.ts | 276 ++++----- test/sandbox/mount-point-manifests.test.ts | 141 ++--- test/sandbox/stale-mount-point.test.ts | 56 +- 8 files changed, 487 insertions(+), 833 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 12117aaf2..eb15db090 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -2,49 +2,30 @@ * Which mount points on the host a running sandbox still relies on (Linux). * * A deny on a path that does not exist needs a mount point there: bwrap makes - * an empty file (or, for an intermediate component, an empty directory) on the - * host and binds /dev/null or an empty directory over it. Unlinking that file - * while a sandbox is still bound over it detaches the mount inside that - * sandbox — the denied path can then be created, and what is written lands on - * the host. A mount point may therefore only be removed when no running - * sandbox relies on it, and nothing a process counts for itself can decide - * that: a second srt process, a caller that crashes, one that cleans up early, - * twice or never, each gets a different answer. + * an empty file or directory on the host and binds over it. Unlinking it while + * a sandbox is bound over it detaches the mount inside that sandbox, and the + * denied path can then be created on the host. So a mount point may be removed + * only when no running sandbox relies on it, and no process can decide that by + * counting for itself. * - * The kernel is asked instead. Each wrap writes a manifest naming the mount - * points it relies on into a per-user runtime directory and passes - * `--lock-file ` to bwrap. bubblewrap's sandbox init process opens - * that path and holds an fcntl read lock (F_RDLCK through F_SETLK) on it for - * exactly the sandbox's lifetime; the kernel drops the lock however the - * sandbox ends, SIGKILL included. /proc/locks then answers, for any process - * and at any moment, whether a sandbox that named a mount point is still - * running, so cleanup becomes a garbage collect any process may run at any - * time and any number of times: a mount point goes only when no live manifest - * names it. + * The kernel is asked instead. Each wrap writes a manifest naming its mount + * points into a per-user directory and passes `--lock-file ` to + * bwrap, whose sandbox init process holds a read lock on it for the sandbox's + * lifetime; the kernel drops it however the sandbox ends. /proc/locks then + * tells any process whether a sandbox that named a mount point is running, so + * cleanup is a garbage collect any process may run at any time. * - * The lock belongs to the sandbox's init process, not to the command: bwrap - * opens the manifest with O_CLOEXEC, so the descriptor does not survive the - * exec into the command, and a POSIX lock is only dropped by the process that - * took it. The directory is bound read-only into every sandbox that - * restricts writes at all, whether or not its own invocation names a manifest - * in it, and so is the other name a process of the same user would keep its - * manifests under (see {@link mountPointManifestDirectories}), so a sandboxed - * command can neither delete nor rewrite one, its own or another sandbox's; - * and the directories between each of them and the write root it lies in are - * pinned there, so that the command cannot rename one aside and make the - * directory again under the old name, with manifests of its own in it. - * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with the directory writable, a process that looks - * for its runtime or temp directory somewhere this one does not, and a - * runtime or temp directory whose name is a link inside a write root, or not - * there yet below one: neither can be pinned. + * The manifest directories are bound read-only into every sandbox that + * restricts writes, and the directories above them inside a write root are + * pinned, so a sandboxed command can neither rewrite a manifest nor swap the + * directory (see {@link mountPointManifestDirectories}). Not covered: a sandbox + * this library did not start, a process that looks for its runtime or temp + * directory elsewhere, and a runtime or temp directory that is a link inside a + * write root or not there yet. * - * Both of the kernel's answers are relative to a PID namespace: /proc/locks - * lists a lock only when its holder has a pid in the namespace of the /proc - * being read, and /proc/PID is local to it. A manifest therefore says which - * PID namespace wrote it, and a process in another one does not judge it at - * all: to that process it is live, and only a process in the namespace that - * wrote it, which can see both answers, ever collects what it names. + * /proc/locks and /proc/PID are relative to a PID namespace, so a manifest + * records the namespace that wrote it and only a process in that namespace + * judges it; to any other it is live. */ import { randomBytes } from 'node:crypto' @@ -60,14 +41,9 @@ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' /** - * A manifest written this recently counts as live even with no lock on it and - * its writer gone. bubblewrap takes the lock in the sandbox's init process, - * which it forks alongside the command, so a wrapping process killed between - * the exec and that fcntl would otherwise leave a starting sandbox's mount - * point unclaimed. The window to cover is bubblewrap's own startup, a few - * milliseconds; this is two orders of magnitude more, and short enough that a - * mount point a killed process left behind is collectable while the command - * after it is still being wrapped. + * A manifest this young counts as live with no lock on it. bubblewrap takes the + * lock a few milliseconds after the wrap returns; a wrapping process killed in + * between must not leave a starting sandbox's mount point unclaimed. */ const MANIFEST_GRACE_MS = 500 @@ -78,62 +54,43 @@ const DIRECTORY_LOCK_WAIT_MS = 2_000 const DIRECTORY_LOCK_RETRY_MS = 25 /** - * A directory lock this old is broken whoever holds it, a holder that is - * still running included: far past the milliseconds a collect takes. It is - * the only way out for a holder that cannot be asked after, one in another - * PID namespace or one whose lock says nothing this process can read. A - * holder in this namespace is asked after directly, and the lock of one that - * is gone is broken at once, whatever its age. + * A directory lock this old is broken whoever holds it: the only way out for a + * holder that cannot be asked after (another PID namespace, unreadable + * content). A holder that is gone is broken at once. */ const DIRECTORY_LOCK_STALE_MS = 60_000 /** - * How many mount points a pass removes on one look at /proc/locks, and for how - * long one look is good. Each look reads all of /proc/locks, whose size is the - * host's and not this library's, so one per removal made a pass over many - * mount points on a busy host outlast the wait above. A removal takes some ten - * microseconds, and a sandbox takes several milliseconds, about four on a fast - * machine, to get from its manifest being published to its binds being in - * place (the wrap returns, the caller spawns a shell, bubblewrap sets up its - * namespaces), so a look shared by this many removals, under a millisecond's - * worth, is as good as one each. The time limit is for a pass that is held up - * between two removals, which then looks again before the next, and is kept - * under that start-up time. + * How many mount points a pass removes on one read of /proc/locks, and for how + * long one read is good. A sandbox needs several milliseconds from its manifest + * being published to its binds being in place, so both stay under that; one + * read per removal would make a pass on a busy host outlast the directory lock + * wait. */ const REMOVALS_PER_LOOK = 64 const LOCKS_GOOD_FOR_MS = 2 /** - * For how long one listing of the manifest directory is good. A manifest - * published for a sandbox that is about to start is the usual way for a pass - * to be out of date, and listing a directory of a few manifests costs less - * than one removal, so this is kept an order of magnitude under the time that - * sandbox needs to get to its binds, and not at the two milliseconds above. It - * is a time and not "before every removal" so that a directory of thousands of - * manifests, which takes milliseconds to list, is not listed thousands of + * For how long one listing of the manifest directory is good: well under the + * time a starting sandbox needs to reach its binds, and a time rather than + * "before every removal" so a directory of thousands is not listed thousands of * times. */ const LISTING_GOOD_FOR_MS = 0.25 /** - * A manifest whose contents cannot be read is dropped once it is this old and - * no sandbox holds its lock — long past anything that could still be using - * what it names, and long enough that another version of this library writing - * to the same directory keeps its manifests for as long as it needs them. + * An unreadable manifest with no lock on it is dropped once it is this old: + * long enough for another version of this library to keep its own format. */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 -/** - * The largest file that is read as a manifest. One names a wrap's mount - * points, some dozens of paths, and is a few kilobytes; whatever is larger - * than this is not one, and is not read into memory to find that out. - */ +/** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 /** - * What a manifest, and the directory lock, is written as before it is moved - * into place. A process killed between the two leaves the file behind, and a - * pass removes one that is as old as an unreadable manifest has to be. + * Suffix of a manifest or directory lock before it is moved into place. One + * left by a killed process is removed at {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ const TEMPORARY_SUFFIX = '.tmp' @@ -143,10 +100,8 @@ const ManifestSchema = z.object({ pid: z.number().int().nonnegative(), start: z.string(), /** - * The PID namespace the wrapping process was in, as `readlink - * /proc/self/ns/pid` names it. `pid` and the lock bubblewrap takes can only - * be asked after from there. Absent from a manifest written before the field - * existed, which is then from a namespace nobody can name. + * The writer's PID namespace (`readlink /proc/self/ns/pid`): `pid` and the + * lock can only be asked after from there. Absent in older manifests. */ ns: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ @@ -154,11 +109,9 @@ const ManifestSchema = z.object({ /** The mount points bwrap makes on the host for that wrap's deny paths. */ paths: z.array(z.string()), /** - * The empty directories those mount points bind FROM, which this library - * makes itself. A live bind's source must stay, so they go the same way the - * mount points do, and are held to what {@link removeMountSource} asks: a - * source that is no longer our own private empty directory is not ours to - * remove. + * The empty directories those mount points bind from. A live bind's source + * must stay, so they are collected the same way (see {@link + * removeMountSource}). */ sources: z.array(z.string()), }) @@ -169,23 +122,16 @@ type Manifest = z.infer & { inode: string device: string /** - * Whether this process has given the manifest up: it wrote it, the caller - * says the command is done, and nothing held the lock when the pass began. - * Its writer is running and it may be young, and neither keeps it: only a - * lock can still make it live. The file stays where it is until the end of - * the pass, like any other finished manifest's. + * This process wrote it, the caller says the command is over, and nothing + * held the lock when the pass began: only a lock can still make it live. */ released: boolean } /** - * Manifests this process wrote and that are still on disk as far as it knows, - * each with the key of the command it was wrapped for where the caller gave - * the wrap one, and whether the caller has said that command is over. That is - * remembered, not acted on at once: a pass can turn back half way, a removal - * can be refused, a sandbox can still hold the lock, and in each case the - * manifest has to stay on disk, since it is all that names the mount points, - * and go at a later pass whatever that pass is told to release. + * Manifests this process wrote that are still on disk, with the command key the + * caller gave and whether that command is over. Remembered rather than acted on + * at once: a manifest must stay on disk until a pass removes what it names. */ const ownManifests = new Map< string, @@ -193,18 +139,17 @@ const ownManifests = new Map< >() /** - * Which of this process's own manifests a collect may release. Releasing one - * says "the command this was wrapped for is over", which only the caller - * knows, so it is the caller that says which: + * Which of this process's own manifests a collect may release. Only the caller + * knows that a command is over: + * * - `all`: no wrap of this process is outstanding, or the process is ending; - * - `none`: some are, and nothing says which of them is over, so only what - * OTHER processes have finished with is taken away; - * - one command: that command is over, whatever else is still running. - * Releasing the manifest of a wrap whose command has not started yet is what - * must never happen: bubblewrap opens the manifest to lock it, and a command - * whose manifest is gone refuses to start, or, if it got its lock in the - * moment between the pass's last look at /proc/locks and the unlink, runs on - * with nothing on disk naming its mount points. + * - `none`: some are, and nothing says which is over, so only what other + * processes have finished with is collected; + * - one command: that command is over, whatever else is running. + * + * Never release the manifest of a wrap whose command has not started: + * bubblewrap opens the manifest to lock it, and the command would refuse to + * start or run with nothing on disk naming its mount points. */ export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } @@ -220,9 +165,8 @@ let manifestDirectoryUnavailable = false const onLinux = (): boolean => process.platform === 'linux' /** - * Field 22 of /proc/PID/stat, the process's start time in clock ticks. The - * comm field can hold spaces and parentheses, so the fields are counted from - * the last ')' rather than from the start of the line. + * Field 22 of /proc/PID/stat, the start time in clock ticks, counted from the + * last ')' because the comm field can hold spaces and parentheses. */ function startTimeFromProcStat(stat: string): string | undefined { return stat @@ -240,9 +184,8 @@ function processStartTime(pid: number): string | undefined { } /** - * Whether the process a manifest names is still running. Only "the process is - * gone" answers no: a /proc that cannot be read must not make a live sandbox's - * manifest collectable. + * Whether the process a manifest names is still running. An unreadable /proc + * answers yes: it must not make a live sandbox's manifest collectable. */ function writerIsRunning(pid: number, start: string): boolean { try { @@ -257,10 +200,7 @@ function writerIsRunning(pid: number, start: string): boolean { let pidNamespace: string | undefined | null = null -/** - * The PID namespace this process is in, as the kernel names it - * (`pid:[4026531836]`), or `undefined` where it cannot be told. - */ +/** This process's PID namespace as the kernel names it, or `undefined`. */ function ownPidNamespace(): string | undefined { if (pidNamespace === null) { try { @@ -272,10 +212,7 @@ function ownPidNamespace(): string | undefined { return pidNamespace } -/** - * The device half of what /proc/locks prints for a file, from the number stat - * gives for it: `major:minor`, in decimal. - */ +/** `major:minor` in decimal, as /proc/locks prints a device. */ function deviceOf(dev: bigint): string { const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) @@ -283,10 +220,8 @@ function deviceOf(dev: bigint): string { } /** - * The filesystems on which the device stat reports for a file is the one - * /proc/locks prints for a lock on it: both are the superblock's. It is not so - * everywhere - a btrfs subvolume and some overlay arrangements give stat a - * device of their own - and there only the inode number can be compared. + * Filesystems on which stat reports the device /proc/locks prints. Elsewhere (a + * btrfs subvolume, some overlays) only the inode number can be compared. */ const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ 0x01021994, // tmpfs, which is what $XDG_RUNTIME_DIR is @@ -313,18 +248,11 @@ type Locks = { } /** - * Whether /proc/locks lists a lock on this file. Any lock on a manifest is the - * one bubblewrap's sandbox init holds: nothing else opens one to lock it. - * - * The device is compared where the manifests' filesystem reports the one - * /proc/locks prints, and only there. Inode numbers are per filesystem, and on - * a tmpfs they are small and handed out in order, so a manifest in a young - * runtime directory can share its number with some unrelated locked file on - * another filesystem; it then read as live for as long as that lock was held, - * and every path it named as a mount point for every later wrap. - * Where the two devices cannot be compared the inode number alone decides, - * which errs towards "locked": the other way round would hide a live - * sandbox's lock. + * Whether /proc/locks lists a lock on this file; nothing but bubblewrap's + * sandbox init locks a manifest. The device is compared where it can be: inode + * numbers are per filesystem, and an unrelated locked file with the same number + * would otherwise keep a manifest live. Where it cannot, the inode alone + * decides, which errs towards "locked". */ function holdsLock( locks: Locks, @@ -336,12 +264,9 @@ function holdsLock( } /** - * What /proc/locks lists, for the manifests in `dir`. Only the locks whose - * holder has a pid in the PID namespace of the /proc being read are listed, - * which is why a manifest from another namespace is never judged by this. - * - * `undefined` when the list could not be read, which every caller reads as - * "every manifest is locked". + * What /proc/locks lists. Only locks whose holder has a pid in this PID + * namespace appear. `undefined` when it cannot be read, which every caller + * treats as "every manifest is locked". */ function readLocks(dir: string): Locks | undefined { let text: string @@ -395,13 +320,10 @@ function isOurPrivateDirectory(dir: string): boolean { } /** - * Make `dir` a directory of ours alone, or say it cannot be. - * - * The name is predictable and sits where sandboxed commands commonly write, - * so what is found there is looked at before it is touched: through a - * descriptor opened without following a link, so that a symlink planted at - * the name is refused rather than having its target's mode changed, and the - * mode is set on that descriptor, never on the path. + * Make `dir` a directory of ours alone, or say it cannot be. The name is + * predictable and sandboxed commands can often write beside it, so it is opened + * without following a link and its mode is set on the descriptor: a symlink + * planted at the name is refused, not followed. */ function makeOurPrivateDirectory(dir: string): boolean { try { @@ -435,9 +357,8 @@ function makeOurPrivateDirectory(dir: string): boolean { } /** - * Whether a manifest kept under `dir` would be out of bubblewrap's reach: the - * wrap mounts a fresh /dev and /proc after every bind, which buries whatever - * was bound beneath them, and bubblewrap opens the manifest after its mounts. + * Whether bubblewrap could not reach a manifest under `dir`: the wrap mounts a + * fresh /dev and /proc over whatever was bound beneath them. */ function isBuriedBySandboxMounts(dir: string): boolean { return ['/dev', '/proc', '/sys'].some( @@ -446,10 +367,8 @@ function isBuriedBySandboxMounts(dir: string): boolean { } /** - * The names under which the processes of this user keep their manifests where - * several can find them, in the order they are tried: under $XDG_RUNTIME_DIR - * when there is one, else under the system temp dir. One that a sandbox's own - * mounts would bury is left out. + * Where processes of this user keep manifests that others can find, in the + * order tried: under $XDG_RUNTIME_DIR, then under the system temp dir. */ function sharedManifestDirectoryNames(): string[] { const runtimeDir = process.env['XDG_RUNTIME_DIR'] @@ -464,15 +383,10 @@ function sharedManifestDirectoryNames(): string[] { } /** - * The directory manifests live in: under $XDG_RUNTIME_DIR when there is one, - * else a per-user directory under the system temp dir, else - when neither can - * be made ours alone and written, as in a sandbox that binds them read-only - - * a private directory only this process knows, which keeps the guarantee - * within this process and loses only what other processes would have read. - * - * Revalidated on every use: the temp dir is somewhere sandboxed commands - * commonly write, and a directory swapped for a symlink between two wraps - * would put manifests somewhere else entirely. + * The directory manifests live in: the first shared name that can be made ours + * alone, else a private directory only this process knows, which keeps the + * guarantee within this process. Revalidated on every use, because a directory + * swapped for a symlink between two wraps would send manifests elsewhere. */ function ensureManifestDirectory(): string | undefined { if ( @@ -535,24 +449,16 @@ function isOurDirectory(dir: string): boolean { } /** - * Every directory a sandbox must not be able to write, because some process - * of this user keeps manifests in it and a collect on the host believes what - * it finds there: both shared names, as this process sees them, and the one - * this process has settled on where that is neither. For a wrap to bind - * read-only, whether or not it has a manifest of its own. + * Every directory a wrap must bind read-only because some process of this user + * keeps manifests in it and a collect believes what it finds there: both shared + * names, plus the one this process settled on. * - * Both names, and not only the one this process uses: a process started with - * $XDG_RUNTIME_DIR uses the first and one started without it (from cron, over - * a plain ssh, as a service) the second, and a sandbox of the first kind with - * the temp dir writable could otherwise delete and forge the manifests of the - * second. Each is made if it can be, so that a sandboxed command cannot make - * it first and fill it before the process that will believe it comes along; - * one that is there and the user's is listed even when it cannot be written - * from here. The last resort is not made for this: there is nothing to keep - * out of reach in a directory nobody has made. A wrap that is about to record - * says so with `recording`, and the directory its manifest will go to is then - * settled first, the last resort included: the wrap has to know every one of - * these before it publishes, to pin what lies above them. + * Both names, because a process started without $XDG_RUNTIME_DIR (cron, plain + * ssh, a service) uses the second, and a sandbox of the first kind with the + * temp dir writable could otherwise forge its manifests. Each is made if it can + * be, so a sandboxed command cannot make and fill it first. With `recording`, + * the directory this wrap's manifest will go to is settled first, so the wrap + * can pin what lies above it before it publishes. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -575,9 +481,8 @@ export function mountPointManifestDirectories(recording = false): string[] { } /** - * Take away the directory only this process knows, once nothing is left in - * it, when the process or its session is ending: nobody else would. With the - * manifest of a sandbox that is still running in it, it stays. + * Remove the directory only this process knows, once it is empty, when the + * process or its session ends. It stays while a manifest is in it. */ export function removePrivateManifestDirectory(): void { if (manifestDirectory === undefined || !manifestDirectoryIsPrivate) { @@ -598,25 +503,16 @@ function sleep(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -/** - * What a process leaves in the directory lock: who it is, and the PID - * namespace in which that can be asked after. - */ +/** What the directory lock holds: who took it, and in which PID namespace. */ function directoryLockContent(): string { return `${process.pid} ${processStartTime(process.pid) ?? '?'} ${ownPidNamespace() ?? '?'}\n` } /** - * Take the directory lock, and say what it now holds, or why it was not taken: - * `held` when something is at its name already, `unavailable` when this - * process cannot make one there at all. - * - * The lock comes into being whole. Made with O_EXCL and written to afterwards - * it stood empty for a moment, and a waiter that read it then found no holder - * in it and removed it at once, so two processes were inside together. It is - * written under another name and linked into place instead: link fails when - * the name is taken, exactly as O_EXCL does, and what it puts there is never - * seen half made. A filesystem without hard links gets no lock, and no pass. + * Take the directory lock: returns what it now holds, `held` when the name is + * taken, or `unavailable` when this process cannot make one there. The content + * is written under another name and hard-linked into place, so a waiter never + * reads a lock that is still empty and takes it for abandoned. */ function takeDirectoryLock( dir: string, @@ -649,9 +545,8 @@ function takeDirectoryLock( } /** - * Give the directory lock up, if it is still this process's. One broken for - * its age while this process was held up belongs to whoever took it next, and - * removing that one by name let a third process in beside it. + * Give the directory lock up if it is still this process's. One broken for its + * age meanwhile belongs to whoever took it next. */ function releaseDirectoryLock(lockFile: string, content: string): void { try { @@ -664,25 +559,22 @@ function releaseDirectoryLock(lockFile: string, content: string): void { } /** - * What stands at the directory lock's name, once taking it has failed: - * - `free`: nothing any more, or the lock of a holder that is gone, removed; + * What stands at the directory lock's name once taking it has failed: + * + * - `free`: nothing, or the lock of a holder that is gone, now removed; * - `held`: somebody's lock, to be waited for; - * - `stuck`: something this process can neither read as a lock nor remove, - * which no amount of waiting changes. + * - `stuck`: something this process can neither read as a lock nor remove. * - * Breaking a lock is three steps - read who holds it, ask after them, unlink - - * and not one, so two waiters can both find the same holder gone and the - * slower one's unlink can land on the lock a third process has taken since. - * Nothing here prevents that. It is why nothing rests on this lock: see - * {@link withDirectoryLock}. + * Breaking a lock is read, ask, unlink, not one step, so a slow waiter's unlink + * can land on a lock a third process has taken since. Nothing prevents that, + * which is why nothing rests on this lock (see {@link withDirectoryLock}). */ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { let holder: string let age: number try { - // Looked at before it is opened, and opened without blocking or following - // a link: opening a FIFO to read waits for a writer, and nothing but a - // regular file is a lock this library made. + // Opened without blocking or following a link: opening a FIFO waits for a + // writer, and only a regular file is a lock this library made. if (!fs.lstatSync(lockFile).isFile()) { return 'stuck' } @@ -701,14 +593,11 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { fs.closeSync(fd) } } catch (e) { - // Gone between the failed attempt and this look: the next attempt takes - // it. Anything else is a file that is there and cannot be read. + // Gone meanwhile: the next attempt takes it. Anything else cannot be read. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } - // A holder can be asked after only in the PID namespace it named, when that - // is this one: elsewhere its pid means nothing, or means some other - // process. One that cannot be asked after is believed until the lock is - // older than any pass, as is one that did not say who it is. + // A holder can be asked after only in its own PID namespace. One that cannot + // be, or that did not say who it is, is believed until the lock is stale. const who = /^(\d+) (\d+) (\S+)\n$/.exec(holder) const held = age < DIRECTORY_LOCK_STALE_MS && @@ -721,31 +610,25 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { try { fs.unlinkSync(lockFile) } catch (e) { - // Another process broke it first; or it cannot be removed from here, in - // which case it is still there and the next attempt would find it so. + // Another process broke it first, or it cannot be removed from here. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } return 'free' } /** - * Run `body` while holding the manifest directory's lock, and report whether - * it ran, and if not, whether that was for somebody `held` it all the while. - * It gives up, without running `body`, after {@link DIRECTORY_LOCK_WAIT_MS} of - * finding the lock held, and at once when what is at the lock's name is - * nothing it could ever take: the wait is this thread's, in the wrap, in the - * clean-up after every command and in the exit handler, and nothing about a - * lock is worth more of it than that. + * Run `body` while holding the manifest directory's lock, and report whether it + * ran and, if not, whether the lock was `held` throughout. Gives up after + * {@link DIRECTORY_LOCK_WAIT_MS}, and at once when what is at the lock's name + * can never be taken: the wait blocks the wrap, the clean-up after every + * command and the exit handler. * - * The lock keeps srt processes out of each other's way most of the time; it - * does not exclude, and nothing may rest on it. Its holder can be held up past - * the age at which it is broken, the break itself is not atomic (see - * {@link examineDirectoryLock}), and a publish that cannot have the lock goes - * ahead without it. What makes a mount point safe to remove is looked at - * again immediately before the removals and at short intervals during them: - * the directory, for a manifest that was not there when the pass began, and - * the kernel's answer about every manifest the pass knows of (see - * {@link collectUnderLock}). + * The lock keeps srt processes out of each other's way; it does not exclude, + * and nothing may rest on it. Its holder can outlive the age at which it is + * broken, the break is not atomic, and a publish that cannot have the lock goes + * ahead without it. What makes a mount point safe to remove is looked at again + * immediately before the removals and during them (see {@link + * collectUnderLock}). */ function withDirectoryLock( dir: string, @@ -772,9 +655,8 @@ function withDirectoryLock( ) return { ran: false, held: false } } - // On every round, whatever the look above found: a lock that is broken - // and taken again by others for as long as this process keeps asking is - // as good as held. + // On every round: a lock that others keep breaking and retaking is as good + // as held. if (Date.now() >= deadline) { return { ran: false, held: true } } @@ -784,12 +666,9 @@ function withDirectoryLock( /** * What is at a manifest's name, read as one, or `undefined` when it is not a - * manifest this version can read. Only a regular file of the user's, no - * larger than {@link MANIFEST_MAX_BYTES}, is read at all, and it is looked at - * through the descriptor it is then read from, opened without following a - * link and without blocking: opening a FIFO to read waits for a writer, a - * link leads wherever whoever planted it likes, and nothing but such a file - * is a manifest this library wrote. + * manifest this version can read. Only a regular file of the user's, no larger + * than {@link MANIFEST_MAX_BYTES}, is read, through a descriptor opened without + * following a link and without blocking. */ function readManifest(file: string): Manifest | undefined { let inode: string @@ -832,16 +711,11 @@ function readManifest(file: string): Manifest | undefined { /** * Whether a sandbox that named this manifest may still be running, as far as - * THIS process can tell. - * - * It can tell only about a manifest written in its own PID namespace: the - * writer's pid and the sandbox's lock are both invisible from any other. So a - * manifest from another namespace is live here whatever /proc says, for good: - * what it names is collected by a process in the namespace that wrote it, and - * by nobody if that namespace has gone, which leaves empty files behind and - * opens nothing. One that does not say where it is from is live while a - * process with its writer's pid and start time can be seen, and otherwise - * until it is as old as an unreadable manifest has to be to be dropped. + * this process can tell. It can tell only for a manifest written in its own PID + * namespace; one from another namespace is always live here and is collected by + * a process in the namespace that wrote it. One that names no namespace is live + * while its writer can be seen, and otherwise until {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ function isLive(manifest: Manifest, locks: Locks): boolean { if (holdsLock(locks, manifest)) { @@ -867,27 +741,23 @@ function isLive(manifest: Manifest, locks: Locks): boolean { } /** - * What became of a path a pass set out to remove: `left` when there is - * nothing more to do about it - it is gone, or is no longer what bubblewrap or - * this library made - and `failed` when it is still that and could not be - * removed from here, which a later pass, or another process, may yet manage. + * What became of a path a pass set out to remove: `left` when it is gone or no + * longer what bubblewrap or this library made, `failed` when it is still that + * and could not be removed from here. */ type Removal = 'removed' | 'left' | 'failed' /** Whether a failed removal means the path is no longer ours to remove. */ function meansNothingToRemove(e: unknown): boolean { const code = (e as NodeJS.ErrnoException | undefined)?.code - // Not empty after all: something was written into it between the look and - // the rmdir. + // Not empty after all: something was written into it meanwhile. return isAbsenceErrno(e) || code === 'ENOTEMPTY' || code === 'EEXIST' } /** * Remove an empty directory a placeholder bound from, if it is still our own. - * It sits under the system temp dir, which sandboxed commands commonly can - * write, so anything that is not a private empty directory of ours — a - * symlink, a directory whose mode has been widened, one with something in it — - * is somebody else's and is left exactly as found. + * It sits where sandboxed commands can often write, so anything but a private + * empty directory of ours is left exactly as found. */ function removeMountSource(source: string): Removal { try { @@ -921,10 +791,9 @@ function removeMountSource(source: string): Removal { /** * Whether `p` still has the shape of a file bubblewrap made to bind onto: a - * regular file (not a link to one), empty, with no write bit, under one name, - * and ours. A manifest is only a claim about a path, and the directory it - * sits in is only as private as its host, so nothing is removed on a - * manifest's word that does not also look like what bubblewrap leaves. + * regular file (not a link), empty, with no write bit, under one name, and + * ours. A manifest is only a claim about a path, so nothing is removed on its + * word that does not also look like what bubblewrap leaves. */ export function isBwrapFileMountPoint(p: string): boolean { try { @@ -942,10 +811,8 @@ export function isBwrapFileMountPoint(p: string): boolean { } /** - * What kind of mount point the path a live manifest names still is, by what - * is there now: an empty regular file, a directory, or neither any more. A - * manifest says a path was a mount point when it was written; a file that has - * been written to since, or a link put in its place, is somebody's own. + * What kind of mount point the path a live manifest names still is, by what is + * there now: an empty regular file, a directory, or neither any more. */ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { try { @@ -960,9 +827,8 @@ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { } /** - * Remove a mount point, if it is still the empty file or directory bwrap made. - * Anything else - a file with content or a write bit, a link, a directory - * something has written into or that is not ours - is left where it is. + * Remove a mount point if it is still the empty file or directory bwrap made; + * anything else is left where it is. */ function removeMountPoint(mountPoint: string): Removal { try { @@ -1014,15 +880,12 @@ export type MountPointManifest = { /** * Record the mount points this wrap relies on, and the empty directories they - * bind from, before the sandbox that relies on them can start, and say where - * to point bubblewrap's --lock-file. + * bind from, before the sandbox can start, and say where to point bubblewrap's + * --lock-file. * - * `undefined` when there is nothing to record, or when no manifest could be - * written, in which case the caller must not track those mount points at all: - * what this process cannot record it does not remove. That is as far as it - * goes. Nothing on disk then says a sandbox relies on them, so a wrap in - * another process that denies the same path takes the file for the caller's - * own: it binds it onto itself and names it nowhere, and nobody removes it. + * `undefined` when there is nothing to record or no manifest could be written. + * The caller must then not track those mount points: what this process cannot + * record it does not remove, and nobody else will. */ export function publishMountPointManifest( mountPoints: readonly string[], @@ -1050,14 +913,10 @@ export function publishMountPointManifest( paths: [...new Set(mountPoints)], sources: [...new Set(sources)], } - // Written beside the manifest and renamed onto it, so a collect in another - // process reads it whole or not at all. The rename is made under the - // directory lock where that can be had, which keeps it out of the middle of - // most passes, and without it where it cannot: a wrap is not refused for - // want of a lock. Either way a pass may already have listed the directory, - // so what keeps this wrap's mount points is that every pass lists it again - // before it removes anything (see collectUnderLock), and that the sandbox - // this manifest covers has not started yet. + // Written beside the manifest and renamed onto it, so a collect reads it + // whole or not at all. Under the directory lock where that can be had; a wrap + // is not refused for want of one, because every pass lists the directory + // again before it removes anything (see collectUnderLock). try { fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) const publish = (): void => fs.renameSync(temporary, file) @@ -1094,16 +953,13 @@ export function discardMountPointManifest(file: string): void { } /** - * The mount points the manifests name, on one reading of the directory: - * `live`, by a manifest whose sandbox may still be running, and `named`, by - * any manifest at all. What only a finished manifest names is what a sandbox - * that has ended left behind and no pass has collected yet, and the next pass - * in any process removes it on that manifest's word. + * The mount points the manifests name, on one reading of the directory: `live` + * by a manifest whose sandbox may still be running, `named` by any manifest. + * What only a finished manifest names is a leftover the next pass removes. * * `live` is empty when /proc/locks cannot be read, and both are when the - * directory cannot be. A wrap takes an existing path for a mount point on a - * manifest's word only, never by the look of it, so it then takes every - * existing path for the caller's own. + * directory cannot be: a wrap takes a path for a mount point on a manifest's + * word only, so it then takes every existing path for the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() @@ -1135,10 +991,8 @@ export function namedMountPoints(): { live: Set; named: Set } { } /** - * The directory this process keeps its manifests in, if it is there already: - * the one it has settled on, else the first of the shared names that is a - * directory of the user's alone. Nothing is made, and nothing is settled: for - * a look that has to leave the host as it found it. + * The directory this process keeps its manifests in, if it exists already. + * Makes and settles nothing: for a look that must leave the host as found. */ function existingManifestDirectory(): string | undefined { return manifestDirectory !== undefined && @@ -1148,9 +1002,8 @@ function existingManifestDirectory(): string | undefined { } /** - * Whether what is at `p` now is still something a clean-up would take away on - * a manifest's word: the empty file bubblewrap made (see - * {@link isBwrapFileMountPoint}), or an empty directory of the user's. + * Whether `p` is still something a clean-up would remove on a manifest's word: + * the empty file bubblewrap made, or an empty directory of the user's. */ function isEmptyMountPoint(p: string): boolean { if (isBwrapFileMountPoint(p)) { @@ -1170,45 +1023,29 @@ function isEmptyMountPoint(p: string): boolean { /** * The mount points a sandbox that may still be running relies on, as the - * manifests say at the moment of the call: for a caller that removes paths of - * its own accord after a command, to leave out the ones in this set. Removing - * a mount point from under a running sandbox lifts the deny there. - * - * It is a snapshot, taken without a lock, from one listing of the manifest - * directory and one reading of /proc/locks, and it may be out of date by the - * time the caller acts on it: a sandbox can start on a path the moment after. - * Its only safe use is to SKIP a removal. That a path is not in it never - * means the path is free to be written, or that nothing will rely on it. + * manifests say at the moment of the call. For a caller that removes paths of + * its own accord after a command: removing a mount point from under a running + * sandbox lifts the deny there. * - * A path is in the set only when both of these hold: - * - a manifest names it, and that manifest is live by the rule the clean-up - * goes by, or its liveness cannot be told. It cannot be told when - * /proc/locks cannot be read, or while a manifest that cannot be read is - * locked or new; every path that any readable manifest names then counts. - * What a manifest that cannot be read names cannot be listed at all; - * - what is at the path now still has the shape of a mount point: an empty - * regular file with no write bit and one link, or an empty directory, and - * the user's. So whatever a caller spares on the word of this set is an - * empty placeholder, and a manifest that is out of date, or forged, never - * makes it spare a file with something in it. + * A snapshot, taken without a lock, that may be out of date by the time the + * caller acts. Its only safe use is to SKIP a removal; a path not being in it + * never means the path is free to write. * - * The paths are as the wraps recorded them: absolute, with the links in the - * directories above the last component resolved and the last component as it - * stands. A caller compares its own paths in that form. + * A path is in the set only when both hold: * - * The manifests are believed as found. Their directory can be written by the - * user's own processes outside any sandbox; every sandbox that restricts - * writes has it bound read-only, with the directories between it and the - * write root it lies in pinned, which does not reach a runtime or temp - * directory whose name is a link inside a write root, nor a runtime directory - * that is not there yet below one. It answers for one manifest directory, - * the one this process keeps its manifests in or, before it has kept any, the - * first of the shared names that is there, so a sandbox of a process that - * keeps its manifests elsewhere, or of something that writes none, is not - * seen. + * - a manifest names it, and that manifest is live by the clean-up's rule or + * its liveness cannot be told (/proc/locks unreadable, or an unreadable + * manifest that is locked or new), in which case every named path counts; + * - what is at the path now is still an empty placeholder: an empty regular + * file with no write bit and one link, or an empty directory, and the user's. + * So a stale or forged manifest never makes a caller spare a file with + * content. * - * Makes nothing on the host, the manifest directory included. Empty where - * there is no manifest directory, and anywhere but on Linux. + * Paths are as the wraps recorded them: absolute, links above the last + * component resolved. It answers for one manifest directory, the one this + * process uses or, before its first wrap, the first shared name that exists, so + * a sandbox whose process keeps manifests elsewhere is not seen. Makes nothing + * on the host. Empty off Linux and where no manifest directory exists. */ export function liveMountPoints(): ReadonlySet { const spared = new Set() @@ -1275,10 +1112,8 @@ export function collectMountPoints( if (!onLinux()) { return [] } - // What the caller says about its own commands is taken down first and kept, - // whatever becomes of this pass: one that cannot have the lock, or turns - // back half way, leaves the manifests for the next, which has to know they - // are done with even when it is told to release nothing. + // Recorded first and kept whatever becomes of this pass, so a later pass + // knows these commands are over even when told to release nothing. for (const own of ownManifests.values()) { if ( release === 'all' || @@ -1304,33 +1139,22 @@ export function collectMountPoints( } /** - * One pass, which the caller makes under the directory lock where it can. + * One pass, made under the directory lock where the caller could take it. * - * Nothing is taken off the disk until the end. A manifest is all that names - * its mount points, so it goes last, after them, and only when every one of - * them is gone or is no longer a mount point: a pass that turns back half way, - * or a removal that is refused, leaves the manifest for a later pass to go by. - * That holds for the manifests of this process as for anybody's. The ones the - * caller has said are over are judged as if their writer were gone, and that - * is all that sets them apart. + * A manifest is all that names its mount points, so it is removed last, and + * only when every path it names is gone or is no longer a mount point: a pass + * that turns back, or a removal that is refused, leaves it for a later pass. + * Manifests the caller has said are over are judged as if their writer were + * gone. * - * The lock does not exclude (see {@link withDirectoryLock}), so the pass does - * not rely on the directory staying as first listed. Immediately before it - * removes anything, and again whenever its last listing is more than - * {@link LISTING_GOOD_FOR_MS} old, it lists the directory again, where a - * manifest that was not there at first keeps everything it names whatever - * else is true of it: that is how a sandbox about to start on the same path - * shows. And before the first removal, and again every - * {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS}, it reads - * /proc/locks again, where a manifest that has been locked since keeps what it - * names. That one covers the actor no lock could: the bubblewrap a wrap has - * already handed to its caller, which takes its lock whenever the caller - * starts it. What is left is the time between a look and the removal made on - * it, a quarter of a millisecond for a newly published manifest and two - * milliseconds for one that was there all along, against the four or more a - * sandbox takes to get from its start to its binds. A pass that is held up for - * longer than that between looking and removing, with a sandbox starting on - * the same path in that time, still takes that sandbox's mount point away. + * The lock does not exclude (see {@link withDirectoryLock}), so before the + * first removal, and again at {@link LISTING_GOOD_FOR_MS}, the pass lists the + * directory again: a manifest that was not there at first keeps everything it + * names, which is how a sandbox about to start on the same path shows. And + * every {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS} it + * reads /proc/locks again: a manifest locked since keeps what it names. What + * remains is a pass held up between a look and the removal for longer than a + * sandbox takes to reach its binds; it then removes that sandbox's mount point. */ function collectUnderLock(dir: string): string[] { let names: string[] @@ -1359,23 +1183,18 @@ function collectUnderLock(dir: string): string[] { if (locks === undefined) { return [] } - // What the pass knows of: the first listing, so that everything found - // later is a newcomer. + // The first listing: whatever is found later is a newcomer. const known = new Set(names) - // A manifest of this process that something else has taken off the disk is - // nothing to remember. + // Forget own manifests that something else has removed. for (const file of ownManifests.keys()) { if (path.dirname(file) === dir && !known.has(path.basename(file))) { ownManifests.delete(file) } } - // A manifest whose contents cannot be read — a file another version of this - // library wrote, a truncated one, one this process may not read — names - // mount points that cannot be honoured one by one. While the kernel says a - // sandbox is running under it, or it is new enough that one may be starting, - // nothing at all is removed; once it is neither, whatever it names is not in - // use and it stands in the way of nothing. + // An unreadable manifest names mount points that cannot be honoured one by + // one. While it is locked, or new enough that a sandbox may be starting, + // nothing at all is removed. let unreadableIsLive = false for (const file of unreadable) { let stat: fs.BigIntStats @@ -1426,9 +1245,7 @@ function collectUnderLock(dir: string): string[] { } } - // The wraps of this process the caller has said are over, except that one - // whose manifest is locked has a sandbox running under it all the same, and - // is anybody's live manifest until the lock goes. + // Over, by the caller's word, unless a sandbox still holds the lock. for (const manifest of manifests) { manifest.released = ownManifests.get(manifest.file)?.over === true && @@ -1464,10 +1281,8 @@ function collectUnderLock(dir: string): string[] { } } - // When the pass last made sure, and what it found then: `revived` are the - // manifests of the first listing that read as finished then and as locked on - // a later look. A finished manifest's writer is gone and its grace is over, - // and neither comes back, so the lock is all that is asked after again. + // `revived`: manifests that read as finished at first and as locked on a + // later look. Only the lock can change; a gone writer does not come back. const revived = new Set() let removalsOnThisLook = 0 let lookedAt: number | undefined @@ -1522,9 +1337,8 @@ function collectUnderLock(dir: string): string[] { listedAt = performance.now() return true } - // The kernel's list is read first and the directory listed last. Reading - // the list takes as long as the host has locks, milliseconds on a busy one, - // after which a listing made before it is out of date as well. + // Locks first, listing last: reading /proc/locks can take milliseconds, and a + // listing made before it would be out of date. const mayStillRemove = (): boolean => (locksAreFresh() || readTheLocksAgain()) && (listingIsFresh() || listTheDirectoryAgain()) @@ -1557,9 +1371,8 @@ function collectUnderLock(dir: string): string[] { if (revived.has(manifest)) { continue } - // A mount point that is still there, still what bubblewrap made, and could - // not be removed from here - a directory this process sees read-only, say - // - is still somebody's to remove, and the manifest is all that says so. + // A mount point that could not be removed from here is still somebody's to + // remove, and the manifest is all that says so. if ( [...manifest.paths, ...manifest.sources].some(named => notRemoved.has(named), @@ -1579,11 +1392,9 @@ function collectUnderLock(dir: string): string[] { } /** - * Forget which directory the manifests are kept in, so that the next use - * works it out again from the environment. Test seam: a test gives itself a - * runtime directory of its own, so that what it lists, attacks and collects - * is never another process's, and this module may have settled on the real - * one before the test was loaded. + * Forget which directory the manifests are kept in, so the next use works it + * out again from the environment. Test seam: a test gives itself a runtime + * directory of its own after this module may have settled on the real one. */ export function forgetMountPointManifestDirectory(): void { manifestDirectory = undefined diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7af3e3ea8..45e74897e 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -854,12 +854,10 @@ function renderBwrapInvocation( let exitHandlerRegistered = false /** - * Wraps handed to the caller and not yet cleaned up after. The caller is told - * to clean up once per command, when that command is over, so while this is - * above zero some command of this process may not have started yet, and what - * it relies on - its manifest, its --args profile - must still be there when - * it does. Nothing of this process's own is given up until the count is back - * at zero, unless the caller says which command it is cleaning up after. + * Wraps handed to the caller and not yet cleaned up after. While above zero, + * some command of this process may not have started, and its manifest and + * --args profile must still be there when it does; so nothing of this process's + * own is released unless the caller names the command. */ let activeSandboxCount = 0 @@ -889,25 +887,21 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called on process exit as a safety net. * - * What it may take away, of what THIS process made: - * - with no `commandId`, nothing until it has been called once for every wrap - * handed out: a command wrapped and not yet started still needs its - * manifest and its --args profile, and nothing here can tell which command - * the call is for. So one long-running command holds back the clean-up of - * the ones that finished beside it; - * - with the `commandId` its wrap was given, what that one command relied on, - * at once, whatever else is still running; - * - with `force`, everything: the process is ending, or the session is. That - * includes the manifest directory itself where it is one only this process - * knows, once nothing is left in it. + * What it releases of what THIS process made: * - * In every case a mount point goes only when no running sandbox relies on it, - * which the kernel answers and not this process (see bwrap-mount-manifests.ts), - * and what other srt processes have finished with goes too. A call too many - * takes nothing from a running sandbox; it does count for some other wrap of - * this process, and if that wrap's command has not started by the time the - * count is back at zero it then refuses to start. Does nothing except on - * Linux. + * - with no `commandId`: nothing until it has been called once for every wrap + * handed out, since nothing can tell which command the call is for. One + * long-running command therefore holds back the clean-up of those that + * finished beside it; + * - with the `commandId` its wrap was given: what that command relied on, at + * once; + * - with `force`: everything, because the process or session is ending. + * + * A mount point goes only when no running sandbox relies on it, which the + * kernel answers (see bwrap-mount-manifests.ts). A call too many takes nothing + * from a running sandbox, but counts for another wrap of this process, whose + * command then refuses to start if it has not started by the time the count + * reaches zero. Does nothing except on Linux. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean @@ -1628,14 +1622,10 @@ async function generateFilesystemArgs( const args: string[] = [] // fs already imported - // The mount points on the host this wrap relies on: the placeholders bwrap - // makes for absent deny paths, and the ones another sandbox made that this - // wrap covers again. They are named in a manifest below, and nothing removes - // them while a sandbox that named them runs (see bwrap-mount-manifests.ts). + // The mount points on the host this wrap relies on: placeholders bwrap makes + // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // The mount points the manifests name, read once per wrap. The ones a - // manifest says a running sandbox relies on are mount points whatever their - // mode on the host says. + // What the manifests name, read once per wrap. let namedMountPointsCache: ReturnType | undefined // Collect normalized allowed write paths. Populated in the writeConfig @@ -2358,43 +2348,23 @@ async function generateFilesystemArgs( } // A mount point another sandbox made is an absent deny path in all but - // name. Bound onto itself as an existing path it would never be named in - // this wrap's manifest, so it would go, with this sandbox still bound - // over it, as soon as the sandbox that made it was cleaned up after. - // Only a manifest says that a path is one. An empty read-only file - // looks like anyone's, and so does an empty directory: taken for a - // leftover by its shape alone, a file its owner keeps empty and - // read-only was covered, named, and removed after the command. What no - // manifest names is the caller's, bound onto itself further down like - // any existing path and never removed, which leaves on the host what a - // killed process of a release that wrote no manifest left there. Any - // manifest will do, a finished one as much as a live one: a finished - // manifest is what a killed process leaves, the next clean-up in any - // process removes what it names on its word unless a live manifest - // names it too, and this wrap's is then the only one that can. What is - // done with a path a manifest names depends on its kind: - // - a FILE that has the shape bubblewrap leaves (see - // isBwrapFileMountPoint), or, where the manifest is live, that is - // still an empty regular file. It is covered with /dev/null like the - // absent leaf below, and named. Under a read-only denied directory - // nothing needs covering (the file is already unwritable there), but - // it is still named: it is no more the caller's file for being there; - // - a DIRECTORY, which is the first missing component of some other - // sandbox's deny. /dev/null cannot be bound over a directory - // (bubblewrap refuses to start), so it is named and then treated as - // the existing directory it is, further down: bound onto itself - // read-only, which is also what the pre-pass above recorded it as; - // - anything else (a file that has been written to since, a link) is - // the caller's by now, and is neither. - // The shape is asked before the existence: another process's collect can - // take a mount point away at any moment, and a path found to exist and - // then found not to have the shape because it has just gone would be - // bound onto itself as a file that is not there. And once more after it, - // when the two disagree: another sandbox starting on the same path makes - // its mount point at any moment too. + // name: bound onto itself it would not be named in this wrap's manifest + // and would go, with this sandbox still bound over it, when its maker is + // cleaned up after. Only a manifest says that a path is one; what no + // manifest names is the caller's own and is never removed. For a path a + // manifest names: + // + // - a FILE with the shape bubblewrap leaves (or, under a live manifest, + // any empty regular file) is covered with /dev/null and named; + // - a DIRECTORY is named and then bound onto itself read-only further + // down, since /dev/null cannot be bound over a directory; + // - anything else is the caller's by now. + // + // The shape is asked before the existence, and once more when the two + // disagree: another process can remove or make a mount point at any + // moment. let hasFileShape = isBwrapFileMountPoint(normalizedPath) - // Whether anything is at the path, asked once for the branches below, - // and once more where the manifests say the file is nobody's. + // Asked once for the branches below. let pathExists = hasFileShape || fs.existsSync(normalizedPath) if (pathExists && !hasFileShape) { hasFileShape = isBwrapFileMountPoint(normalizedPath) @@ -2403,17 +2373,11 @@ async function generateFilesystemArgs( isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath) ) { - // The manifests are read once per wrap, and a reading that names the - // path is believed whenever it was taken. One that does not name a - // file of that shape is believed only if it was taken after the file - // was seen: a sandbox publishes its manifest before bubblewrap makes - // the file, so such a reading names every mount point that is there, - // and an older one misses that of a sandbox that has started since, - // which would be bound onto itself as the caller's own file, named by - // this wrap nowhere, and gone from under it at that sandbox's - // clean-up. A file of that shape which such a reading does not name - // is the caller's, or has gone since it was seen, its manifest with - // it: whether it is still there is asked again. + // A reading that names the path is believed whenever it was taken. One + // that does not name a file of that shape is believed only if taken + // after the file was seen: a sandbox publishes its manifest before + // bubblewrap makes the file, so an older reading would miss a sandbox + // that started since. let named = pathExists ? namedMountPointsCache : undefined if ( pathExists && @@ -2791,14 +2755,11 @@ async function generateFilesystemArgs( // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. // - // The directories of this library's own that are bound read-only at the end - // of this function are seeds as well: the manifest directories, the store of - // fake files and the empty directory the placeholders bind from. What is - // found at each of those names is believed on the host, by the next - // clean-up or the next wrap, and the read-only bind keeps a command out of - // the directory only for as long as the name leads to it: with the temp dir - // strictly inside a write root, a command could rename the temp dir aside - // and make the name again, with a manifest directory of its own in it. + // This library's own read-only directories are seeds as well (the manifest + // directories, the fake-file store, the empty placeholder source): a + // read-only bind protects one only while its name still leads to it, and + // with the temp dir strictly inside a write root a command could otherwise + // rename the temp dir aside and make the name again. const manifestDirs = writeConfig !== undefined ? mountPointManifestDirectories( @@ -2950,37 +2911,23 @@ async function generateFilesystemArgs( } } - // Name the mount points this wrap relies on, before the sandbox that relies - // on them can start, and have bwrap hold a lock on the manifest for exactly - // as long as that sandbox runs: the lock is what tells every process, this - // one included, that these mount points are still in use. Mount points that - // could not be recorded are left on the host, since what this process cannot - // record, no process may remove. + // Name the mount points this wrap relies on before the sandbox can start, and + // have bwrap hold a lock on the manifest for as long as it runs. Mount points + // that could not be recorded are left on the host. // // INVARIANT: a manifest directory must never be writable from inside a - // sandbox — a command that could delete a manifest could make the mount - // points a running sandbox relies on look unused, and one that could write - // a manifest could name any path at all for a collect on the host to - // remove. That holds for a sandbox whose own wrap names no manifest as much - // as for one that does, and for the directory some other process of this - // user keeps its manifests in as much as for this one's: the directory sits - // under the system temp dir wherever there is no $XDG_RUNTIME_DIR, which a - // caller's allowWrite commonly covers, and what is in it then belongs to - // OTHER sandboxes. So every wrap that restricts writes binds every one of - // them read-only (see mountPointManifestDirectories), with what lies above - // them pinned (they are seeds of the ancestor pins), and a wrap with mount - // points of its own adds the lock. Emitted after every allow and deny bind, - // for the same reason as the two stores below (they are all directories of - // their own, so their order among themselves does not matter). It is also - // what lets bwrap open the manifest: it does that inside the sandbox, after - // the mounts, so a profile with a tmpfs over the temp dir would otherwise - // leave nothing there to open. + // sandbox. A command that could delete a manifest could make a running + // sandbox's mount points look unused, and one that could write a manifest + // could name any path for a collect on the host to remove. This holds for + // every wrap that restricts writes, whether or not it names a manifest, and + // for every directory some process of this user keeps manifests in, so each + // is bound read-only (see mountPointManifestDirectories) with what lies above + // it pinned. Emitted after every allow and deny bind, like the two stores + // below. // - // Each is bound where it really is, like a pin, and not at the name the - // environment gave for it: bubblewrap makes a bind's destination by name - // inside the new root before it mounts, and a link on the way whose target - // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir - // reached through such a link no command that restricts writes started. + // Each is bound where it really is, not at the name the environment gave: + // bubblewrap makes a bind's destination by name inside the new root, and a + // link with an absolute target on the way leads nowhere from there. const manifest = publishMountPointManifest( mountPoints, emptySource === undefined ? [] : [emptySource], @@ -3441,9 +3388,8 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // No command came of this wrap, so no sandbox can be running under its - // manifest and the mount points it named are no one's, and the caller has - // nothing to clean up after. + // No command came of this wrap, so no sandbox runs under its manifest and + // the mount points it named are no one's. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 3ac05bc34..438e3fe0c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -2077,14 +2077,12 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Call it once for each wrapped command, when that command is over. Two - * commands of one process may be in flight at once, and a call cannot tell - * which of them it is for, so with no argument nothing this process made is - * taken away until it has been called once for every wrap handed out: the - * command that has not started yet still needs what its wrap prepared. Pass - * the `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to - * have that one command's mount points go at once, whatever else is running, - * which matters when some command runs for a long time. + * Call it once for each wrapped command, when that command is over. With no + * argument nothing this process made is removed until it has been called + * once for every wrap handed out, because a call cannot tell which command + * it is for. Pass the `commandId` the wrap was given + * (`WrapWithSandboxOptions.commandId`) to have that command's mount points + * removed at once, which matters when another command runs for a long time. * * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 897287435..cb555144a 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -10,19 +10,14 @@ import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.j * point manifests, for as long as its tests run. * * The manifests of every srt process of a user live in one directory, and a - * collect reads, judges and removes all of them. A test that lists that - * directory, attacks it from inside a sandbox or asserts on what is left in it - * would otherwise be doing so to whatever else that user is running on the - * machine, and be failed by it in turn. `$XDG_RUNTIME_DIR` is where the library - * looks first, and child processes inherit it, so pointing it at a fresh - * directory moves this process and everything it spawns at once. + * collect reads, judges and removes all of them, so a test that lists or + * attacks that directory would otherwise act on whatever else the user is + * running. `$XDG_RUNTIME_DIR` is where the library looks first and child + * processes inherit it. * * It also starts the `describe` with no wrap of this process outstanding, and - * leaves it so. The library counts the wraps it has handed out and releases - * nothing of this process's until each has been cleaned up after; the count is - * the module's, shared by every test file of one run, and other suites wrap - * without cleaning up. A test that expects one clean-up to take its mount point - * away would otherwise pass or fail by which file ran before it. + * leaves it so: the library's count of wraps is shared by every test file of a + * run, and other suites wrap without cleaning up. * * Call it inside the `describe` callback. Returns where the manifests go. */ diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 98fa867d0..14e8e414d 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -390,8 +390,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { it('keeps a pending profile through the clean-up after another command, and gives it back with the last', () => { // A profile belongs to one wrap and nothing maps it back to a command, so - // none is closed while any wrap of this process is outstanding: the one - // that has not started yet reopens its profile through /proc when it does. + // none is closed while any wrap of this process is outstanding. const seen = isolated(` const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 878f71936..a54a9b63d 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -29,18 +29,11 @@ import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' /** - * A denyWrite path that does not exist is blocked by binding /dev/null over - * it, and bwrap makes the mount point for that bind on the host. Unlinking one - * while a sandbox is still bound over it detaches the mount inside that - * sandbox: the denied path can be created there, and what is written lands on - * the host. Whether a sandbox is still running is therefore not something a - * process may decide for itself — a second srt process, a caller that crashes, - * one that cleans up early, twice or never, each had a different answer, and a - * count kept in one process's memory could not see any of them. - * - * Every wrap now names the mount points it relies on in a manifest and has - * bwrap hold a lock on it for the sandbox's lifetime, so the kernel answers - * instead. + * A denyWrite path that does not exist is blocked by binding over a mount point + * bwrap makes on the host. Unlinking one while a sandbox is bound over it + * detaches the mount inside that sandbox, and the denied path can then be + * created on the host. So whether a sandbox still runs is asked of the kernel: + * every wrap names its mount points in a manifest that bwrap holds a lock on. */ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // These tests list the manifests, attack them from inside a sandbox and @@ -143,8 +136,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The manifest of a wrap that named `paths`, written a minute ago by - * `writer`: a process that is gone, which makes it a finished one, unless - * told otherwise. + * `writer`: by default a process that is gone, which makes it a finished one. */ function manifestNaming(paths: string[], writer?: number): string { const dir = runtime.manifestDir() @@ -194,7 +186,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(lstatSync(LOCK).size).toBe(0) // A second process wraps the same deny path, runs a command and cleans - // up. On the counter this took the running sandbox's mount point away. + // up. const second = spawnSync( process.execPath, [ @@ -240,10 +232,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { await waitFor(SIGNAL) cleanupBwrapMountPoints() cleanupBwrapMountPoints() - // `force` is what the exit handler and reset() pass: everything this - // process made may go. A sandbox that is running under a manifest is - // not this process's to end, so that manifest and what it names stay, - // for a later cleanup here or in any other process. + // `force` is what the exit handler and reset() pass. A sandbox running + // under a manifest is not this process's to end, so that manifest and + // what it names stay. cleanupBwrapMountPoints({ force: true }) expect(lstatSync(LOCK).size).toBe(0) expect(existsSync(manifestOf(command))).toBe(true) @@ -308,9 +299,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'stays while a second sandbox that denies it runs, after the one it was made for has ended and been cleaned up after', async () => { - // The second wrap finds the file there. It is a mount point because a - // manifest names it, so the wrap covers it and names it too, and its - // own manifest keeps it once the first sandbox's is gone. + // The second wrap finds the file there. A manifest names it, so the wrap + // covers and names it too, and its own manifest keeps it once the first + // is gone. const upSecond = join(AREA, 'up-second') const goSecond = join(AREA, 'go-second') const holder = spawn( @@ -439,11 +430,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'are read-only in a sandbox that names no manifest of its own', async () => { - // A wrap with no absent deny path has no mount points and no manifest, - // and the manifests in the directory are then other sandboxes'. The - // collect that runs on the host believes them, so a command able to - // write one could have any empty file removed, and one able to delete - // one could make a running sandbox's mount points look unused. + // A wrap with no absent deny path has no manifest, and the manifests in + // the directory are then other sandboxes'. A collect on the host believes + // them, so the command must be able neither to write nor to delete one. const directory = dirname(manifestOf(await wrap('true'))) const before = readdirSync(directory).filter(n => n.endsWith('.json')) expect(before.length).toBeGreaterThan(0) @@ -483,12 +472,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 'are read-only, too, where a process that was started without a runtime directory keeps them', async () => { // A process started with $XDG_RUNTIME_DIR keeps its manifests under it, - // and one started without (from cron, over a plain ssh) under the temp - // dir. A sandbox of the first kind that may write the temp dir could - // delete and forge the manifests of the second, which a collect on the - // host then believed. So every wrap binds both names read-only, and - // makes the one that is not there so that the command cannot make it - // first. + // one started without under the temp dir. Every wrap binds both names + // read-only and makes the one that is missing, so a sandbox of the first + // kind cannot forge the manifests of the second. const RUN = join(BASE, 'run') const TMP = join(BASE, 'tmp') mkdirSync(RUN, { mode: 0o700 }) @@ -563,8 +549,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { timeout: 60000, }) // bubblewrap sets the mounts up before it opens the lock file, so the - // mount point is back on the host; the command it was made for never - // runs. + // mount point is back on the host; the command never runs. expect(result.status).not.toBe(0) expect(`${result.stdout}${result.stderr}`).toContain( 'Unable to open lock file', @@ -574,13 +559,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) - // ---- two commands of one process in flight ----------------------------- + // ---- two commands of one process in flight ---- // - // The caller cleans up once per command, when that command is over, and a - // call cannot tell which command it is for. So while a wrap is outstanding - // nothing this process made may go: the command that has not started yet - // still needs its manifest (bubblewrap opens it to take the lock) and its - // mount point. + // A call to clean up cannot tell which command it is for, so while a wrap is + // outstanding nothing this process made may go: the command that has not + // started still needs its manifest and its mount point. it.if(BWRAP_CAN_NAMESPACE)( 'a command wrapped and not yet started survives the clean-up after the one that finished first', @@ -616,11 +599,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a sandbox that takes its lock while another command is being cleaned up after keeps its manifest and its deny', async () => { - // The interleaving that chance produces when one command ends a few - // milliseconds after another was spawned, made certain: the collect - // looks at /proc/locks, where the second command's manifest is not yet - // locked, and before it goes on that command's sandbox comes up and - // takes the lock. + // The interleaving made certain: the collect reads /proc/locks while the + // second command's manifest is not yet locked, and that command's sandbox + // takes its lock before the collect goes on. const first = await wrap('true') const second = await wrap(heldCommand()) const manifest = manifestOf(second) @@ -701,9 +682,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(run(first).status).toBe(0) expect(existsSync(lockOfFirst)).toBe(true) // bubblewrap leaves it - // One call per command, as always. This one says which command it is - // for, so that command's mount point goes although the second is still - // outstanding, and the second's manifest is untouched. + // This call says which command it is for, so that command's mount point + // goes although the second is still outstanding. cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) expect(existsSync(lockOfFirst)).toBe(false) expect(existsSync(manifestOf(second))).toBe(true) @@ -720,9 +700,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a wrap that produced no command is not waited for by the clean-up after the next', async () => { - // It gave its count back when it threw: the caller has no command to - // clean up after, and one clean-up after the command that did run is - // all that is owed. + // It gave its count back when it threw, so one clean-up after the command + // that did run is all that is owed. const refused = wrapCommandWithSandboxLinux({ command: 'true', binShell: 'srt-no-such-shell', @@ -743,10 +722,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { - // Live: written by this process, which has not let go of it. The path - // was a mount point when that manifest was written; a file with content - // is nothing bubblewrap made, and /dev/null over it would hide it from - // the command it belongs to. + // Live: written by this process, which has not let go of it. A file with + // content is nothing bubblewrap made, and /dev/null over it would hide it + // from the command it belongs to. const first = await wrap('true') expect(first).toContain(`--ro-bind /dev/null ${LOCK}`) writeFileSync(LOCK, 'somebody wrote this\n') @@ -758,11 +736,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { - // Absent when the wrap asked what shape it has, there when it asked - // whether anything is: another sandbox started on the same path in - // between, its manifest published and its mount point made. Bound onto - // itself as the caller's own file it was named by this wrap nowhere, and - // went from under its sandbox at the other's clean-up. + // Absent when the wrap asked for its shape, there when it asked whether + // anything is: another sandbox started on the same path in between. const exists = fs.existsSync let made = false const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { @@ -929,9 +904,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `} catch {`, ` writeFileSync(${JSON.stringify(OUT)}, 'threw')`, `}`, - // Stays alive: a manifest of a process that is still running - // counts as live, so only letting go of it lets the mount point be - // collected here. + // Stays alive: a manifest of a running process counts as live, so + // only letting go of it lets the mount point be collected here. `await new Promise(resolve => setTimeout(resolve, 30000))`, ].join('\n'), ), @@ -941,9 +915,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { try { await waitFor(OUT) expect(readFileSync(OUT, 'utf8')).toBe('threw') - // This process names the same mount point and asks for the cleanup. Had - // the wrap that threw held on to its manifest, the process behind it - // being alive would have kept the mount point here. + // This process names the same mount point and asks for the cleanup. A + // wrap that threw must not have held on to its manifest. await wrap('true') cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) @@ -955,18 +928,13 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The one case where nothing but the kernel's lock keeps a mount point: the - * process that wrapped the command is GONE, and the sandbox it started is - * STILL RUNNING. The manifest's writer no longer vouches for it, the grace - * period has run out, and no count in anyone's memory knows about it; only - * bubblewrap's --lock-file, seen through /proc/locks, says the sandbox is - * there. Every test above has the wrapping process alive, which alone keeps + * process that wrapped the command is gone and the sandbox it started is still + * running. Every test above has the wrapping process alive, which alone keeps * the manifest live. * - * The wrap always asks for --die-with-parent, so a sandbox outlives the - * process that wrapped it only when something else is bubblewrap's parent: - * here a shell in a session of its own, as when a wrapped command is handed to - * a job runner, a terminal multiplexer or `nohup sh -c`, or when the wrapping - * process is killed and the `sh -c` it spawned the command through is not. + * The wrap always asks for --die-with-parent, so a sandbox outlives its wrapper + * only when something else is bubblewrap's parent: here a shell in a session of + * its own, as under a job runner, a terminal multiplexer or `nohup sh -c`. */ describe.if(isLinux)( 'A mount point whose sandbox outlives the process that wrapped it', @@ -1086,9 +1054,8 @@ describe.if(isLinux)( } /** - * Says it is up, waits to be told, tries the denied write, reports what - * that returned, and then STAYS: the sandbox is still running when it is - * killed further down. + * Says it is up, waits to be told, tries the denied write, reports the + * result, and then stays: the sandbox is still running when it is killed. */ function heldCommand(): string { return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` @@ -1096,8 +1063,8 @@ describe.if(isLinux)( /** * A process that wraps the held command, starts it under a shell in a - * session of its own, says who is who, waits for the sandbox to be up and - * then goes: `leave` is how. + * session of its own, waits for the sandbox to be up and then goes: `leave` + * is how. */ function writerScript(leave: string): string { return [ @@ -1173,7 +1140,7 @@ describe.if(isLinux)( /** * Has a process wrap the held command, start it and go, and checks the - * premise: that process is gone, and its sandbox is up and running. + * premise: that process is gone and its sandbox is running. */ async function sandboxWhoseWriterHasGone( leave: string, @@ -1222,9 +1189,8 @@ describe.if(isLinux)( `process.kill(process.pid, 'SIGKILL')`, 'SIGKILL', ], - // Its exit handler runs the cleanup, with `force`, under its own - // sandbox: everything the process made may go, and a sandbox that is - // still running under a manifest is not the process's to end. + // Its exit handler runs the cleanup with `force`: a sandbox still running + // under a manifest is not the process's to end. ['exits, running its exit-time cleanup', `process.exit(0)`, null], ] @@ -1243,11 +1209,9 @@ describe.if(isLinux)( expect(written.pid).toBe(info.writer) expect(written.paths).toContain(LOCK) - // Whether the mount point bwrap made is on the host, and the - // manifest that names it where the wrap put it, after each thing - // that might have taken them. Looked at together further down, so - // that a failure says all of what happened and not only the first - // of it. + // Whether the mount point and the manifest that names it are still + // there after each thing that might have taken them. Checked together + // further down, so a failure says all of what happened. const BOTH = 'the mount point and its manifest' const stayed: Record = {} const look = (when: string): void => { @@ -1276,11 +1240,10 @@ describe.if(isLinux)( cleanupBwrapMountPoints({ force: true }) look('after this process collected, twice') - // The sandbox is still there. Let it try the denied write: it must - // fail inside, and nothing may land on the host. (A mount point - // that was removed and made again by the second wrap is on the host - // again, but is no longer what this sandbox is bound over, so only - // the write tells.) + // The sandbox is still there. Its denied write must fail inside, and + // nothing may land on the host. (A mount point removed and made again + // by the second wrap is on the host but is no longer what this + // sandbox is bound over, so only the write tells.) expect(runningTree(info.launcher).length).toBeGreaterThan(1) expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ stayed: { @@ -1299,9 +1262,8 @@ describe.if(isLinux)( expect(runScript('collector.ts', collectorScript())).toBe(0) expect(existsSync(LOCK)).toBe(true) - // Kill the sandbox. However it ends the kernel drops the lock, and - // the next collect, by anyone, takes the mount point and the - // manifest away. + // Kill the sandbox. The kernel drops the lock, and the next collect + // by anyone removes the mount point and the manifest. await killTree(info.launcher) launcher = undefined expect(runScript('collector.ts', collectorScript())).toBe(0) @@ -1315,12 +1277,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'holds its deny while another process collects without pause, with the process that wrapped it gone', async () => { - // A collector that never has a candidate proves nothing, and with the - // wrapping process alive it never has one: the writer vouches for the - // manifest whatever the kernel says. Here the writer is gone and the - // grace is over before the write is tried, so for most of each round + // A collector that never has a candidate proves nothing. Here the + // writer is gone and the grace is over before the write is tried, so // the lock is all that stands between the collector and the mount - // point, and the collector is asking the whole time. + // point. const loopFile = join(BASE, 'collect-loop.ts') writeFileSync( loopFile, @@ -1366,8 +1326,8 @@ describe.if(isLinux)( /** * The mount point for a deny whose first missing component is not the leaf is - * an empty DIRECTORY, bound from an empty read-only one. On the host it looks - * like anyone's empty directory, so only a manifest says what it is. + * an empty directory. On the host it looks like anyone's, so only a manifest + * says what it is. */ describe.if(isLinux)( 'A directory mount point a running sandbox relies on', @@ -1455,9 +1415,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied by a second wrap whose deny path is exactly that directory', async () => { - // A live manifest names it, which used to get it /dev/null bound over - // it like a file mount point: bubblewrap cannot put a character device - // over a directory, and the command did not start. + // A live manifest names it. /dev/null cannot be bound over a directory: + // bubblewrap would refuse to start. const up = join(AREA, 'up-holder') const go = join(AREA, 'go-holder') const held = holder(up, go) @@ -1485,12 +1444,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied again by the same list, with the earlier wrap of this process not yet cleaned up after', async () => { - // No second process and nothing running: a manifest is live for as - // long as the process that wrote it has not let go of it. A deny list - // that names a missing directory and something beneath it makes the - // directory the mount point, and the same list again then finds it - // named by a live manifest. It is what the mandatory denies under - // `/.claude` make of a `denyWrite` on `/.claude` itself. + // No second process: a manifest is live while the process that wrote it + // has not let go of it. A deny list naming a missing directory and + // something beneath it makes the directory the mount point, and the + // same list again finds it named by a live manifest. const first = run(await wrap('echo first-ran', [DIRECTORY, LEAF])) expect(first.stdout).toContain('first-ran') expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) @@ -1515,10 +1472,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while that second sandbox runs, after the sandbox that made it has ended and been cleaned up after', async () => { - // Bound onto itself and not named, it went with the sandbox that made - // it: the bind came off inside the second sandbox, the denied - // directory could be made again there, and what was written under it - // landed on the host. + // Bound onto itself and not named, it would go with the sandbox that + // made it, and the denied directory could be made again inside the + // second sandbox. const upHolder = join(AREA, 'up-holder') const goHolder = join(AREA, 'go-holder') const upSecond = join(AREA, 'up-second') @@ -1565,11 +1521,10 @@ describe.if(isLinux)( 90000, ) - // ---- left by a process that was killed ------------------------------ + // ---- left by a process that was killed ---- // - // Its manifest is finished, not live: the writer is gone, nothing holds - // the lock, the grace is over. The next clean-up in any process removes - // the directory on that manifest's word, so a wrap that denies the + // Its manifest is finished, not live. The next clean-up in any process + // removes the directory on that manifest's word, so a wrap that denies the // directory in the meantime has to name it for itself. /** What a process killed a minute ago left in the manifest directory. */ @@ -1608,17 +1563,16 @@ describe.if(isLinux)( mkdirSync(DIRECTORY) const left = manifestOfAKilledProcess([DIRECTORY]) - // The list that does this: the directory itself, beside something - // beneath it, which needs no mount point of its own under a directory - // that is read-only. So the directory stays empty on the host. + // The directory itself, beside something beneath it, which needs no mount + // point of its own under a read-only directory. const command = await wrap('true', [DIRECTORY, LEAF]) expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) expect(command).not.toContain(LEAF) expect(namedBy(command)).toEqual([DIRECTORY]) - // A second command of this process, and the clean-up after it: the - // first is still outstanding, so nothing of this process's is released, - // and what the killed process left is collected. + // A second command of this process and the clean-up after it: the first + // is still outstanding, so nothing of this process's is released, and + // what the killed process left is collected. await wrap('true', []) cleanupBwrapMountPoints() expect(existsSync(left)).toBe(false) @@ -1662,10 +1616,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up', async () => { - // Bound onto itself and not named, it went at the next clean-up in - // any process: the bind came off inside the running sandbox, the - // denied directory could be made again there, and what was written - // under it landed on the host. + // Bound onto itself and not named, it would go at the next clean-up in + // any process, from under the running sandbox. const script = (name: string, lines: string[]): string => { const file = join(BASE, name) writeFileSync(file, lines.join('\n')) @@ -1745,13 +1697,11 @@ describe.if(isLinux)( ) /** - * The manifest directories, the store of fake files and the empty directory - * the placeholders bind from are each bound read-only in a sandbox, and what - * is found at their names is believed on the host. The bind keeps a command - * out of the directory; it does not keep the name leading to it. Where the - * temp dir (or the runtime directory) lies strictly inside a write root, the - * directories between the two are the command's to rename, bind and all, and - * to make again: so they are pinned, like those above a deny path. + * The manifest directories, the fake-file store and the empty placeholder + * source are bound read-only in a sandbox, and what is found at their names is + * believed on the host. The bind keeps a command out of the directory, not the + * name leading to it: where the temp dir lies strictly inside a write root the + * directories between the two are pinned. */ describe.if(isLinux)( 'The directories above the ones this library keeps on the host', @@ -1782,7 +1732,7 @@ describe.if(isLinux)( /** * Runs `lines` in a process of its own whose temp dir is TMP. An `env` - * entry that is undefined is one that process does not have. + * entry that is undefined is removed from its environment. */ function inAChild( name: string, @@ -1928,9 +1878,8 @@ describe.if(isLinux)( }) it('are pinned where the temp dir really is, when its name leads there through a link', () => { - // A pin is a mount, and a mount lands where its path resolves. On the - // name it would be a mount on a link, which bubblewrap refuses from - // 0.12 on. + // A pin is a mount, and a mount lands where its path resolves; on the + // name it would be a mount on a link, which bubblewrap refuses. const real = join(HOME, 'real-tmp') const through = join(HOME, 'tmp-link') mkdirSync(real) @@ -1964,10 +1913,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', async () => { - // What the command is after: the next clean-up on the host reads the - // directory at the name, finds no live manifest there that claims - // GUARD, and removes it from under the sandbox that relies on it, on - // the word of a manifest the command wrote. + // What the command is after: a clean-up on the host that reads the + // directory at the name, finds no live manifest claiming GUARD, and + // removes it from under the sandbox that relies on it. const theirs = join(TMP, `srt-mount-points-${UID}`) const up = join(HOME, 'up') const go = join(HOME, 'go') @@ -2049,13 +1997,10 @@ describe.if(isLinux)( ) /** - * A bind is made where its destination really is. bubblewrap makes the - * destination by name inside the new root before it mounts on it, and a link - * on the way whose target is absolute leads out of that root, to nothing: - * "Can't mkdir". The manifest directories were bound at the name the - * environment gave for the temp dir, so with a temp dir reached through such - * a link (a `/tmp` that is a link to `/var/tmp`, a `TMPDIR` that is one) no - * command that restricts writes started at all. + * A bind is made where its destination really is: bubblewrap makes the + * destination by name inside the new root, and a link with an absolute target + * on the way leads out of that root. So with a temp dir reached through such a + * link, every manifest directory is bound at its real path. */ describe.if(isLinux)( 'The manifest directory under a temp dir whose name is a link', @@ -2083,9 +2028,9 @@ describe.if(isLinux)( }) /** - * What a process whose temp dir is `through`, and which has no runtime - * directory, wraps `command` as with that deny list, and what the command - * said when run. + * What a process whose temp dir is `through`, with no runtime directory, + * wraps `command` as with that deny list, and what the command said when + * run. */ function wrappedAndRun( through: string, @@ -2255,12 +2200,10 @@ describe.if(isLinux)( ) /** - * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID - * is, and /proc/locks leaves out every lock whose holder has no pid in the - * namespace of the /proc being read. A process that cleans up from another PID - * namespace while sharing the manifest directory - a container beside the - * host, one srt inside another's sandbox - sees a running sandbox's manifest - * exactly as it would see one a dead process left. + * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID, + * and /proc/locks, which leaves out every lock whose holder has no pid there. A + * process cleaning up from another PID namespace that shares the manifest + * directory sees a running sandbox's manifest as it would see a dead process's. */ describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', @@ -2370,9 +2313,8 @@ describe.if(isLinux)( writeScript('collector.ts', [ `import { existsSync, readFileSync, statSync } from 'node:fs'`, `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - // The premise, so that a pass means something: from here the - // kernel's list really does leave the sandbox's lock out, and - // the writer's pid is nobody's. + // The premise: from here the kernel's list really does leave + // the sandbox's lock out, and the writer's pid is nobody's. `const inode = statSync(${JSON.stringify(manifest)}).ino`, `if (readFileSync('/proc/locks', 'utf8').includes(':' + inode + ' ')) process.exit(7)`, `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 58fa38c8a..61ba353c0 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -35,8 +35,8 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo /** * The manifests and the pass that collects on their word, below the level of a * sandbox: what a pass believes, what it looks at again before it removes - * anything, and that nothing about the directory's lock can hold a process up - * or be relied on. + * anything, and that the directory's lock neither holds a process up nor is + * relied on. */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() @@ -124,10 +124,9 @@ describe.if(isLinux)('The mount point manifests', () => { /** * Runs `source` with the module as `m` in a process of its own, which is - * killed if it has not ended by itself: what these cases guard against blocks - * the thread it happens on, so a test in this process could not time it out. - * `first` runs before the module is loaded, and an `env` entry that is - * undefined is one the child does not have. + * killed if it does not end by itself: what these cases guard against blocks + * the thread. `first` runs before the module is loaded, and an `env` entry + * that is undefined is removed from the child's environment. */ function inAChild( source: string, @@ -166,14 +165,12 @@ describe.if(isLinux)('The mount point manifests', () => { const COLLECT = `m.collectMountPoints()` const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` - // ---- the directory's lock never holds a process up --------------------- + // ---- the directory's lock never holds a process up ---- // - // Whatever is at the lock's name, a collect and a publish both come back: - // by the deadline when it is somebody's lock, and at once when it is nothing - // this process could ever take. Each used to go round for ever, on the one - // thread, in the wrap, in the clean-up after a command and in the exit - // handler. "At once" is held to well under the two seconds somebody's lock - // is waited for: the wait is made four times over by one command. + // Whatever is at the lock's name, a collect and a publish both come back: by + // the deadline when it is somebody's lock, and at once when it is nothing + // this process could ever take. "At once" is held to well under the + // two-second wait, which one command makes four times over. const AT_ONCE_MS = 1500 const notALock: [string, () => void, boolean][] = [ @@ -227,10 +224,9 @@ describe.if(isLinux)('The mount point manifests', () => { it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( 'comes back from a collect at once where the directory is read-only from here, and collects nothing on its word', () => { - // What a process inside a sandbox sees of the directory: bound - // read-only, with a lock in it that it could never remove. It keeps its - // own manifests elsewhere, and what is in this one is not for it to - // judge: it could remove the mount point and not the manifest. + // What a process inside a sandbox sees of the directory: bound read-only, + // with a lock in it that it could never remove. It keeps its own + // manifests elsewhere and does not judge what is in this one. const lock = `${deadPid()} 1 ${OWN_NAMESPACE}\n` writeFileSync(LOCK, lock) leftover(X) @@ -276,11 +272,9 @@ describe.if(isLinux)('The mount point manifests', () => { }, 15000) it('believes a lock that names nobody until it is old, and breaks it then', () => { - // An empty lock file is what a lock made in two steps looked like for a - // moment, and a waiter that read it then took it for nobody's and removed - // it at once: two processes inside. The lock is made whole now, and one - // that says nothing is believed like any other whose holder cannot be - // asked after. + // An empty lock file says nothing about its holder. It is believed like any + // other lock whose holder cannot be asked after, not taken for nobody's and + // removed. writeFileSync(LOCK, '') leftover(X) manifestOfADeadProcess([X]) @@ -351,11 +345,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('keeps a mount point named by a manifest that is published while the pass is under way', () => { - // The lock does not exclude: its holder can be held up past the age at - // which it is broken, breaking one is not atomic, and a publish that - // cannot have it goes ahead without. So a manifest can appear between a - // pass listing the directory and its removals, naming a path the pass is - // about to remove, with a sandbox starting under it. + // The lock does not exclude, so a manifest can appear between a pass + // listing the directory and its removals, naming a path the pass is about + // to remove, with a sandbox starting under it. leftover(X) manifestOfADeadProcess([X]) let published: { status: number | null; stdout: string } | undefined @@ -380,8 +372,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('gives up only a lock that is still its own', () => { // Held up for longer than a lock is believed, a pass finds on its way out - // that the lock at that name is somebody else's by now. Removing it by - // name let a third process in beside the second. + // that the lock at that name is somebody else's by now, and must leave it. const theirs = `${process.ppid} 1 pid:[1]\n` let mine = '' const pass = duringTheNextPass(() => { @@ -427,9 +418,8 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('looks at /proc/locks a bounded number of times, not once for every mount point', () => { - // The list is the host's, and reading all of it for each removal made a - // pass over many mount points take seconds, under a lock whose waiters - // give up after two. + // The list is the host's: reading all of it for each removal would make a + // pass over many mount points outlast the lock's waiters. const many = 500 const paths: string[] = [] for (let i = 0; i < many; i++) { @@ -453,8 +443,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(removed.length).toBe(many) // One to begin with, one before the first removal, one for every 64 // removals after that, and one more for every two milliseconds the pass - // took, since a look goes out of date with time as well. No fewer than - // that either: one look is not good for ever. + // took. No fewer either: one look is not good for ever. expect(looks).toBeGreaterThanOrEqual(1 + Math.ceil(many / 64)) expect(looks).toBeLessThanOrEqual( 3 + Math.ceil(many / 64) + Math.ceil(tookMs / 2), @@ -467,7 +456,7 @@ describe.if(isLinux)('The mount point manifests', () => { () => { // Seen read-only from here, as from inside a sandbox that may not write // the directory it is in. The manifest is all that says the file is a - // mount point and whose; with it gone the file stayed for good. + // mount point, so it must stay while the file does. const readOnly = join(BASE, 'read-only-from-here') mkdirSync(readOnly) const inside = join(readOnly, 'config.lock') @@ -486,14 +475,11 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) - // ---- a manifest goes last, this process's own like anybody's ----------- + // ---- a manifest goes last, this process's own like anybody's ---- // - // A manifest is all that names its mount points. One this process wrote - // used to be unlinked as soon as the caller said its command was over, - // before the pass knew whether it would get as far as the removals or - // whether they would work; after a pass that turned back, or a removal that - // was refused, nothing on disk named the mount points and no later pass - // removed them. + // A manifest is all that names its mount points. It stays until a pass has + // removed what it names: after a pass that turned back, or a removal that was + // refused, a later pass still has it to go by. it.if(NOT_ROOT)( 'keeps the manifest of a wrap of its own whose mount point it could not remove, for a pass that can', @@ -522,8 +508,8 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = publishMountPointManifest([X], [])!.file // Another version of this library publishes, in a layout this one cannot - // read, after the pass has listed the directory: what that names cannot be - // kept path by path, so the pass stops short of removing anything. + // read, after the pass has listed the directory: the pass stops short of + // removing anything. const stranger = join(DIR, '4242-0123456789abcdef.json') const pass = duringTheNextPass(() => writeFileSync(stranger, '{"version":2}'), @@ -586,8 +572,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('keeps what a finished manifest names when its lock shows on the look before the removals', () => { // The bubblewrap a wrap has handed to its caller takes its lock whenever - // the caller starts it, which no lock of this library's can put off: the - // kernel is asked again before anything goes. + // the caller starts it, so the kernel is asked again before anything goes. leftover(X) const manifest = manifestOfADeadProcess([X]) const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n` @@ -614,10 +599,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('lists the directory again between removals, not only before the first', () => { - // A sandbox about to start on a path shows as a manifest that was not - // there when the pass began, and it can appear at any point of a pass over - // many mount points. One listing is good for a quarter of a millisecond, - // far less than a sandbox needs to get to its binds. + // A sandbox about to start on a path shows as a manifest that was not there + // when the pass began, at any point of a pass over many mount points. One + // listing is good for a quarter of a millisecond. const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -628,8 +612,8 @@ describe.if(isLinux)('The mount point manifests', () => { unlink(file) if (file === X && arrived === undefined) { // Published the moment the first mount point has gone, with the pass - // held up for a millisecond before it goes on to the next: less than - // the two for which it trusts what it read from /proc/locks. + // held up for a millisecond before the next: less than the two for + // which it trusts what it read from /proc/locks. arrived = manifestOfADeadProcess([Y], { pid: process.pid, created: Date.now(), @@ -738,12 +722,11 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(young)).toBe(true) }) - // ---- liveness is relative to a PID namespace --------------------------- + // ---- liveness is relative to a PID namespace ---- // - // /proc/locks lists a lock only when its holder has a pid in the namespace - // of the /proc being read, and /proc/PID is local to it. From another - // namespace a running sandbox's manifest looks exactly like one a dead - // process left. + // /proc/locks lists a lock only when its holder has a pid in the namespace of + // the /proc being read, and /proc/PID is local to it. From another namespace + // a running sandbox's manifest looks like one a dead process left. it.each([ ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], @@ -808,9 +791,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'is not kept live by a lock on a file with the same inode number on another filesystem', () => { // Inode numbers are per filesystem, and on a young tmpfs they are small. - // A manifest whose number some unrelated locked file shared read as - // live for as long as that lock was held: never released, and every - // path it named a mount point to every later wrap. + // A manifest that shares its number with an unrelated locked file on + // another filesystem must not read as live. leftover(X) const manifest = publishMountPointManifest([X], [])!.file const inode = statSync(manifest).ino @@ -860,8 +842,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'are never kept under /dev, which the sandbox mounts afresh over them', () => { // bubblewrap opens the manifest after its mounts, and the wrap mounts a - // new /dev after every bind: a manifest under /dev is not there to be - // opened, and every command with a mount point refused to start. + // new /dev after every bind: a manifest under /dev would not be there to + // be opened. const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) chmodSync(shm, 0o700) try { @@ -896,9 +878,8 @@ describe.if(isLinux)('The mount point manifests', () => { ) it("are not kept behind a link planted at the directory's name, whose target is left as it was", () => { - // The name is predictable and sits where sandboxed commands commonly - // write. A link there is refused, not followed: its target is somebody's - // directory, and would have had its mode changed. + // The name is predictable and sits where sandboxed commands commonly write. + // A link there is refused, not followed. const target = join(BASE, 'somebody-elses') mkdirSync(target) chmodSync(target, 0o755) @@ -918,8 +899,7 @@ describe.if(isLinux)('The mount point manifests', () => { 'go to a directory this process can write when the usual one is read-only from here', () => { // A process inside a sandbox sees the directory bound read-only. It is - // ours in every other respect, and used to be settled on, after which - // every wrap of that process failed to record its mount points. + // ours in every other respect, and must not be settled on. const elsewhere = TMP const published = inAChild(PUBLISH, { launcher: [ @@ -942,12 +922,10 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) - // ---- what is read as a manifest ---------------------------------------- + // ---- what is read as a manifest ---- // - // Whatever ends in `.json` in the directory used to be opened as it stood: - // a link was followed to wherever it led, and a FIFO held the thread for - // good, in the wrap, in the clean-up and in the exit handler. Only a - // regular file of the user's, of a size a manifest can have, is one. + // Only a regular file of the user's, of a size a manifest can have, is read: + // a link is not followed and a FIFO is not opened. /** Thirty seconds old: nothing can be starting under it any more. */ function aged(file: string): string { @@ -1061,12 +1039,11 @@ describe.if(isLinux)('The mount point manifests', () => { }) }, 15000) - // ---- which mount points a running sandbox relies on -------------------- + // ---- which mount points a running sandbox relies on ---- // - // A caller that removes paths of its own accord after a command removes by - // path, and removing a mount point from under a running sandbox lifts the - // deny there. So it can ask first which paths to leave out, and what it is - // told to leave out is never more than an empty placeholder. + // A caller that removes paths itself after a command can ask first which to + // leave out. What it is told to leave out is never more than an empty + // placeholder. /** The set, as a sorted list. */ function live(): string[] { @@ -1200,10 +1177,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('holds what any manifest names while a manifest that cannot be read may have a sandbox under it', () => { - // What another version of this library wrote, say. What it names cannot - // be listed, and whether the sandboxes of the others are over cannot be - // told apart from it, for as long as the clean-up takes it to be live: - // while it is new, or a lock is on it. + // What another version of this library wrote, say. What it names cannot be + // listed, so while the clean-up takes it to be live every named path + // counts. const written = join(BASE, 'written-to') writeFileSync(written, 'mine') chmodSync(written, 0o444) @@ -1328,8 +1304,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('does nothing at all where the platform is not Linux', () => { // Every clean-up after a command, on every platform, comes through here. - // Off Linux it made the directory and its lock each time, and on a - // platform with no uids a fresh temporary directory each time. + // Off Linux it must make nothing. leftover(X) manifestOfADeadProcess([X]) const before = readdirSync(DIR).sort() diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 7f3d3e091..1e07417ce 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -47,18 +47,14 @@ function run( /** * A denyWrite path that does not exist gets `--ro-bind /dev/null `, and - * bwrap creates the mount point for it on the host: an empty file, mode 0444. - * A cleanup removes it once no sandbox relies on it. A process killed before - * it can clean up leaves the file behind, and it used to stay for good: every - * later wrap saw an existing file, bound it onto itself and never removed it. - * For a path whose existence is its meaning that is a lasting fault on the - * host: a leftover `.git/config.lock` makes every `git config` write outside - * the sandbox fail with "could not lock config file". + * bwrap creates the mount point for it on the host: an empty file, mode 0444. A + * process killed before it can clean up leaves the file behind. For a path + * whose existence is its meaning that is a lasting fault: a leftover + * `.git/config.lock` makes every `git config` write outside the sandbox fail. * * The killed process leaves its manifest as well, and that is what says the - * file is a leftover. The file alone does not: an empty read-only file is - * also what somebody keeps at such a path on purpose, and one taken for a - * leftover by its shape was removed after the first command. + * file is a leftover. The file alone does not: an empty read-only file is also + * what somebody keeps at such a path on purpose. */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const runtime = usePrivateManifestDirectory() @@ -106,8 +102,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } /** - * The manifest of a wrap that named `paths`, written by `pid`: a process - * that is gone, a minute ago, unless told otherwise. Where it is written. + * The manifest of a wrap that named `paths`, written a minute ago by `pid`, + * by default a process that is gone. Returns where it is written. */ function manifestNaming( paths: string[], @@ -188,11 +184,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { await wrap([LOCK]) await wrap([LOCK]) - // Two wraps handed out, one cleaned up after: the other command may not - // have started yet, and a command that starts after its mount point or - // its manifest has gone either refuses to start or runs without its deny. - // A call cannot tell which command it is for, so this process gives up - // nothing of its own until it has been called for both. + // Two wraps handed out, one cleaned up after. A call cannot tell which + // command it is for, so this process gives up nothing of its own until it + // has been called for both. cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) @@ -255,10 +249,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { }, ) - // ---- an empty read-only file that no manifest names -------------------- + // ---- an empty read-only file that no manifest names ---- // - // It is the caller's own, whatever it looks like: an empty `.mcp.json` of - // mode 0444, a lockfile somebody keeps in place. It is denied like any + // It is the caller's own, whatever it looks like. It is denied like any // existing path, by a read-only bind onto itself, and nothing ever removes // it. @@ -331,9 +324,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } expect(namedBy(command)).not.toContain(mandatory) expect(namedBy(command)).not.toContain(LOCK) - // The wrap has mount points of its own, for the mandatory denies that - // are absent: the clean-up below has something to remove, and removes - // that and nothing else. + // The wrap has mount points of its own, for the mandatory denies that are + // absent: the clean-up below removes those and nothing else. expect(namedBy(command)).toContain(join(project, '.bashrc')) const session = run(command, project) @@ -356,11 +348,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { ) it('is asked after again, where the manifests were read before another sandbox named it and made it', async () => { - // A wrap reads the manifests once. What was read before the file was - // there cannot say whose it is: a sandbox that started in between - // published its manifest first and had bubblewrap make the file after. - // Taken for the caller's own it was bound onto itself and named by this - // wrap nowhere, and went from under its sandbox at the other's clean-up. + // A wrap reads the manifests once. What was read before the file was there + // cannot say whose it is: a sandbox that started in between published its + // manifest first and had bubblewrap make the file after. const earlier = join(GIT_DIR, 'earlier.lock') plantLeftover(earlier) const exists = fs.existsSync @@ -387,9 +377,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it('is taken for absent where it has gone, with the manifest that named it, while the wrap was looking', async () => { // Seen with the shape of a mount point, and named by no manifest a moment - // later: the sandbox it was made for has ended and been cleaned up after - // in between. Bound onto itself it was a source that is not there, and - // the command did not start. + // later: the sandbox it was made for has ended and been cleaned up after in + // between. const left = plantLeftover(LOCK) const readdir = fs.readdirSync let collected = false @@ -522,9 +511,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) // A manifest whose writer is gone and that no sandbox has locked counts - // as live for half a second, in case the sandbox it covers is starting: - // the killed process left one, so the cleanup that takes its mount - // point away is the first one after that. + // as live for half a second, so the cleanup that takes its mount point + // away is the first one after that. await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) From 0795878637e4a80bf54b3c9bd20357db3960199b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 11:37:44 +0000 Subject: [PATCH 080/141] Shorten the comments around the manifest directories Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. The same standard as on the branch this one is stacked on, applied to the comments this branch rewrites and adds. --- src/sandbox/bwrap-mount-manifests.ts | 754 ++++++++------------- src/sandbox/linux-sandbox-utils.ts | 180 ++--- src/sandbox/sandbox-manager.ts | 14 +- test/cli.test.ts | 5 +- test/control-fd.test.ts | 5 +- test/helpers/private-manifest-directory.ts | 109 ++- test/preload.ts | 7 +- test/sandbox/linux-bwrap-args-file.test.ts | 3 +- test/sandbox/mount-point-liveness.test.ts | 335 ++++----- test/sandbox/mount-point-manifests.test.ts | 176 ++--- test/sandbox/stale-mount-point.test.ts | 56 +- 11 files changed, 625 insertions(+), 1019 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 36f94d2ca..398fedda6 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -2,62 +2,41 @@ * Which mount points on the host a running sandbox still relies on (Linux). * * A deny on a path that does not exist needs a mount point there: bwrap makes - * an empty file (or, for an intermediate component, an empty directory) on the - * host and binds /dev/null or an empty directory over it. Unlinking that file - * while a sandbox is still bound over it detaches the mount inside that - * sandbox — the denied path can then be created, and what is written lands on - * the host. A mount point may therefore only be removed when no running - * sandbox relies on it, and nothing a process counts for itself can decide - * that: a second srt process, a caller that crashes, one that cleans up early, - * twice or never, each gets a different answer. + * an empty file or directory on the host and binds over it. Unlinking it while + * a sandbox is bound over it detaches the mount inside that sandbox, and the + * denied path can then be created on the host. So a mount point may be removed + * only when no running sandbox relies on it, and no process can decide that by + * counting for itself. * - * The kernel is asked instead. Each wrap writes a manifest naming the mount - * points it relies on into a per-user runtime directory and passes - * `--lock-file ` to bwrap. bubblewrap's sandbox init process opens - * that path and holds an fcntl read lock (F_RDLCK through F_SETLK) on it for - * exactly the sandbox's lifetime; the kernel drops the lock however the - * sandbox ends, SIGKILL included. /proc/locks then answers, for any process - * and at any moment, whether a sandbox that named a mount point is still - * running, so cleanup becomes a garbage collect any process may run at any - * time and any number of times: a mount point goes only when no live manifest - * names it. + * The kernel is asked instead. Each wrap writes a manifest naming its mount + * points into a per-user directory and passes `--lock-file ` to + * bwrap, whose sandbox init process holds a read lock on it for the sandbox's + * lifetime; the kernel drops it however the sandbox ends. /proc/locks then + * tells any process whether a sandbox that named a mount point is running, so + * cleanup is a garbage collect any process may run at any time. * - * The lock belongs to the sandbox's init process, not to the command: bwrap - * opens the manifest with O_CLOEXEC, so the descriptor does not survive the - * exec into the command, and a POSIX lock is only dropped by the process that - * took it. - * - * Two processes of one user keep each other's mount points only when each - * reads what the other writes, so where the manifests go is worked out from - * the user id and the file system before the environment, which differs - * between a login shell and what a service, an IDE or cron starts: see - * {@link manifestDirectoryCandidates}. A process writes to the first of those - * directories it can use, and believes what it finds in the ones that come - * from the user id, or, where it can use none of those, in the ones its - * environment names (see {@link believedManifestDirectories}). + * Two processes of one user keep each other's mount points only when each reads + * what the other writes, so where the manifests go is worked out from the user + * id and the file system before the environment, which differs between a login + * shell and what a service, an IDE or cron starts (see {@link + * manifestDirectoryCandidates}). A process writes to the first of those + * directories it can use and believes what it finds in the ones that come from + * the user id, or, where it can use none of those, in the ones its environment + * names (see {@link believedManifestDirectories}). * * Every one of them is bound read-only into every sandbox that restricts - * writes at all, whether or not its own invocation names a manifest, and - * whichever of them the wrapping process writes to (see - * {@link mountPointManifestDirectories}), so a sandboxed command can neither - * delete nor rewrite a manifest, its own or another sandbox's; one that is - * not there is made first, so that the command cannot make it itself, with - * manifests of its own in it; and the directories between each of them and - * the write root it lies in are pinned there, so that the command cannot - * rename one aside and make the directory again under the old name. - * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with a directory writable, the sandbox of a process with - * another environment where a process has to keep its manifests under a name - * its own environment gives, and a directory whose name, or a name above it, - * is a link inside a write root, or whose parent is not there yet below one: - * neither can be pinned. + * writes, and is made first if it is missing, and the directories above it + * inside a write root are pinned, so a sandboxed command can neither rewrite a + * manifest nor make or swap the directory (see {@link + * mountPointManifestDirectories}). Not covered: a sandbox this library did not + * start, the sandbox of a process with another environment where a process has + * to keep its manifests under a name its own environment gives, and a directory + * whose name, or a name above it, is a link inside a write root or whose parent + * is not there yet. * - * Both of the kernel's answers are relative to a PID namespace: /proc/locks - * lists a lock only when its holder has a pid in the namespace of the /proc - * being read, and /proc/PID is local to it. A manifest therefore says which - * PID namespace wrote it, and a process in another one does not judge it at - * all: to that process it is live, and only a process in the namespace that - * wrote it, which can see both answers, ever collects what it names. + * /proc/locks and /proc/PID are relative to a PID namespace, so a manifest + * records the namespace that wrote it and only a process in that namespace + * judges it; to any other it is live. */ import { randomBytes } from 'node:crypto' @@ -73,14 +52,9 @@ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' /** - * A manifest written this recently counts as live even with no lock on it and - * its writer gone. bubblewrap takes the lock in the sandbox's init process, - * which it forks alongside the command, so a wrapping process killed between - * the exec and that fcntl would otherwise leave a starting sandbox's mount - * point unclaimed. The window to cover is bubblewrap's own startup, a few - * milliseconds; this is two orders of magnitude more, and short enough that a - * mount point a killed process left behind is collectable while the command - * after it is still being wrapped. + * A manifest this young counts as live with no lock on it. bubblewrap takes the + * lock a few milliseconds after the wrap returns; a wrapping process killed in + * between must not leave a starting sandbox's mount point unclaimed. */ const MANIFEST_GRACE_MS = 500 @@ -91,62 +65,43 @@ const DIRECTORY_LOCK_WAIT_MS = 2_000 const DIRECTORY_LOCK_RETRY_MS = 25 /** - * A directory lock this old is broken whoever holds it, a holder that is - * still running included: far past the milliseconds a collect takes. It is - * the only way out for a holder that cannot be asked after, one in another - * PID namespace or one whose lock says nothing this process can read. A - * holder in this namespace is asked after directly, and the lock of one that - * is gone is broken at once, whatever its age. + * A directory lock this old is broken whoever holds it: the only way out for a + * holder that cannot be asked after (another PID namespace, unreadable + * content). A holder that is gone is broken at once. */ const DIRECTORY_LOCK_STALE_MS = 60_000 /** - * How many mount points a pass removes on one look at /proc/locks, and for how - * long one look is good. Each look reads all of /proc/locks, whose size is the - * host's and not this library's, so one per removal made a pass over many - * mount points on a busy host outlast the wait above. A removal takes some ten - * microseconds, and a sandbox takes several milliseconds, about four on a fast - * machine, to get from its manifest being published to its binds being in - * place (the wrap returns, the caller spawns a shell, bubblewrap sets up its - * namespaces), so a look shared by this many removals, under a millisecond's - * worth, is as good as one each. The time limit is for a pass that is held up - * between two removals, which then looks again before the next, and is kept - * under that start-up time. + * How many mount points a pass removes on one read of /proc/locks, and for how + * long one read is good. A sandbox needs several milliseconds from its manifest + * being published to its binds being in place, so both stay under that; one + * read per removal would make a pass on a busy host outlast the directory lock + * wait. */ const REMOVALS_PER_LOOK = 64 const LOCKS_GOOD_FOR_MS = 2 /** - * For how long one listing of the manifest directory is good. A manifest - * published for a sandbox that is about to start is the usual way for a pass - * to be out of date, and listing a directory of a few manifests costs less - * than one removal, so this is kept an order of magnitude under the time that - * sandbox needs to get to its binds, and not at the two milliseconds above. It - * is a time and not "before every removal" so that a directory of thousands of - * manifests, which takes milliseconds to list, is not listed thousands of + * For how long one listing of the manifest directory is good: well under the + * time a starting sandbox needs to reach its binds, and a time rather than + * "before every removal" so a directory of thousands is not listed thousands of * times. */ const LISTING_GOOD_FOR_MS = 0.25 /** - * A manifest whose contents cannot be read is dropped once it is this old and - * no sandbox holds its lock — long past anything that could still be using - * what it names, and long enough that another version of this library writing - * to the same directory keeps its manifests for as long as it needs them. + * An unreadable manifest with no lock on it is dropped once it is this old: + * long enough for another version of this library to keep its own format. */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 -/** - * The largest file that is read as a manifest. One names a wrap's mount - * points, some dozens of paths, and is a few kilobytes; whatever is larger - * than this is not one, and is not read into memory to find that out. - */ +/** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 /** - * What a manifest, and the directory lock, is written as before it is moved - * into place. A process killed between the two leaves the file behind, and a - * pass removes one that is as old as an unreadable manifest has to be. + * Suffix of a manifest or directory lock before it is moved into place. One + * left by a killed process is removed at {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ const TEMPORARY_SUFFIX = '.tmp' @@ -156,10 +111,8 @@ const ManifestSchema = z.object({ pid: z.number().int().nonnegative(), start: z.string(), /** - * The PID namespace the wrapping process was in, as `readlink - * /proc/self/ns/pid` names it. `pid` and the lock bubblewrap takes can only - * be asked after from there. Absent from a manifest written before the field - * existed, which is then from a namespace nobody can name. + * The writer's PID namespace (`readlink /proc/self/ns/pid`): `pid` and the + * lock can only be asked after from there. Absent in older manifests. */ ns: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ @@ -167,11 +120,9 @@ const ManifestSchema = z.object({ /** The mount points bwrap makes on the host for that wrap's deny paths. */ paths: z.array(z.string()), /** - * The empty directories those mount points bind FROM, which this library - * makes itself. A live bind's source must stay, so they go the same way the - * mount points do, and are held to what {@link removeMountSource} asks: a - * source that is no longer our own private empty directory is not ours to - * remove. + * The empty directories those mount points bind from. A live bind's source + * must stay, so they are collected the same way (see {@link + * removeMountSource}). */ sources: z.array(z.string()), }) @@ -182,23 +133,16 @@ type Manifest = z.infer & { inode: string device: string /** - * Whether this process has given the manifest up: it wrote it, the caller - * says the command is done, and nothing held the lock when the pass began. - * Its writer is running and it may be young, and neither keeps it: only a - * lock can still make it live. The file stays where it is until the end of - * the pass, like any other finished manifest's. + * This process wrote it, the caller says the command is over, and nothing + * held the lock when the pass began: only a lock can still make it live. */ released: boolean } /** - * Manifests this process wrote and that are still on disk as far as it knows, - * each with the key of the command it was wrapped for where the caller gave - * the wrap one, and whether the caller has said that command is over. That is - * remembered, not acted on at once: a pass can turn back half way, a removal - * can be refused, a sandbox can still hold the lock, and in each case the - * manifest has to stay on disk, since it is all that names the mount points, - * and go at a later pass whatever that pass is told to release. + * Manifests this process wrote that are still on disk, with the command key the + * caller gave and whether that command is over. Remembered rather than acted on + * at once: a manifest must stay on disk until a pass removes what it names. */ const ownManifests = new Map< string, @@ -206,18 +150,17 @@ const ownManifests = new Map< >() /** - * Which of this process's own manifests a collect may release. Releasing one - * says "the command this was wrapped for is over", which only the caller - * knows, so it is the caller that says which: + * Which of this process's own manifests a collect may release. Only the caller + * knows that a command is over: + * * - `all`: no wrap of this process is outstanding, or the process is ending; - * - `none`: some are, and nothing says which of them is over, so only what - * OTHER processes have finished with is taken away; - * - one command: that command is over, whatever else is still running. - * Releasing the manifest of a wrap whose command has not started yet is what - * must never happen: bubblewrap opens the manifest to lock it, and a command - * whose manifest is gone refuses to start, or, if it got its lock in the - * moment between the pass's last look at /proc/locks and the unlink, runs on - * with nothing on disk naming its mount points. + * - `none`: some are, and nothing says which is over, so only what other + * processes have finished with is collected; + * - one command: that command is over, whatever else is running. + * + * Never release the manifest of a wrap whose command has not started: + * bubblewrap opens the manifest to lock it, and the command would refuse to + * start or run with nothing on disk naming its mount points. */ export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } @@ -233,9 +176,8 @@ let manifestDirectoryUnavailable = false const onLinux = (): boolean => process.platform === 'linux' /** - * Field 22 of /proc/PID/stat, the process's start time in clock ticks. The - * comm field can hold spaces and parentheses, so the fields are counted from - * the last ')' rather than from the start of the line. + * Field 22 of /proc/PID/stat, the start time in clock ticks, counted from the + * last ')' because the comm field can hold spaces and parentheses. */ function startTimeFromProcStat(stat: string): string | undefined { return stat @@ -253,9 +195,8 @@ function processStartTime(pid: number): string | undefined { } /** - * Whether the process a manifest names is still running. Only "the process is - * gone" answers no: a /proc that cannot be read must not make a live sandbox's - * manifest collectable. + * Whether the process a manifest names is still running. An unreadable /proc + * answers yes: it must not make a live sandbox's manifest collectable. */ function writerIsRunning(pid: number, start: string): boolean { try { @@ -270,10 +211,7 @@ function writerIsRunning(pid: number, start: string): boolean { let pidNamespace: string | undefined | null = null -/** - * The PID namespace this process is in, as the kernel names it - * (`pid:[4026531836]`), or `undefined` where it cannot be told. - */ +/** This process's PID namespace as the kernel names it, or `undefined`. */ function ownPidNamespace(): string | undefined { if (pidNamespace === null) { try { @@ -285,10 +223,7 @@ function ownPidNamespace(): string | undefined { return pidNamespace } -/** - * The device half of what /proc/locks prints for a file, from the number stat - * gives for it: `major:minor`, in decimal. - */ +/** `major:minor` in decimal, as /proc/locks prints a device. */ function deviceOf(dev: bigint): string { const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) @@ -296,10 +231,8 @@ function deviceOf(dev: bigint): string { } /** - * The filesystems on which the device stat reports for a file is the one - * /proc/locks prints for a lock on it: both are the superblock's. It is not so - * everywhere - a btrfs subvolume and some overlay arrangements give stat a - * device of their own - and there only the inode number can be compared. + * Filesystems on which stat reports the device /proc/locks prints. Elsewhere (a + * btrfs subvolume, some overlays) only the inode number can be compared. */ const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ 0x01021994, // tmpfs, which is what /run/user/UID is @@ -326,18 +259,11 @@ type Locks = { } /** - * Whether /proc/locks lists a lock on this file. Any lock on a manifest is the - * one bubblewrap's sandbox init holds: nothing else opens one to lock it. - * - * The device is compared where the manifests' filesystem reports the one - * /proc/locks prints, and only there. Inode numbers are per filesystem, and on - * a tmpfs they are small and handed out in order, so a manifest in a young - * runtime directory can share its number with some unrelated locked file on - * another filesystem; it then read as live for as long as that lock was held, - * and every path it named as a mount point for every later wrap. - * Where the two devices cannot be compared the inode number alone decides, - * which errs towards "locked": the other way round would hide a live - * sandbox's lock. + * Whether /proc/locks lists a lock on this file; nothing but bubblewrap's + * sandbox init locks a manifest. The device is compared where it can be: inode + * numbers are per filesystem, and an unrelated locked file with the same number + * would otherwise keep a manifest live. Where it cannot, the inode alone + * decides, which errs towards "locked". */ function holdsLock( locks: Locks, @@ -349,12 +275,9 @@ function holdsLock( } /** - * What /proc/locks lists, for the manifests in `dirs`. Only the locks whose - * holder has a pid in the PID namespace of the /proc being read are listed, - * which is why a manifest from another namespace is never judged by this. - * - * `undefined` when the list could not be read, which every caller reads as - * "every manifest is locked". + * What /proc/locks lists. Only locks whose holder has a pid in this PID + * namespace appear. `undefined` when it cannot be read, which every caller + * treats as "every manifest is locked". */ function readLocks(dirs: readonly string[]): Locks | undefined { let text: string @@ -412,13 +335,10 @@ function isOurPrivateDirectory(dir: string): boolean { } /** - * Make `dir` a directory of ours alone, or say it cannot be. - * - * The name is predictable and sits where sandboxed commands commonly write, - * so what is found there is looked at before it is touched: through a - * descriptor opened without following a link, so that a symlink planted at - * the name is refused rather than having its target's mode changed, and the - * mode is set on that descriptor, never on the path. + * Make `dir` a directory of ours alone, or say it cannot be. The name is + * predictable and sandboxed commands can often write beside it, so it is opened + * without following a link and its mode is set on the descriptor: a symlink + * planted at the name is refused, not followed. */ function makeOurPrivateDirectory(dir: string): boolean { try { @@ -452,9 +372,8 @@ function makeOurPrivateDirectory(dir: string): boolean { } /** - * Whether a manifest kept under `dir` would be out of bubblewrap's reach: the - * wrap mounts a fresh /dev and /proc after every bind, which buries whatever - * was bound beneath them, and bubblewrap opens the manifest after its mounts. + * Whether bubblewrap could not reach a manifest under `dir`: the wrap mounts a + * fresh /dev and /proc over whatever was bound beneath them. */ function isBuriedBySandboxMounts(dir: string): boolean { return ['/dev', '/proc', '/sys'].some( @@ -463,9 +382,9 @@ function isBuriedBySandboxMounts(dir: string): boolean { } /** - * The places the manifest directories are looked for in. The first two are - * the same for every process of a user, whatever started it; the last two are - * what this process's environment says. + * The places the manifest directories are looked for in. The first two are the + * same for every process of a user; the last two are what this process's + * environment says. */ export type ManifestDirectoryPlaces = { /** The user's runtime directory where the system keeps one: /run/user/UID. */ @@ -492,9 +411,8 @@ function manifestDirectoryPlaces(): ManifestDirectoryPlaces { } /** - * Whether `dir` is a runtime directory of this user's as the system makes - * one: a real directory, not a link to one, the user's, and nobody else's to - * look into. + * Whether `dir` is a runtime directory of this user's as the system makes one: + * a real directory, not a link, the user's, and closed to everyone else. */ function isTheUsersRuntimeDirectory(dir: string): boolean { try { @@ -510,9 +428,8 @@ function isTheUsersRuntimeDirectory(dir: string): boolean { } /** - * Where the directory `name` names really is, as far as that can be told - * before it is made: under its parent as the links above it resolve, by its - * own last component. + * Where the directory `name` names really is, as far as can be told before it + * is made: its parent with links resolved, plus its own last component. */ function whereItReallyIs(name: string): string { try { @@ -523,8 +440,9 @@ function whereItReallyIs(name: string): string { } /** - * The names under which the processes of this user keep their manifests where - * several can find them, in the order they are tried for writing: + * The names under which processes of this user keep manifests that others can + * find, in the order tried for writing: + * * - `/run/user/UID/srt-mount-points`, where `/run/user/UID` is a runtime * directory of the user's (see {@link isTheUsersRuntimeDirectory}); * - `/tmp/srt-mount-points-UID`; @@ -532,17 +450,12 @@ function whereItReallyIs(name: string): string { * - `srt-mount-points-UID` under the system temp dir, which is `$TMPDIR`'s. * * The user id and the file system come first because they are the same for - * every process of the user. The environment is not: a login shell and what a - * service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, - * two shells can differ in `TMPDIR`, and two processes that each wrote where - * their own environment said never read each other's manifests. The second - * wrap then took the first's mount point for the user's own file, and the - * first's clean-up removed it from under the second sandbox. The names from - * the environment stay, last, for a process that can use neither of the first - * two. - * - * Two names for one directory are one, the earlier, by where they really are. - * One that a sandbox's own mounts would bury is left out. + * every process of the user. The environment is not: two processes that each + * wrote where their own environment said would never read each other's + * manifests, and one would remove the other's mount point from under its + * sandbox. The names from the environment stay, last, for a process that can + * use neither of the first two. Two names for one directory count once, and one + * that a sandbox's own mounts would bury is left out. */ function manifestDirectoryCandidates(): { name: string @@ -591,16 +504,11 @@ function sharedManifestDirectoryNames(): string[] { } /** - * The directory this process writes its manifests to: the first of the shared - * names (see {@link sharedManifestDirectoryNames}) that is, or can be made, a - * directory of ours alone that we can write, else - when none can, as in a - * sandbox that binds them read-only - a private directory only this process - * knows, which keeps the guarantee within this process and loses only what - * other processes would have read. - * - * Revalidated on every use: the temp dir is somewhere sandboxed commands - * commonly write, and a directory swapped for a symlink between two wraps - * would put manifests somewhere else entirely. + * The directory this process writes its manifests to: the first shared name + * (see {@link sharedManifestDirectoryNames}) that can be made ours alone, else + * a private directory only this process knows, which keeps the guarantee within + * this process. Revalidated on every use, because a directory swapped for a + * symlink between two wraps would send manifests elsewhere. */ function ensureManifestDirectory(): string | undefined { if ( @@ -666,28 +574,21 @@ function isOurDirectory(dir: string): boolean { } /** - * Every directory whose manifests this process believes, for a wrap, a - * clean-up and {@link liveMountPoints} to read: - * - each of the names that come from the user id that is there and is a - * directory of ours alone that we can write, whichever of them this process - * writes to; - * - the names the environment gives, held to the same, only where none of - * the first kind will do, which is where this process writes to one of - * them; - * - the one this process has settled on, whatever it is. + * Every directory whose manifests this process believes, for a wrap, a clean-up + * and {@link liveMountPoints} to read: * - * What the environment names is kept out of a sandboxed command's reach by - * the processes whose environment names it, and by no other: a process with - * another temp dir does not know of it, and its command may write there. A - * manifest planted that way would keep a path, or have one removed, on the - * word of a process that never relied on the directory, so it is believed - * only by a process that has nowhere else to keep its own. Nothing is lost by - * that: every process that can use a name from the user id meets every other - * one there. + * - each name that comes from the user id that is there and is a directory of + * ours alone that we can write; + * - the names the environment gives, held to the same, only where none of the + * first kind will do; + * - the one this process has settled on. * - * One that is a link, somebody else's, open to others or not to be written is - * passed over: what is in it is not believed, and nothing fails for it. - * Nothing is made. + * What the environment names is kept out of a sandboxed command's reach only by + * processes whose environment names it; a command of a process with another + * temp dir may write there. A manifest planted that way must not keep or remove + * a path, so such a directory is believed only by a process that has nowhere + * else to keep its own. One that is a link, somebody else's, open to others or + * not writable is passed over. Nothing is made. */ function believedManifestDirectories(): string[] { const usable = manifestDirectoryCandidates().filter(candidate => @@ -710,27 +611,18 @@ function believedManifestDirectories(): string[] { } /** - * Every directory a sandbox must not be able to write, because some process - * of this user keeps manifests in it and a collect on the host believes what - * it finds there: every shared name, as this process sees them, and the one - * this process has settled on where that is none of them. For a wrap to bind - * read-only, whether or not it has a manifest of its own. + * Every directory a wrap must bind read-only because some process of this user + * keeps manifests in it and a collect believes what it finds there: every + * shared name, plus the one this process settled on. * - * Every name, and not only the one this process writes to: every process of - * the user believes what is under the ones that come from the user id, and - * one with this environment that can use none of those what is under the - * ones the environment gives, so a sandbox with the temp dir writable could - * otherwise delete and forge manifests in the one under it while its own - * process writes under the runtime directory. Each is made if it can be, so - * that a sandboxed command cannot make it first and fill it before a process - * that will believe it comes along: the command runs as the user, so what it - * makes passes every check of owner and mode, which keep other users out and - * not the command. One that is there and the user's is listed even when it - * cannot be written from here. The last resort is not made for this: there is - * nothing to keep out of reach in a directory nobody has made. A wrap that is - * about to record says so with `recording`, and the directory its manifest - * will go to is then settled first, the last resort included: the wrap has to - * know every one of these before it publishes, to pin what lies above them. + * Every name, not only the one this process writes to: a sandbox with the temp + * dir writable could otherwise forge manifests in the directory under it while + * its own process writes under the runtime directory. Each is made if it can + * be, so a sandboxed command cannot make and fill it first: the command runs as + * the user, so what it makes passes every check of owner and mode. One that is + * there and the user's is listed even when it cannot be written from here. With + * `recording`, the directory this wrap's manifest will go to is settled first, + * so the wrap can pin what lies above it before it publishes. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -753,9 +645,8 @@ export function mountPointManifestDirectories(recording = false): string[] { } /** - * Take away the directory only this process knows, once nothing is left in - * it, when the process or its session is ending: nobody else would. With the - * manifest of a sandbox that is still running in it, it stays. + * Remove the directory only this process knows, once it is empty, when the + * process or its session ends. It stays while a manifest is in it. */ export function removePrivateManifestDirectory(): void { if (manifestDirectory === undefined || !manifestDirectoryIsPrivate) { @@ -776,25 +667,16 @@ function sleep(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -/** - * What a process leaves in the directory lock: who it is, and the PID - * namespace in which that can be asked after. - */ +/** What the directory lock holds: who took it, and in which PID namespace. */ function directoryLockContent(): string { return `${process.pid} ${processStartTime(process.pid) ?? '?'} ${ownPidNamespace() ?? '?'}\n` } /** - * Take the directory lock, and say what it now holds, or why it was not taken: - * `held` when something is at its name already, `unavailable` when this - * process cannot make one there at all. - * - * The lock comes into being whole. Made with O_EXCL and written to afterwards - * it stood empty for a moment, and a waiter that read it then found no holder - * in it and removed it at once, so two processes were inside together. It is - * written under another name and linked into place instead: link fails when - * the name is taken, exactly as O_EXCL does, and what it puts there is never - * seen half made. A filesystem without hard links gets no lock, and no pass. + * Take the directory lock: returns what it now holds, `held` when the name is + * taken, or `unavailable` when this process cannot make one there. The content + * is written under another name and hard-linked into place, so a waiter never + * reads a lock that is still empty and takes it for abandoned. */ function takeDirectoryLock( dir: string, @@ -827,9 +709,8 @@ function takeDirectoryLock( } /** - * Give the directory lock up, if it is still this process's. One broken for - * its age while this process was held up belongs to whoever took it next, and - * removing that one by name let a third process in beside it. + * Give the directory lock up if it is still this process's. One broken for its + * age meanwhile belongs to whoever took it next. */ function releaseDirectoryLock(lockFile: string, content: string): void { try { @@ -842,25 +723,22 @@ function releaseDirectoryLock(lockFile: string, content: string): void { } /** - * What stands at the directory lock's name, once taking it has failed: - * - `free`: nothing any more, or the lock of a holder that is gone, removed; + * What stands at the directory lock's name once taking it has failed: + * + * - `free`: nothing, or the lock of a holder that is gone, now removed; * - `held`: somebody's lock, to be waited for; - * - `stuck`: something this process can neither read as a lock nor remove, - * which no amount of waiting changes. + * - `stuck`: something this process can neither read as a lock nor remove. * - * Breaking a lock is three steps - read who holds it, ask after them, unlink - - * and not one, so two waiters can both find the same holder gone and the - * slower one's unlink can land on the lock a third process has taken since. - * Nothing here prevents that. It is why nothing rests on this lock: see - * {@link withDirectoryLock}. + * Breaking a lock is read, ask, unlink, not one step, so a slow waiter's unlink + * can land on a lock a third process has taken since. Nothing prevents that, + * which is why nothing rests on this lock (see {@link withDirectoryLock}). */ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { let holder: string let age: number try { - // Looked at before it is opened, and opened without blocking or following - // a link: opening a FIFO to read waits for a writer, and nothing but a - // regular file is a lock this library made. + // Opened without blocking or following a link: opening a FIFO waits for a + // writer, and only a regular file is a lock this library made. if (!fs.lstatSync(lockFile).isFile()) { return 'stuck' } @@ -879,14 +757,11 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { fs.closeSync(fd) } } catch (e) { - // Gone between the failed attempt and this look: the next attempt takes - // it. Anything else is a file that is there and cannot be read. + // Gone meanwhile: the next attempt takes it. Anything else cannot be read. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } - // A holder can be asked after only in the PID namespace it named, when that - // is this one: elsewhere its pid means nothing, or means some other - // process. One that cannot be asked after is believed until the lock is - // older than any pass, as is one that did not say who it is. + // A holder can be asked after only in its own PID namespace. One that cannot + // be, or that did not say who it is, is believed until the lock is stale. const who = /^(\d+) (\d+) (\S+)\n$/.exec(holder) const held = age < DIRECTORY_LOCK_STALE_MS && @@ -899,31 +774,25 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { try { fs.unlinkSync(lockFile) } catch (e) { - // Another process broke it first; or it cannot be removed from here, in - // which case it is still there and the next attempt would find it so. + // Another process broke it first, or it cannot be removed from here. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } return 'free' } /** - * Run `body` while holding the manifest directory's lock, and report whether - * it ran, and if not, whether that was for somebody `held` it all the while. - * It gives up, without running `body`, after {@link DIRECTORY_LOCK_WAIT_MS} of - * finding the lock held, and at once when what is at the lock's name is - * nothing it could ever take: the wait is this thread's, in the wrap, in the - * clean-up after every command and in the exit handler, and nothing about a - * lock is worth more of it than that. + * Run `body` while holding the manifest directory's lock, and report whether it + * ran and, if not, whether the lock was `held` throughout. Gives up after + * {@link DIRECTORY_LOCK_WAIT_MS}, and at once when what is at the lock's name + * can never be taken: the wait blocks the wrap, the clean-up after every + * command and the exit handler. * - * The lock keeps srt processes out of each other's way most of the time; it - * does not exclude, and nothing may rest on it. Its holder can be held up past - * the age at which it is broken, the break itself is not atomic (see - * {@link examineDirectoryLock}), and a publish that cannot have the lock goes - * ahead without it. What makes a mount point safe to remove is looked at - * again immediately before the removals and at short intervals during them: - * the directory, for a manifest that was not there when the pass began, and - * the kernel's answer about every manifest the pass knows of (see - * {@link collectUnderLock}). + * The lock keeps srt processes out of each other's way; it does not exclude, + * and nothing may rest on it. Its holder can outlive the age at which it is + * broken, the break is not atomic, and a publish that cannot have the lock goes + * ahead without it. What makes a mount point safe to remove is looked at again + * immediately before the removals and during them (see {@link + * collectUnderLock}). */ function withDirectoryLock( dir: string, @@ -950,9 +819,8 @@ function withDirectoryLock( ) return { ran: false, held: false } } - // On every round, whatever the look above found: a lock that is broken - // and taken again by others for as long as this process keeps asking is - // as good as held. + // On every round: a lock that others keep breaking and retaking is as good + // as held. if (Date.now() >= deadline) { return { ran: false, held: true } } @@ -962,12 +830,9 @@ function withDirectoryLock( /** * What is at a manifest's name, read as one, or `undefined` when it is not a - * manifest this version can read. Only a regular file of the user's, no - * larger than {@link MANIFEST_MAX_BYTES}, is read at all, and it is looked at - * through the descriptor it is then read from, opened without following a - * link and without blocking: opening a FIFO to read waits for a writer, a - * link leads wherever whoever planted it likes, and nothing but such a file - * is a manifest this library wrote. + * manifest this version can read. Only a regular file of the user's, no larger + * than {@link MANIFEST_MAX_BYTES}, is read, through a descriptor opened without + * following a link and without blocking. */ function readManifest(file: string): Manifest | undefined { let inode: string @@ -1010,16 +875,11 @@ function readManifest(file: string): Manifest | undefined { /** * Whether a sandbox that named this manifest may still be running, as far as - * THIS process can tell. - * - * It can tell only about a manifest written in its own PID namespace: the - * writer's pid and the sandbox's lock are both invisible from any other. So a - * manifest from another namespace is live here whatever /proc says, for good: - * what it names is collected by a process in the namespace that wrote it, and - * by nobody if that namespace has gone, which leaves empty files behind and - * opens nothing. One that does not say where it is from is live while a - * process with its writer's pid and start time can be seen, and otherwise - * until it is as old as an unreadable manifest has to be to be dropped. + * this process can tell. It can tell only for a manifest written in its own PID + * namespace; one from another namespace is always live here and is collected by + * a process in the namespace that wrote it. One that names no namespace is live + * while its writer can be seen, and otherwise until {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ function isLive(manifest: Manifest, locks: Locks): boolean { if (holdsLock(locks, manifest)) { @@ -1045,27 +905,23 @@ function isLive(manifest: Manifest, locks: Locks): boolean { } /** - * What became of a path a pass set out to remove: `left` when there is - * nothing more to do about it - it is gone, or is no longer what bubblewrap or - * this library made - and `failed` when it is still that and could not be - * removed from here, which a later pass, or another process, may yet manage. + * What became of a path a pass set out to remove: `left` when it is gone or no + * longer what bubblewrap or this library made, `failed` when it is still that + * and could not be removed from here. */ type Removal = 'removed' | 'left' | 'failed' /** Whether a failed removal means the path is no longer ours to remove. */ function meansNothingToRemove(e: unknown): boolean { const code = (e as NodeJS.ErrnoException | undefined)?.code - // Not empty after all: something was written into it between the look and - // the rmdir. + // Not empty after all: something was written into it meanwhile. return isAbsenceErrno(e) || code === 'ENOTEMPTY' || code === 'EEXIST' } /** * Remove an empty directory a placeholder bound from, if it is still our own. - * It sits under the system temp dir, which sandboxed commands commonly can - * write, so anything that is not a private empty directory of ours — a - * symlink, a directory whose mode has been widened, one with something in it — - * is somebody else's and is left exactly as found. + * It sits where sandboxed commands can often write, so anything but a private + * empty directory of ours is left exactly as found. */ function removeMountSource(source: string): Removal { try { @@ -1099,10 +955,9 @@ function removeMountSource(source: string): Removal { /** * Whether `p` still has the shape of a file bubblewrap made to bind onto: a - * regular file (not a link to one), empty, with no write bit, under one name, - * and ours. A manifest is only a claim about a path, and the directory it - * sits in is only as private as its host, so nothing is removed on a - * manifest's word that does not also look like what bubblewrap leaves. + * regular file (not a link), empty, with no write bit, under one name, and + * ours. A manifest is only a claim about a path, so nothing is removed on its + * word that does not also look like what bubblewrap leaves. */ export function isBwrapFileMountPoint(p: string): boolean { try { @@ -1120,10 +975,8 @@ export function isBwrapFileMountPoint(p: string): boolean { } /** - * What kind of mount point the path a live manifest names still is, by what - * is there now: an empty regular file, a directory, or neither any more. A - * manifest says a path was a mount point when it was written; a file that has - * been written to since, or a link put in its place, is somebody's own. + * What kind of mount point the path a live manifest names still is, by what is + * there now: an empty regular file, a directory, or neither any more. */ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { try { @@ -1138,9 +991,8 @@ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { } /** - * Remove a mount point, if it is still the empty file or directory bwrap made. - * Anything else - a file with content or a write bit, a link, a directory - * something has written into or that is not ours - is left where it is. + * Remove a mount point if it is still the empty file or directory bwrap made; + * anything else is left where it is. */ function removeMountPoint(mountPoint: string): Removal { try { @@ -1192,15 +1044,12 @@ export type MountPointManifest = { /** * Record the mount points this wrap relies on, and the empty directories they - * bind from, before the sandbox that relies on them can start, and say where - * to point bubblewrap's --lock-file. + * bind from, before the sandbox can start, and say where to point bubblewrap's + * --lock-file. * - * `undefined` when there is nothing to record, or when no manifest could be - * written, in which case the caller must not track those mount points at all: - * what this process cannot record it does not remove. That is as far as it - * goes. Nothing on disk then says a sandbox relies on them, so a wrap in - * another process that denies the same path takes the file for the caller's - * own: it binds it onto itself and names it nowhere, and nobody removes it. + * `undefined` when there is nothing to record or no manifest could be written. + * The caller must then not track those mount points: what this process cannot + * record it does not remove, and nobody else will. */ export function publishMountPointManifest( mountPoints: readonly string[], @@ -1228,14 +1077,10 @@ export function publishMountPointManifest( paths: [...new Set(mountPoints)], sources: [...new Set(sources)], } - // Written beside the manifest and renamed onto it, so a collect in another - // process reads it whole or not at all. The rename is made under the - // directory lock where that can be had, which keeps it out of the middle of - // most passes, and without it where it cannot: a wrap is not refused for - // want of a lock. Either way a pass may already have listed the directory, - // so what keeps this wrap's mount points is that every pass lists it again - // before it removes anything (see collectUnderLock), and that the sandbox - // this manifest covers has not started yet. + // Written beside the manifest and renamed onto it, so a collect reads it + // whole or not at all. Under the directory lock where that can be had; a wrap + // is not refused for want of one, because every pass lists the directory + // again before it removes anything (see collectUnderLock). try { fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) const publish = (): void => fs.renameSync(temporary, file) @@ -1272,8 +1117,8 @@ export function discardMountPointManifest(file: string): void { } /** - * The path of every file in `dirs`, for a reading on which nothing is - * removed: a directory that cannot be listed adds nothing. + * The path of every file in `dirs`. A directory that cannot be listed adds + * nothing. */ function filesIn(dirs: readonly string[]): string[] { return dirs.flatMap(dir => { @@ -1287,16 +1132,14 @@ function filesIn(dirs: readonly string[]): string[] { /** * The mount points the manifests name, on one reading of every directory this - * process believes (see {@link believedManifestDirectories}): `live`, by a - * manifest whose sandbox may still be running, and `named`, by any manifest at - * all. What only a finished manifest names is what a sandbox that has ended - * left behind and no pass has collected yet, and the next pass in any process - * removes it on that manifest's word. + * process believes (see {@link believedManifestDirectories}): `live` by a + * manifest whose sandbox may still be running, `named` by any manifest. What + * only a finished manifest names is a leftover the next pass removes. * - * `live` is empty when /proc/locks cannot be read, and a directory that - * cannot be listed adds to neither. A wrap takes an existing path for a mount - * point on a manifest's word only, never by the look of it, so with none to - * be read it takes every existing path for the caller's own. + * `live` is empty when /proc/locks cannot be read, and a directory that cannot + * be listed adds to neither: a wrap takes a path for a mount point on a + * manifest's word only, so with none to read it takes every existing path for + * the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() @@ -1323,9 +1166,8 @@ export function namedMountPoints(): { live: Set; named: Set } { } /** - * Whether what is at `p` now is still something a clean-up would take away on - * a manifest's word: the empty file bubblewrap made (see - * {@link isBwrapFileMountPoint}), or an empty directory of the user's. + * Whether `p` is still something a clean-up would remove on a manifest's word: + * the empty file bubblewrap made, or an empty directory of the user's. */ function isEmptyMountPoint(p: string): boolean { if (isBwrapFileMountPoint(p)) { @@ -1345,46 +1187,29 @@ function isEmptyMountPoint(p: string): boolean { /** * The mount points a sandbox that may still be running relies on, as the - * manifests say at the moment of the call: for a caller that removes paths of - * its own accord after a command, to leave out the ones in this set. Removing - * a mount point from under a running sandbox lifts the deny there. + * manifests say at the moment of the call. For a caller that removes paths of + * its own accord after a command: removing a mount point from under a running + * sandbox lifts the deny there. * - * It is a snapshot, taken without a lock, from one listing of each manifest - * directory and one reading of /proc/locks, and it may be out of date by the - * time the caller acts on it: a sandbox can start on a path the moment after. - * Its only safe use is to SKIP a removal. That a path is not in it never - * means the path is free to be written, or that nothing will rely on it. + * A snapshot, taken without a lock, that may be out of date by the time the + * caller acts. Its only safe use is to SKIP a removal; a path not being in it + * never means the path is free to write. * - * A path is in the set only when both of these hold: - * - a manifest names it, and that manifest is live by the rule the clean-up - * goes by, or its liveness cannot be told. It cannot be told when - * /proc/locks cannot be read, or while a manifest that cannot be read is - * locked or new; every path that any readable manifest names then counts. - * What a manifest that cannot be read names cannot be listed at all; - * - what is at the path now still has the shape of a mount point: an empty - * regular file with no write bit and one link, or an empty directory, and - * the user's. So whatever a caller spares on the word of this set is an - * empty placeholder, and a manifest that is out of date, or forged, never - * makes it spare a file with something in it. + * A path is in the set only when both hold: * - * The paths are as the wraps recorded them: absolute, with the links in the - * directories above the last component resolved and the last component as it - * stands. A caller compares its own paths in that form. + * - a manifest names it, and that manifest is live by the clean-up's rule or + * its liveness cannot be told (/proc/locks unreadable, or an unreadable + * manifest that is locked or new), in which case every named path counts; + * - what is at the path now is still an empty placeholder: an empty regular + * file with no write bit and one link, or an empty directory, and the user's. + * So a stale or forged manifest never makes a caller spare a file with + * content. * - * The manifests are believed as found. Their directories can be written by - * the user's own processes outside any sandbox; every sandbox that restricts - * writes has each of them bound read-only, with the directories between it - * and the write root it lies in pinned, which does not reach a directory - * whose name, or a name above it, is a link inside a write root, nor one - * whose parent is not there yet below one. It answers for every manifest - * directory this process believes (see {@link believedManifestDirectories}), - * whichever of them it writes to, so a sandbox of a process that keeps its - * manifests under a name its environment gives, or in a directory of its own, - * for want of a name from the user id that it can use, or of something that - * writes none, is not seen. - * - * Makes nothing on the host, a manifest directory included. Empty where - * there is no manifest directory, and anywhere but on Linux. + * Paths are as the wraps recorded them: absolute, links above the last + * component resolved. It answers for every manifest directory this process + * believes (see {@link believedManifestDirectories}), so a sandbox whose + * process keeps manifests elsewhere is not seen. Makes nothing on the host. + * Empty off Linux and where no manifest directory exists. */ export function liveMountPoints(): ReadonlySet { const spared = new Set() @@ -1440,9 +1265,8 @@ export function liveMountPoints(): ReadonlySet { * nothing anywhere but on Linux. * * One pass for each directory this process believes, under that directory's - * lock: a pass takes off the disk what the finished manifests of its own - * directory name, and those manifests, and goes by the manifests of every - * directory for what is still relied on. + * lock: a pass removes what the finished manifests of its own directory name, + * and goes by the manifests of every directory for what is still relied on. */ export function collectMountPoints( release: OwnManifestRelease = 'all', @@ -1450,10 +1274,8 @@ export function collectMountPoints( if (!onLinux()) { return [] } - // What the caller says about its own commands is taken down first and kept, - // whatever becomes of this pass: one that cannot have the lock, or turns - // back half way, leaves the manifests for the next, which has to know they - // are done with even when it is told to release nothing. + // Recorded first and kept whatever becomes of this pass, so a later pass + // knows these commands are over even when told to release nothing. for (const own of ownManifests.values()) { if ( release === 'all' || @@ -1481,39 +1303,25 @@ export function collectMountPoints( } /** - * One pass over `dir`, which the caller makes under that directory's lock - * where it can. `dirs` is every directory this process believes, `dir` among - * them. The pass reads the manifests of all of them, and a mount point stays - * while a live manifest in any of them names it; what it takes off the disk - * is what the finished manifests of `dir` name, and those manifests. What the - * finished manifests of another directory name goes at the pass over that - * one, under its lock. + * One pass over `dir`, made under that directory's lock where the caller could + * take it. `dirs` is every directory this process believes, `dir` among them: a + * mount point stays while a live manifest in any of them names it, and what is + * removed is what the finished manifests of `dir` name. * - * Nothing is taken off the disk until the end. A manifest is all that names - * its mount points, so it goes last, after them, and only when every one of - * them is gone or is no longer a mount point: a pass that turns back half way, - * or a removal that is refused, leaves the manifest for a later pass to go by. - * That holds for the manifests of this process as for anybody's. The ones the - * caller has said are over are judged as if their writer were gone, and that - * is all that sets them apart. + * A manifest is all that names its mount points, so it is removed last, and + * only when every path it names is gone or is no longer a mount point: a pass + * that turns back, or a removal that is refused, leaves it for a later pass. + * Manifests the caller has said are over are judged as if their writer were + * gone. * - * The lock does not exclude (see {@link withDirectoryLock}), so the pass does - * not rely on the directory staying as first listed. Immediately before it - * removes anything, and again whenever its last listing is more than - * {@link LISTING_GOOD_FOR_MS} old, it lists every directory again, where a - * manifest that was not there at first keeps everything it names whatever - * else is true of it: that is how a sandbox about to start on the same path - * shows. And before the first removal, and again every - * {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS}, it reads - * /proc/locks again, where a manifest that has been locked since keeps what it - * names. That one covers the actor no lock could: the bubblewrap a wrap has - * already handed to its caller, which takes its lock whenever the caller - * starts it. What is left is the time between a look and the removal made on - * it, a quarter of a millisecond for a newly published manifest and two - * milliseconds for one that was there all along, against the four or more a - * sandbox takes to get from its start to its binds. A pass that is held up for - * longer than that between looking and removing, with a sandbox starting on - * the same path in that time, still takes that sandbox's mount point away. + * The lock does not exclude (see {@link withDirectoryLock}), so before the + * first removal, and again at {@link LISTING_GOOD_FOR_MS}, the pass lists every + * directory again: a manifest that was not there at first keeps everything it + * names, which is how a sandbox about to start on the same path shows. And + * every {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS} it + * reads /proc/locks again: a manifest locked since keeps what it names. What + * remains is a pass held up between a look and the removal for longer than a + * sandbox takes to reach its binds; it then removes that sandbox's mount point. */ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { // Every file in every directory, or nothing: a directory that is believed @@ -1548,23 +1356,18 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { if (locks === undefined) { return [] } - // What the pass knows of: the first listing, so that everything found - // later is a newcomer. + // The first listing: whatever is found later is a newcomer. const known = new Set(files) - // A manifest of this process that something else has taken off the disk is - // nothing to remember. + // Forget own manifests that something else has removed. for (const file of ownManifests.keys()) { if (isHere(file) && !known.has(file)) { ownManifests.delete(file) } } - // A manifest whose contents cannot be read — a file another version of this - // library wrote, a truncated one, one this process may not read — names - // mount points that cannot be honoured one by one. While the kernel says a - // sandbox is running under it, or it is new enough that one may be starting, - // nothing at all is removed; once it is neither, whatever it names is not in - // use and it stands in the way of nothing. + // An unreadable manifest names mount points that cannot be honoured one by + // one. While it is locked, or new enough that a sandbox may be starting, + // nothing at all is removed. let unreadableIsLive = false for (const file of unreadable) { let stat: fs.BigIntStats @@ -1615,9 +1418,7 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { } } - // The wraps of this process the caller has said are over, except that one - // whose manifest is locked has a sandbox running under it all the same, and - // is anybody's live manifest until the lock goes. + // Over, by the caller's word, unless a sandbox still holds the lock. for (const manifest of manifests) { manifest.released = ownManifests.get(manifest.file)?.over === true && @@ -1638,9 +1439,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { finished.push(manifest) } } - // Each path once, and as a mount point where a manifest names it as one. - // Only what the finished manifests of this directory name: the others are - // kept in `finished` to be asked after again, not to be acted on. + // Each path once, and as a mount point where a manifest names it as one. Only + // what the finished manifests of this directory name. const candidates = new Map() for (const manifest of finished) { if (!isHere(manifest.file)) continue @@ -1656,10 +1456,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { } } - // When the pass last made sure, and what it found then: `revived` are the - // manifests of the first listing that read as finished then and as locked on - // a later look. A finished manifest's writer is gone and its grace is over, - // and neither comes back, so the lock is all that is asked after again. + // `revived`: manifests that read as finished at first and as locked on a + // later look. Only the lock can change; a gone writer does not come back. const revived = new Set() let removalsOnThisLook = 0 let lookedAt: number | undefined @@ -1713,9 +1511,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { listedAt = performance.now() return true } - // The kernel's list is read first and the directory listed last. Reading - // the list takes as long as the host has locks, milliseconds on a busy one, - // after which a listing made before it is out of date as well. + // Locks first, listing last: reading /proc/locks can take milliseconds, and a + // listing made before it would be out of date. const mayStillRemove = (): boolean => (locksAreFresh() || readTheLocksAgain()) && (listingIsFresh() || listTheDirectoryAgain()) @@ -1749,9 +1546,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { if (revived.has(manifest)) { continue } - // A mount point that is still there, still what bubblewrap made, and could - // not be removed from here - a directory this process sees read-only, say - // - is still somebody's to remove, and the manifest is all that says so. + // A mount point that could not be removed from here is still somebody's to + // remove, and the manifest is all that says so. if ( [...manifest.paths, ...manifest.sources].some(named => notRemoved.has(named), @@ -1772,17 +1568,11 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { /** * Put directories of a test's own in the place of those the manifest - * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, - * with nothing, put the real ones back. Test seam, and nothing else may call - * it: the real places are every other process's of the user, and a test that - * lists, attacks or collects there does so to the sandboxes that user is - * running. A place that is left out stays what it really is, so a test can - * stand in for `/run/user/UID` and `/tmp` and leave the environment to say the - * rest. - * - * Which directory the manifests are written to is forgotten with it, so that - * the next use works it out again: this module may have settled on one before - * the test that wants another was loaded. + * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, with + * nothing, put the real ones back. Test seam, and nothing else may call it: the + * real places are shared with every other process of the user. A place that is + * left out stays what it really is. Which directory the manifests are written + * to is forgotten with it, so the next use works it out again. */ export function setMountPointManifestPlacesForTesting( places?: Partial, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 72c274b8a..0c82093d1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -854,12 +854,10 @@ function renderBwrapInvocation( let exitHandlerRegistered = false /** - * Wraps handed to the caller and not yet cleaned up after. The caller is told - * to clean up once per command, when that command is over, so while this is - * above zero some command of this process may not have started yet, and what - * it relies on - its manifest, its --args profile - must still be there when - * it does. Nothing of this process's own is given up until the count is back - * at zero, unless the caller says which command it is cleaning up after. + * Wraps handed to the caller and not yet cleaned up after. While above zero, + * some command of this process may not have started, and its manifest and + * --args profile must still be there when it does; so nothing of this process's + * own is released unless the caller names the command. */ let activeSandboxCount = 0 @@ -889,25 +887,21 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called on process exit as a safety net. * - * What it may take away, of what THIS process made: - * - with no `commandId`, nothing until it has been called once for every wrap - * handed out: a command wrapped and not yet started still needs its - * manifest and its --args profile, and nothing here can tell which command - * the call is for. So one long-running command holds back the clean-up of - * the ones that finished beside it; - * - with the `commandId` its wrap was given, what that one command relied on, - * at once, whatever else is still running; - * - with `force`, everything: the process is ending, or the session is. That - * includes the manifest directory itself where it is one only this process - * knows, once nothing is left in it. + * What it releases of what THIS process made: * - * In every case a mount point goes only when no running sandbox relies on it, - * which the kernel answers and not this process (see bwrap-mount-manifests.ts), - * and what other srt processes have finished with goes too. A call too many - * takes nothing from a running sandbox; it does count for some other wrap of - * this process, and if that wrap's command has not started by the time the - * count is back at zero it then refuses to start. Does nothing except on - * Linux. + * - with no `commandId`: nothing until it has been called once for every wrap + * handed out, since nothing can tell which command the call is for. One + * long-running command therefore holds back the clean-up of those that + * finished beside it; + * - with the `commandId` its wrap was given: what that command relied on, at + * once; + * - with `force`: everything, because the process or session is ending. + * + * A mount point goes only when no running sandbox relies on it, which the + * kernel answers (see bwrap-mount-manifests.ts). A call too many takes nothing + * from a running sandbox, but counts for another wrap of this process, whose + * command then refuses to start if it has not started by the time the count + * reaches zero. Does nothing except on Linux. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean @@ -1628,14 +1622,10 @@ async function generateFilesystemArgs( const args: string[] = [] // fs already imported - // The mount points on the host this wrap relies on: the placeholders bwrap - // makes for absent deny paths, and the ones another sandbox made that this - // wrap covers again. They are named in a manifest below, and nothing removes - // them while a sandbox that named them runs (see bwrap-mount-manifests.ts). + // The mount points on the host this wrap relies on: placeholders bwrap makes + // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // The mount points the manifests name, read once per wrap. The ones a - // manifest says a running sandbox relies on are mount points whatever their - // mode on the host says. + // What the manifests name, read once per wrap. let namedMountPointsCache: ReturnType | undefined // Collect normalized allowed write paths. Populated in the writeConfig @@ -2358,43 +2348,23 @@ async function generateFilesystemArgs( } // A mount point another sandbox made is an absent deny path in all but - // name. Bound onto itself as an existing path it would never be named in - // this wrap's manifest, so it would go, with this sandbox still bound - // over it, as soon as the sandbox that made it was cleaned up after. - // Only a manifest says that a path is one. An empty read-only file - // looks like anyone's, and so does an empty directory: taken for a - // leftover by its shape alone, a file its owner keeps empty and - // read-only was covered, named, and removed after the command. What no - // manifest names is the caller's, bound onto itself further down like - // any existing path and never removed, which leaves on the host what a - // killed process of a release that wrote no manifest left there. Any - // manifest will do, a finished one as much as a live one: a finished - // manifest is what a killed process leaves, the next clean-up in any - // process removes what it names on its word unless a live manifest - // names it too, and this wrap's is then the only one that can. What is - // done with a path a manifest names depends on its kind: - // - a FILE that has the shape bubblewrap leaves (see - // isBwrapFileMountPoint), or, where the manifest is live, that is - // still an empty regular file. It is covered with /dev/null like the - // absent leaf below, and named. Under a read-only denied directory - // nothing needs covering (the file is already unwritable there), but - // it is still named: it is no more the caller's file for being there; - // - a DIRECTORY, which is the first missing component of some other - // sandbox's deny. /dev/null cannot be bound over a directory - // (bubblewrap refuses to start), so it is named and then treated as - // the existing directory it is, further down: bound onto itself - // read-only, which is also what the pre-pass above recorded it as; - // - anything else (a file that has been written to since, a link) is - // the caller's by now, and is neither. - // The shape is asked before the existence: another process's collect can - // take a mount point away at any moment, and a path found to exist and - // then found not to have the shape because it has just gone would be - // bound onto itself as a file that is not there. And once more after it, - // when the two disagree: another sandbox starting on the same path makes - // its mount point at any moment too. + // name: bound onto itself it would not be named in this wrap's manifest + // and would go, with this sandbox still bound over it, when its maker is + // cleaned up after. Only a manifest says that a path is one; what no + // manifest names is the caller's own and is never removed. For a path a + // manifest names: + // + // - a FILE with the shape bubblewrap leaves (or, under a live manifest, + // any empty regular file) is covered with /dev/null and named; + // - a DIRECTORY is named and then bound onto itself read-only further + // down, since /dev/null cannot be bound over a directory; + // - anything else is the caller's by now. + // + // The shape is asked before the existence, and once more when the two + // disagree: another process can remove or make a mount point at any + // moment. let hasFileShape = isBwrapFileMountPoint(normalizedPath) - // Whether anything is at the path, asked once for the branches below, - // and once more where the manifests say the file is nobody's. + // Asked once for the branches below. let pathExists = hasFileShape || fs.existsSync(normalizedPath) if (pathExists && !hasFileShape) { hasFileShape = isBwrapFileMountPoint(normalizedPath) @@ -2403,17 +2373,11 @@ async function generateFilesystemArgs( isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath) ) { - // The manifests are read once per wrap, and a reading that names the - // path is believed whenever it was taken. One that does not name a - // file of that shape is believed only if it was taken after the file - // was seen: a sandbox publishes its manifest before bubblewrap makes - // the file, so such a reading names every mount point that is there, - // and an older one misses that of a sandbox that has started since, - // which would be bound onto itself as the caller's own file, named by - // this wrap nowhere, and gone from under it at that sandbox's - // clean-up. A file of that shape which such a reading does not name - // is the caller's, or has gone since it was seen, its manifest with - // it: whether it is still there is asked again. + // A reading that names the path is believed whenever it was taken. One + // that does not name a file of that shape is believed only if taken + // after the file was seen: a sandbox publishes its manifest before + // bubblewrap makes the file, so an older reading would miss a sandbox + // that started since. let named = pathExists ? namedMountPointsCache : undefined if ( pathExists && @@ -2791,14 +2755,11 @@ async function generateFilesystemArgs( // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. // - // The directories of this library's own that are bound read-only at the end - // of this function are seeds as well: the manifest directories, the store of - // fake files and the empty directory the placeholders bind from. What is - // found at each of those names is believed on the host, by the next - // clean-up or the next wrap, and the read-only bind keeps a command out of - // the directory only for as long as the name leads to it: with the temp dir - // strictly inside a write root, a command could rename the temp dir aside - // and make the name again, with a manifest directory of its own in it. + // This library's own read-only directories are seeds as well (the manifest + // directories, the fake-file store, the empty placeholder source): a + // read-only bind protects one only while its name still leads to it, and + // with the temp dir strictly inside a write root a command could otherwise + // rename the temp dir aside and make the name again. const manifestDirs = writeConfig !== undefined ? mountPointManifestDirectories( @@ -2950,37 +2911,23 @@ async function generateFilesystemArgs( } } - // Name the mount points this wrap relies on, before the sandbox that relies - // on them can start, and have bwrap hold a lock on the manifest for exactly - // as long as that sandbox runs: the lock is what tells every process, this - // one included, that these mount points are still in use. Mount points that - // could not be recorded are left on the host, since what this process cannot - // record, no process may remove. + // Name the mount points this wrap relies on before the sandbox can start, and + // have bwrap hold a lock on the manifest for as long as it runs. Mount points + // that could not be recorded are left on the host. // // INVARIANT: a manifest directory must never be writable from inside a - // sandbox — a command that could delete a manifest could make the mount - // points a running sandbox relies on look unused, and one that could write - // a manifest could name any path at all for a collect on the host to - // remove. That holds for a sandbox whose own wrap names no manifest as much - // as for one that does, and for the directory some other process of this - // user keeps its manifests in as much as for this one's: one of them sits - // under /tmp whether or not this process writes there, where a caller's - // allowWrite commonly reaches, and what is in it then belongs to OTHER - // sandboxes. So every wrap that restricts writes binds every one of - // them read-only (see mountPointManifestDirectories), with what lies above - // them pinned (they are seeds of the ancestor pins), and a wrap with mount - // points of its own adds the lock. Emitted after every allow and deny bind, - // for the same reason as the two stores below (they are all directories of - // their own, so their order among themselves does not matter). It is also - // what lets bwrap open the manifest: it does that inside the sandbox, after - // the mounts, so a profile with a tmpfs over the temp dir would otherwise - // leave nothing there to open. + // sandbox. A command that could delete a manifest could make a running + // sandbox's mount points look unused, and one that could write a manifest + // could name any path for a collect on the host to remove. This holds for + // every wrap that restricts writes, whether or not it names a manifest, and + // for every directory some process of this user keeps manifests in, so each + // is bound read-only (see mountPointManifestDirectories) with what lies above + // it pinned. Emitted after every allow and deny bind, like the two stores + // below. // - // Each is bound where it really is, like a pin, and not at the name it - // was worked out as: bubblewrap makes a bind's destination by name - // inside the new root before it mounts, and a link on the way whose target - // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir - // reached through such a link no command that restricts writes started. + // Each is bound where it really is, not at the name it was worked out as: + // bubblewrap makes a bind's destination by name inside the new root, and a + // link with an absolute target on the way leads nowhere from there. const manifest = publishMountPointManifest( mountPoints, emptySource === undefined ? [] : [emptySource], @@ -3441,9 +3388,8 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // No command came of this wrap, so no sandbox can be running under its - // manifest and the mount points it named are no one's, and the caller has - // nothing to clean up after. + // No command came of this wrap, so no sandbox runs under its manifest and + // the mount points it named are no one's. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 3ac05bc34..438e3fe0c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -2077,14 +2077,12 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Call it once for each wrapped command, when that command is over. Two - * commands of one process may be in flight at once, and a call cannot tell - * which of them it is for, so with no argument nothing this process made is - * taken away until it has been called once for every wrap handed out: the - * command that has not started yet still needs what its wrap prepared. Pass - * the `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to - * have that one command's mount points go at once, whatever else is running, - * which matters when some command runs for a long time. + * Call it once for each wrapped command, when that command is over. With no + * argument nothing this process made is removed until it has been called + * once for every wrap handed out, because a call cannot tell which command + * it is for. Pass the `commandId` the wrap was given + * (`WrapWithSandboxOptions.commandId`) to have that command's mount points + * removed at once, which matters when another command runs for a long time. * * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. diff --git a/test/cli.test.ts b/test/cli.test.ts index c5be11f20..854c11282 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -14,9 +14,8 @@ import { isolatedProgram } from './helpers/private-manifest-directory.js' let cliPath: string | undefined /** - * Get the path to the CLI entry point: the tool itself, started with - * directories of the run's own in place of the ones it keeps its mount point - * manifests in, which are every other srt process's of the user. + * Get the path to the CLI entry point, started with directories of the run's + * own in place of the ones the tool keeps its mount point manifests in. */ function getCliPath(): string { return (cliPath ??= isolatedProgram(join(process.cwd(), 'src', 'cli.ts'))) diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 2e7f0fd73..2a10cc34d 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -12,9 +12,8 @@ import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' import { isolatedProgram } from './helpers/private-manifest-directory.js' -// Get the path to the built CLI: the tool itself, started with directories of -// the run's own in place of the ones it keeps its mount point manifests in, -// which are every other srt process's of the user. +// Get the path to the built CLI, started with directories of the run's own in +// place of the ones the tool keeps its mount point manifests in. const CLI_PATH = isolatedProgram(path.join(process.cwd(), 'dist', 'cli.js')) // srt is expected to exit on its own shortly after the wrapped command diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 2180c3373..caef055b4 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -20,31 +20,25 @@ const REPOSITORY = join(import.meta.dir, '../..') const SEAM = join(REPOSITORY, 'src/sandbox/bwrap-mount-manifests.ts') /** - * The manifests of every srt process of a user live in a few directories that - * are worked out from the user id: `/run/user/UID/srt-mount-points` and - * `/tmp/srt-mount-points-UID`, and after those what the environment names. - * Every wrap makes and binds them, and every clean-up reads, judges and - * removes what is in them. A test that did so in the real ones would be doing - * it to whatever else that user is running on the machine, and be failed by - * it in turn, and nothing in the environment moves a process away from them. - * So no test looks there: + * The manifests of every srt process of a user live in a few directories worked + * out from the user id (`/run/user/UID/srt-mount-points`, + * `/tmp/srt-mount-points-UID`) and after those in what the environment names. + * Every wrap makes and binds them and every clean-up reads and removes what is + * in them, so a test that used the real ones would act on whatever else the + * user is running. No test looks there: * * - in the test process, directories of the test's own stand in for all four - * places, through the seam the module has for it - * ({@link isolateManifestDirectoriesForTheRun} for every test file of the - * run, {@link usePrivateManifestDirectory} for a `describe` that wants them - * to itself); - * - a child process that loads the library from a script is given a module to - * load that puts the same stand-ins in place first ({@link isolatedModule}), - * and one that runs the command line tool a program that does - * ({@link isolatedProgram}); - * - a child process that is to work the directories out for itself, from the - * real names, runs in a mount namespace of its own in which the stand-ins - * are bound over `/tmp` and `/run/user` ({@link inPrivateNamespace}). + * places ({@link isolateManifestDirectoriesForTheRun} for the run, {@link + * usePrivateManifestDirectory} for one `describe`); + * - a child process that loads the library is given a module that puts the same + * stand-ins in place first ({@link isolatedModule}), and one that runs the + * command line tool a program that does ({@link isolatedProgram}); + * - a child process that is to work the directories out for itself runs in a + * mount namespace in which the stand-ins are bound over `/tmp` and + * `/run/user` ({@link inPrivateNamespace}). * - * All of it is bubblewrap's, which is Linux's: on any other platform the - * library keeps no manifest anywhere, nothing is put in the place of - * anything, and a child process is given the module or the program itself. + * Off Linux the library keeps no manifest, nothing is replaced, and a child + * process is given the module or the program itself. */ type Places = ManifestDirectoryPlaces @@ -61,11 +55,10 @@ type PrivatePlaces = { const bases = new Set() /** - * Directories of the caller's own to stand in for the four places. The - * runtime directory is named after the user id under its parent, as the real - * one is, so that the parent can be bound over `/run/user`. Nothing stands in - * for `$XDG_RUNTIME_DIR`, which is as if it were not set, and the temp dir - * the environment names is the one that stands in for `/tmp`. + * Directories of the caller's own to stand in for the four places. The runtime + * directory is named after the user id under its parent, so the parent can be + * bound over `/run/user`. Nothing stands in for `$XDG_RUNTIME_DIR`, and the + * temp dir the environment names is the one that stands in for `/tmp`. */ function makePlaces(): PrivatePlaces { const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-places-'))) @@ -104,14 +97,11 @@ function sourceOf(places: Partial): string { let filesMade = 0 /** - * A file for a child process to load in place of `target`, which puts - * `places` in place and then is `target`: a module of the sources, which it - * gives again, or the command line tool, from the sources or as it was built, - * which it runs. The tool's seam is the one beside it. - * - * The stand-ins are in place before the child does anything with what it has - * loaded. A module's imports are evaluated before its own body, and the body - * is the call to the seam; a program starts work as it is loaded, so it is + * A file for a child process to load in place of `target`, which puts `places` + * in place and then is `target`: a module, which it gives again, or the command + * line tool, which it runs. The stand-ins are in place before the child uses + * what it loaded: a module's imports are evaluated before its own body, and the + * body is the call to the seam; a program starts work as it is loaded, so it is * loaded after the call. */ function isolatedFile( @@ -152,10 +142,9 @@ function isolatedFile( let placesOfTheRun: PrivatePlaces | undefined /** - * Puts stand-ins in place for the whole run: most suites wrap a command - * somewhere, and none of them is to make, bind or collect in the user's real - * directories for it. The test runner's preload does it before any test file - * is loaded, and takes the stand-ins away after the last one + * Puts stand-ins in place for the whole run, so no suite makes, binds or + * collects in the user's real directories. The test runner's preload calls it + * before any test file is loaded and removes the stand-ins after the last one * ({@link removeManifestDirectoriesOfTheRun}). */ export function isolateManifestDirectoriesForTheRun(): void { @@ -184,9 +173,8 @@ function ofTheRun(): PrivatePlaces { /** * The path of a module for a child process to load in place of `module`, with - * the stand-ins of the run in place, or with `places` where the child is to - * look somewhere else. A place that is left out of `places` is the real one - * for that child: what its environment says, for the last two. + * the stand-ins of the run in place, or `places` instead. A place left out of + * `places` is the real one for that child. */ export function isolatedModule( module: string, @@ -200,9 +188,9 @@ export function isolatedModule( } /** - * The path of a program for a child process to run in place of `program`, the - * command line tool as it is in the sources or as it was built, with the - * stand-ins of the run in place. + * The path of a program for a child process to run in place of `program` (the + * command line tool, from the sources or as built), with the stand-ins of the + * run in place. */ export function isolatedProgram(program: string): string { if (!onLinux) { @@ -213,16 +201,14 @@ export function isolatedProgram(program: string): string { } /** - * The start of a command line that runs what follows it in a mount namespace - * of its own, in which `places.tempDir` is bound over `/tmp` and the parent of - * `places.runtimeDir` over `/run/user`: for a child process that is to work - * the directories out for itself, from the real names, without reaching the - * real ones. Whatever is in `keep` is bound back where it is, for what the - * child needs that lies under the real `/tmp`, and so are this repository and - * the runtime where they lie there. No PID namespace, so `/proc/locks` and - * the pids in it are the test's own. - * - * Needs a bubblewrap that can make a namespace (see `bwrapCanNamespace`). + * The start of a command line that runs what follows it in a mount namespace of + * its own, in which `places.tempDir` is bound over `/tmp` and the parent of + * `places.runtimeDir` over `/run/user`: for a child process that is to work the + * directories out for itself without reaching the real ones. Whatever is in + * `keep` is bound back where it is, as are this repository and the runtime + * where they lie under the real `/tmp`. No PID namespace, so `/proc/locks` and + * its pids are the test's own. Needs a bubblewrap that can make a namespace + * (see `bwrapCanNamespace`). */ export function inPrivateNamespace( places: Pick, @@ -250,18 +236,15 @@ export function inPrivateNamespace( /** * Gives the enclosing `describe` directories of its own for the mount point - * manifests, for as long as its tests run: a test that lists the manifests, - * attacks them from inside a sandbox or asserts on what is left of them needs - * to be the only one that writes there. + * manifests, for as long as its tests run: a test that lists or attacks the + * manifests needs to be the only one that writes there. * * It also starts the `describe` with no wrap of this process outstanding, and - * leaves it so. The library counts the wraps it has handed out and releases - * nothing of this process's until each has been cleaned up after; the count is - * the module's, shared by every test file of one run, and other suites wrap - * without cleaning up. A test that expects one clean-up to take its mount point - * away would otherwise pass or fail by which file ran before it. + * leaves it so: the library's count of wraps is shared by every test file of a + * run, and other suites wrap without cleaning up. * * Call it inside the `describe` callback. Returns: + * * - `manifestDir()`, where this process's manifests go: under the runtime * directory, the first place looked in; * - `places()`, the stand-ins themselves; diff --git a/test/preload.ts b/test/preload.ts index a6c4ef0be..5349887f8 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -4,9 +4,8 @@ import { removeManifestDirectoriesOfTheRun, } from './helpers/private-manifest-directory.js' -// Before any test file is loaded, so that no wrap and no clean-up of the run -// reaches the directories the user's own sandboxes keep their manifests in. A -// hook here belongs to the run and not to a file: the stand-ins go after the -// last test file. +// Before any test file is loaded, so no wrap or clean-up of the run reaches the +// user's real manifest directories. The stand-ins are removed after the last +// test file. isolateManifestDirectoriesForTheRun() afterAll(removeManifestDirectoriesOfTheRun) diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 851f519b0..59ff45c7f 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -396,8 +396,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { it('keeps a pending profile through the clean-up after another command, and gives it back with the last', () => { // A profile belongs to one wrap and nothing maps it back to a command, so - // none is closed while any wrap of this process is outstanding: the one - // that has not started yet reopens its profile through /proc when it does. + // none is closed while any wrap of this process is outstanding. const seen = isolated(` const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 2ce6447ac..cdeada3f0 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -36,18 +36,11 @@ import { } from '../helpers/private-manifest-directory.js' /** - * A denyWrite path that does not exist is blocked by binding /dev/null over - * it, and bwrap makes the mount point for that bind on the host. Unlinking one - * while a sandbox is still bound over it detaches the mount inside that - * sandbox: the denied path can be created there, and what is written lands on - * the host. Whether a sandbox is still running is therefore not something a - * process may decide for itself — a second srt process, a caller that crashes, - * one that cleans up early, twice or never, each had a different answer, and a - * count kept in one process's memory could not see any of them. - * - * Every wrap now names the mount points it relies on in a manifest and has - * bwrap hold a lock on it for the sandbox's lifetime, so the kernel answers - * instead. + * A denyWrite path that does not exist is blocked by binding over a mount point + * bwrap makes on the host. Unlinking one while a sandbox is bound over it + * detaches the mount inside that sandbox, and the denied path can then be + * created on the host. So whether a sandbox still runs is asked of the kernel: + * every wrap names its mount points in a manifest that bwrap holds a lock on. */ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // These tests list the manifests, attack them from inside a sandbox and @@ -157,8 +150,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The manifest of a wrap that named `paths`, written a minute ago by - * `writer`: a process that is gone, which makes it a finished one, unless - * told otherwise. + * `writer`: by default a process that is gone, which makes it a finished one. */ function manifestNaming( paths: string[], @@ -211,7 +203,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(lstatSync(LOCK).size).toBe(0) // A second process wraps the same deny path, runs a command and cleans - // up. On the counter this took the running sandbox's mount point away. + // up. const second = spawnSync( process.execPath, [ @@ -257,10 +249,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { await waitFor(SIGNAL) cleanupBwrapMountPoints() cleanupBwrapMountPoints() - // `force` is what the exit handler and reset() pass: everything this - // process made may go. A sandbox that is running under a manifest is - // not this process's to end, so that manifest and what it names stay, - // for a later cleanup here or in any other process. + // `force` is what the exit handler and reset() pass. A sandbox running + // under a manifest is not this process's to end, so that manifest and + // what it names stay. cleanupBwrapMountPoints({ force: true }) expect(lstatSync(LOCK).size).toBe(0) expect(existsSync(manifestOf(command))).toBe(true) @@ -325,9 +316,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'stays while a second sandbox that denies it runs, after the one it was made for has ended and been cleaned up after', async () => { - // The second wrap finds the file there. It is a mount point because a - // manifest names it, so the wrap covers it and names it too, and its - // own manifest keeps it once the first sandbox's is gone. + // The second wrap finds the file there. A manifest names it, so the wrap + // covers and names it too, and its own manifest keeps it once the first + // is gone. const upSecond = join(AREA, 'up-second') const goSecond = join(AREA, 'go-second') const holder = spawn( @@ -384,13 +375,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { async () => { // Two processes as they really are, each working the directories out // for itself from the real names, in a mount namespace where this - // test's stand-ins are what is at those names. They differ in what - // the environment says, as a login shell and what a service or an IDE - // starts do, and each kept its manifests where its own environment - // said: the second wrap took the first's mount point for the user's - // own file, and it went from under the second sandbox when the first - // process cleaned up. The two that differ in their temp dir have no - // runtime directory, and meet under /tmp. + // test's stand-ins are at those names. They differ in what the + // environment says, as a login shell and what a service or an IDE + // starts do. The two that differ in their temp dir have no runtime + // directory, and meet under /tmp. const noRuntimeDirectory = join(BASE, 'run-user') mkdirSync(noRuntimeDirectory) const launcher = inPrivateNamespace( @@ -543,11 +531,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) it('is not taken for one on the word of a manifest where the environment says, by a process that can use a directory that comes from the user id', async () => { - // What the environment names is out of reach of the sandboxes of the - // processes with that environment, and of no others. While this process - // has a directory every process of the user looks in, what is found - // there is nobody's word: the file is the user's own, empty and - // read-only as it is. + // What the environment names is out of reach only of the sandboxes of + // processes with that environment. While this process has a directory every + // process of the user looks in, what is found there is not believed: the + // file is the user's own. const xdg = join(BASE, 'xdg') mkdirSync(xdg) chmodSync(xdg, 0o700) @@ -581,13 +568,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 'is not removed on the word of a manifest that the sandbox of a process with another temp dir wrote under this one', () => { // Two processes as they really are, in a mount namespace where this - // test's stand-ins are at the real names. The first has a temp dir of - // its own, inside what the second lets its command write. The second - // does not know of that temp dir, so its sandbox has nothing bound - // there, and its command writes a manifest under it that names - // somebody's empty read-only file. The first has a directory under - // /tmp to keep its manifests in, and believes nothing under its temp - // dir. + // test's stand-ins are at the real names. The first has a temp dir of its + // own, inside what the second lets its command write, so the second's + // command can write a manifest there that names somebody's empty + // read-only file. The first keeps its manifests under /tmp and believes + // nothing under its temp dir. const uid = process.getuid!() const HOME = join(BASE, 'home') const theirTmp = join(HOME, 'tmp') @@ -705,11 +690,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( 'cannot be made, written or moved aside under /tmp by a command that may write /tmp, while its process writes under the runtime directory', () => { - // The directory under /tmp is where a process without a runtime - // directory writes, and every process reads it. A command that may write - // /tmp runs as the same user: a directory it made there itself would - // pass every check of owner and mode, and what it put in it would be - // believed. So the wrap makes it first, and binds it read-only. + // The directory under /tmp is where a process without a runtime directory + // writes, and every process reads it. A command that may write /tmp runs + // as the same user, so a directory it made there would pass every check + // of owner and mode. So the wrap makes it first, and binds it read-only. const uid = process.getuid!() const theirs = `/tmp/srt-mount-points-${uid}` const onTheHost = join(runtime.places().tempDir, basename(theirs)) @@ -851,11 +835,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'are read-only in a sandbox that names no manifest of its own', async () => { - // A wrap with no absent deny path has no mount points and no manifest, - // and the manifests in the directory are then other sandboxes'. The - // collect that runs on the host believes them, so a command able to - // write one could have any empty file removed, and one able to delete - // one could make a running sandbox's mount points look unused. + // A wrap with no absent deny path has no manifest, and the manifests in + // the directory are then other sandboxes'. A collect on the host believes + // them, so the command must be able neither to write nor to delete one. const directory = dirname(manifestOf(await wrap('true'))) const before = readdirSync(directory).filter(n => n.endsWith('.json')) expect(before.length).toBeGreaterThan(0) @@ -894,13 +876,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'are read-only, too, in the directory under the temp dir, for a process that writes under the runtime directory', async () => { - // A process with a runtime directory keeps its manifests under it, and - // one without (in a container, under another user's `su`) under the - // temp dir, and every process reads both. A sandbox of the first kind - // that may write the temp dir could delete and forge the manifests of - // the second, which a collect on the host then believed. So every wrap - // binds every name read-only, and makes the one that is not there so - // that the command cannot make it first. + // A process with a runtime directory keeps its manifests under it, one + // without (in a container, under another user's `su`) under the temp dir, + // and every process reads both. Every wrap binds every name read-only and + // makes the one that is missing, so a sandbox of the first kind cannot + // forge the manifests of the second. const TMP = join(BASE, 'tmp') mkdirSync(TMP) const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) @@ -984,8 +964,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { timeout: 60000, }) // bubblewrap sets the mounts up before it opens the lock file, so the - // mount point is back on the host; the command it was made for never - // runs. + // mount point is back on the host; the command never runs. expect(result.status).not.toBe(0) expect(`${result.stdout}${result.stderr}`).toContain( 'Unable to open lock file', @@ -995,13 +974,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) - // ---- two commands of one process in flight ----------------------------- + // ---- two commands of one process in flight ---- // - // The caller cleans up once per command, when that command is over, and a - // call cannot tell which command it is for. So while a wrap is outstanding - // nothing this process made may go: the command that has not started yet - // still needs its manifest (bubblewrap opens it to take the lock) and its - // mount point. + // A call to clean up cannot tell which command it is for, so while a wrap is + // outstanding nothing this process made may go: the command that has not + // started still needs its manifest and its mount point. it.if(BWRAP_CAN_NAMESPACE)( 'a command wrapped and not yet started survives the clean-up after the one that finished first', @@ -1037,11 +1014,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a sandbox that takes its lock while another command is being cleaned up after keeps its manifest and its deny', async () => { - // The interleaving that chance produces when one command ends a few - // milliseconds after another was spawned, made certain: the collect - // looks at /proc/locks, where the second command's manifest is not yet - // locked, and before it goes on that command's sandbox comes up and - // takes the lock. + // The interleaving made certain: the collect reads /proc/locks while the + // second command's manifest is not yet locked, and that command's sandbox + // takes its lock before the collect goes on. const first = await wrap('true') const second = await wrap(heldCommand()) const manifest = manifestOf(second) @@ -1122,9 +1097,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(run(first).status).toBe(0) expect(existsSync(lockOfFirst)).toBe(true) // bubblewrap leaves it - // One call per command, as always. This one says which command it is - // for, so that command's mount point goes although the second is still - // outstanding, and the second's manifest is untouched. + // This call says which command it is for, so that command's mount point + // goes although the second is still outstanding. cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) expect(existsSync(lockOfFirst)).toBe(false) expect(existsSync(manifestOf(second))).toBe(true) @@ -1141,9 +1115,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a wrap that produced no command is not waited for by the clean-up after the next', async () => { - // It gave its count back when it threw: the caller has no command to - // clean up after, and one clean-up after the command that did run is - // all that is owed. + // It gave its count back when it threw, so one clean-up after the command + // that did run is all that is owed. const refused = wrapCommandWithSandboxLinux({ command: 'true', binShell: 'srt-no-such-shell', @@ -1164,10 +1137,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { - // Live: written by this process, which has not let go of it. The path - // was a mount point when that manifest was written; a file with content - // is nothing bubblewrap made, and /dev/null over it would hide it from - // the command it belongs to. + // Live: written by this process, which has not let go of it. A file with + // content is nothing bubblewrap made, and /dev/null over it would hide it + // from the command it belongs to. const first = await wrap('true') expect(first).toContain(`--ro-bind /dev/null ${LOCK}`) writeFileSync(LOCK, 'somebody wrote this\n') @@ -1179,11 +1151,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { - // Absent when the wrap asked what shape it has, there when it asked - // whether anything is: another sandbox started on the same path in - // between, its manifest published and its mount point made. Bound onto - // itself as the caller's own file it was named by this wrap nowhere, and - // went from under its sandbox at the other's clean-up. + // Absent when the wrap asked for its shape, there when it asked whether + // anything is: another sandbox started on the same path in between. const exists = fs.existsSync let made = false const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { @@ -1357,9 +1326,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `} catch {`, ` writeFileSync(${JSON.stringify(OUT)}, 'threw')`, `}`, - // Stays alive: a manifest of a process that is still running - // counts as live, so only letting go of it lets the mount point be - // collected here. + // Stays alive: a manifest of a running process counts as live, so + // only letting go of it lets the mount point be collected here. `await new Promise(resolve => setTimeout(resolve, 30000))`, ].join('\n'), ), @@ -1369,9 +1337,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { try { await waitFor(OUT) expect(readFileSync(OUT, 'utf8')).toBe('threw') - // This process names the same mount point and asks for the cleanup. Had - // the wrap that threw held on to its manifest, the process behind it - // being alive would have kept the mount point here. + // This process names the same mount point and asks for the cleanup. A + // wrap that threw must not have held on to its manifest. await wrap('true') cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) @@ -1383,18 +1350,13 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The one case where nothing but the kernel's lock keeps a mount point: the - * process that wrapped the command is GONE, and the sandbox it started is - * STILL RUNNING. The manifest's writer no longer vouches for it, the grace - * period has run out, and no count in anyone's memory knows about it; only - * bubblewrap's --lock-file, seen through /proc/locks, says the sandbox is - * there. Every test above has the wrapping process alive, which alone keeps + * process that wrapped the command is gone and the sandbox it started is still + * running. Every test above has the wrapping process alive, which alone keeps * the manifest live. * - * The wrap always asks for --die-with-parent, so a sandbox outlives the - * process that wrapped it only when something else is bubblewrap's parent: - * here a shell in a session of its own, as when a wrapped command is handed to - * a job runner, a terminal multiplexer or `nohup sh -c`, or when the wrapping - * process is killed and the `sh -c` it spawned the command through is not. + * The wrap always asks for --die-with-parent, so a sandbox outlives its wrapper + * only when something else is bubblewrap's parent: here a shell in a session of + * its own, as under a job runner, a terminal multiplexer or `nohup sh -c`. */ describe.if(isLinux)( 'A mount point whose sandbox outlives the process that wrapped it', @@ -1518,9 +1480,8 @@ describe.if(isLinux)( } /** - * Says it is up, waits to be told, tries the denied write, reports what - * that returned, and then STAYS: the sandbox is still running when it is - * killed further down. + * Says it is up, waits to be told, tries the denied write, reports the + * result, and then stays: the sandbox is still running when it is killed. */ function heldCommand(): string { return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` @@ -1528,8 +1489,8 @@ describe.if(isLinux)( /** * A process that wraps the held command, starts it under a shell in a - * session of its own, says who is who, waits for the sandbox to be up and - * then goes: `leave` is how. + * session of its own, waits for the sandbox to be up and then goes: `leave` + * is how. */ function writerScript(leave: string): string { return [ @@ -1605,7 +1566,7 @@ describe.if(isLinux)( /** * Has a process wrap the held command, start it and go, and checks the - * premise: that process is gone, and its sandbox is up and running. + * premise: that process is gone and its sandbox is running. */ async function sandboxWhoseWriterHasGone( leave: string, @@ -1654,9 +1615,8 @@ describe.if(isLinux)( `process.kill(process.pid, 'SIGKILL')`, 'SIGKILL', ], - // Its exit handler runs the cleanup, with `force`, under its own - // sandbox: everything the process made may go, and a sandbox that is - // still running under a manifest is not the process's to end. + // Its exit handler runs the cleanup with `force`: a sandbox still running + // under a manifest is not the process's to end. ['exits, running its exit-time cleanup', `process.exit(0)`, null], ] @@ -1675,11 +1635,9 @@ describe.if(isLinux)( expect(written.pid).toBe(info.writer) expect(written.paths).toContain(LOCK) - // Whether the mount point bwrap made is on the host, and the - // manifest that names it where the wrap put it, after each thing - // that might have taken them. Looked at together further down, so - // that a failure says all of what happened and not only the first - // of it. + // Whether the mount point and the manifest that names it are still + // there after each thing that might have taken them. Checked together + // further down, so a failure says all of what happened. const BOTH = 'the mount point and its manifest' const stayed: Record = {} const look = (when: string): void => { @@ -1708,11 +1666,10 @@ describe.if(isLinux)( cleanupBwrapMountPoints({ force: true }) look('after this process collected, twice') - // The sandbox is still there. Let it try the denied write: it must - // fail inside, and nothing may land on the host. (A mount point - // that was removed and made again by the second wrap is on the host - // again, but is no longer what this sandbox is bound over, so only - // the write tells.) + // The sandbox is still there. Its denied write must fail inside, and + // nothing may land on the host. (A mount point removed and made again + // by the second wrap is on the host but is no longer what this + // sandbox is bound over, so only the write tells.) expect(runningTree(info.launcher).length).toBeGreaterThan(1) expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ stayed: { @@ -1731,9 +1688,8 @@ describe.if(isLinux)( expect(runScript('collector.ts', collectorScript())).toBe(0) expect(existsSync(LOCK)).toBe(true) - // Kill the sandbox. However it ends the kernel drops the lock, and - // the next collect, by anyone, takes the mount point and the - // manifest away. + // Kill the sandbox. The kernel drops the lock, and the next collect + // by anyone removes the mount point and the manifest. await killTree(info.launcher) launcher = undefined expect(runScript('collector.ts', collectorScript())).toBe(0) @@ -1747,12 +1703,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'holds its deny while another process collects without pause, with the process that wrapped it gone', async () => { - // A collector that never has a candidate proves nothing, and with the - // wrapping process alive it never has one: the writer vouches for the - // manifest whatever the kernel says. Here the writer is gone and the - // grace is over before the write is tried, so for most of each round + // A collector that never has a candidate proves nothing. Here the + // writer is gone and the grace is over before the write is tried, so // the lock is all that stands between the collector and the mount - // point, and the collector is asking the whole time. + // point. const loopFile = join(BASE, 'collect-loop.ts') writeFileSync( loopFile, @@ -1798,8 +1752,8 @@ describe.if(isLinux)( /** * The mount point for a deny whose first missing component is not the leaf is - * an empty DIRECTORY, bound from an empty read-only one. On the host it looks - * like anyone's empty directory, so only a manifest says what it is. + * an empty directory. On the host it looks like anyone's, so only a manifest + * says what it is. */ describe.if(isLinux)( 'A directory mount point a running sandbox relies on', @@ -1891,9 +1845,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied by a second wrap whose deny path is exactly that directory', async () => { - // A live manifest names it, which used to get it /dev/null bound over - // it like a file mount point: bubblewrap cannot put a character device - // over a directory, and the command did not start. + // A live manifest names it. /dev/null cannot be bound over a directory: + // bubblewrap would refuse to start. const up = join(AREA, 'up-holder') const go = join(AREA, 'go-holder') const held = holder(up, go) @@ -1921,12 +1874,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied again by the same list, with the earlier wrap of this process not yet cleaned up after', async () => { - // No second process and nothing running: a manifest is live for as - // long as the process that wrote it has not let go of it. A deny list - // that names a missing directory and something beneath it makes the - // directory the mount point, and the same list again then finds it - // named by a live manifest. It is what the mandatory denies under - // `/.claude` make of a `denyWrite` on `/.claude` itself. + // No second process: a manifest is live while the process that wrote it + // has not let go of it. A deny list naming a missing directory and + // something beneath it makes the directory the mount point, and the + // same list again finds it named by a live manifest. const first = run(await wrap('echo first-ran', [DIRECTORY, LEAF])) expect(first.stdout).toContain('first-ran') expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) @@ -1951,10 +1902,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while that second sandbox runs, after the sandbox that made it has ended and been cleaned up after', async () => { - // Bound onto itself and not named, it went with the sandbox that made - // it: the bind came off inside the second sandbox, the denied - // directory could be made again there, and what was written under it - // landed on the host. + // Bound onto itself and not named, it would go with the sandbox that + // made it, and the denied directory could be made again inside the + // second sandbox. const upHolder = join(AREA, 'up-holder') const goHolder = join(AREA, 'go-holder') const upSecond = join(AREA, 'up-second') @@ -2001,11 +1951,10 @@ describe.if(isLinux)( 90000, ) - // ---- left by a process that was killed ------------------------------ + // ---- left by a process that was killed ---- // - // Its manifest is finished, not live: the writer is gone, nothing holds - // the lock, the grace is over. The next clean-up in any process removes - // the directory on that manifest's word, so a wrap that denies the + // Its manifest is finished, not live. The next clean-up in any process + // removes the directory on that manifest's word, so a wrap that denies the // directory in the meantime has to name it for itself. /** What a process killed a minute ago left in the manifest directory. */ @@ -2044,17 +1993,16 @@ describe.if(isLinux)( mkdirSync(DIRECTORY) const left = manifestOfAKilledProcess([DIRECTORY]) - // The list that does this: the directory itself, beside something - // beneath it, which needs no mount point of its own under a directory - // that is read-only. So the directory stays empty on the host. + // The directory itself, beside something beneath it, which needs no mount + // point of its own under a read-only directory. const command = await wrap('true', [DIRECTORY, LEAF]) expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) expect(command).not.toContain(LEAF) expect(namedBy(command)).toEqual([DIRECTORY]) - // A second command of this process, and the clean-up after it: the - // first is still outstanding, so nothing of this process's is released, - // and what the killed process left is collected. + // A second command of this process and the clean-up after it: the first + // is still outstanding, so nothing of this process's is released, and + // what the killed process left is collected. await wrap('true', []) cleanupBwrapMountPoints() expect(existsSync(left)).toBe(false) @@ -2098,10 +2046,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up', async () => { - // Bound onto itself and not named, it went at the next clean-up in - // any process: the bind came off inside the running sandbox, the - // denied directory could be made again there, and what was written - // under it landed on the host. + // Bound onto itself and not named, it would go at the next clean-up in + // any process, from under the running sandbox. const script = (name: string, lines: string[]): string => { const file = join(BASE, name) writeFileSync(file, lines.join('\n')) @@ -2181,13 +2127,11 @@ describe.if(isLinux)( ) /** - * The manifest directories, the store of fake files and the empty directory - * the placeholders bind from are each bound read-only in a sandbox, and what - * is found at their names is believed on the host. The bind keeps a command - * out of the directory; it does not keep the name leading to it. Where the - * temp dir (or the runtime directory) lies strictly inside a write root, the - * directories between the two are the command's to rename, bind and all, and - * to make again: so they are pinned, like those above a deny path. + * The manifest directories, the fake-file store and the empty placeholder + * source are bound read-only in a sandbox, and what is found at their names is + * believed on the host. The bind keeps a command out of the directory, not the + * name leading to it: where the temp dir lies strictly inside a write root the + * directories between the two are pinned. */ describe.if(isLinux)( 'The directories above the ones this library keeps on the host', @@ -2234,9 +2178,9 @@ describe.if(isLinux)( }) /** - * Runs `lines` in a process of its own whose temp dir is TMP, and which - * has no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is one that - * process does not have. + * Runs `lines` in a process of its own whose temp dir is TMP and which has + * no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is removed from + * its environment. */ function inAChild( name: string, @@ -2333,12 +2277,11 @@ describe.if(isLinux)( }) it('are pinned above every manifest directory, whichever of them the process writes to', () => { - // Four names for four directories, each strictly inside the write - // root: the runtime directory and /tmp, and what the environment has - // for the two. The process writes to the first, and a process of the - // user that writes to one of the others believes what it finds there, - // so the command is kept out of all of them: each is made, bound - // read-only where it is, and what lies above it pinned. + // Four names for four directories, each strictly inside the write root: + // the runtime directory and /tmp, and what the environment has for the + // two. A process of the user may believe what it finds in any of them, so + // the command is kept out of all: each is made, bound read-only where it + // is, and what lies above it pinned. const at = (name: string): string => { const dir = join(HOME, name, 'deeper') mkdirSync(dir, { recursive: true, mode: 0o700 }) @@ -2449,9 +2392,8 @@ describe.if(isLinux)( }) it('are pinned where the temp dir really is, when its name leads there through a link', () => { - // A pin is a mount, and a mount lands where its path resolves. On the - // name it would be a mount on a link, which bubblewrap refuses from - // 0.12 on. + // A pin is a mount, and a mount lands where its path resolves; on the + // name it would be a mount on a link, which bubblewrap refuses. const real = join(HOME, 'real-tmp') const through = join(HOME, 'tmp-link') mkdirSync(real) @@ -2484,10 +2426,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', async () => { - // What the command is after: the next clean-up on the host reads the - // directory at the name, finds no live manifest there that claims - // GUARD, and removes it from under the sandbox that relies on it, on - // the word of a manifest the command wrote. + // What the command is after: a clean-up on the host that reads the + // directory at the name, finds no live manifest claiming GUARD, and + // removes it from under the sandbox that relies on it. const theirs = join(TMP, `srt-mount-points-${UID}`) const up = join(HOME, 'up') const go = join(HOME, 'go') @@ -2565,13 +2506,10 @@ describe.if(isLinux)( ) /** - * A bind is made where its destination really is. bubblewrap makes the - * destination by name inside the new root before it mounts on it, and a link - * on the way whose target is absolute leads out of that root, to nothing: - * "Can't mkdir". The manifest directories were bound at the name the - * environment gave for the temp dir, so with a temp dir reached through such - * a link (a `/tmp` that is a link to `/var/tmp`, a `TMPDIR` that is one) no - * command that restricts writes started at all. + * A bind is made where its destination really is: bubblewrap makes the + * destination by name inside the new root, and a link with an absolute target + * on the way leads out of that root. So with a temp dir reached through such a + * link, every manifest directory is bound at its real path. */ describe.if(isLinux)( 'The manifest directory under a temp dir whose name is a link', @@ -2600,9 +2538,9 @@ describe.if(isLinux)( }) /** - * What a process whose temp dir is `through`, as `/tmp` is, and which has - * no runtime directory, wraps `command` as with that deny list, and what - * the command said when run. + * What a process whose temp dir is `through`, as `/tmp` is, with no runtime + * directory, wraps `command` as with that deny list, and what the command + * said when run. */ function wrappedAndRun( through: string, @@ -2778,12 +2716,10 @@ describe.if(isLinux)( ) /** - * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID - * is, and /proc/locks leaves out every lock whose holder has no pid in the - * namespace of the /proc being read. A process that cleans up from another PID - * namespace while sharing the manifest directory - a container beside the - * host, one srt inside another's sandbox - sees a running sandbox's manifest - * exactly as it would see one a dead process left. + * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID, + * and /proc/locks, which leaves out every lock whose holder has no pid there. A + * process cleaning up from another PID namespace that shares the manifest + * directory sees a running sandbox's manifest as it would see a dead process's. */ describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', @@ -2897,9 +2833,8 @@ describe.if(isLinux)( writeScript('collector.ts', [ `import { existsSync, readFileSync, statSync } from 'node:fs'`, `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - // The premise, so that a pass means something: from here the - // kernel's list really does leave the sandbox's lock out, and - // the writer's pid is nobody's. + // The premise: from here the kernel's list really does leave + // the sandbox's lock out, and the writer's pid is nobody's. `const inode = statSync(${JSON.stringify(manifest)}).ino`, `if (readFileSync('/proc/locks', 'utf8').includes(':' + inode + ' ')) process.exit(7)`, `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index c7591ade0..25438edbd 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -39,8 +39,8 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo /** * The manifests and the pass that collects on their word, below the level of a * sandbox: what a pass believes, what it looks at again before it removes - * anything, and that nothing about the directory's lock can hold a process up - * or be relied on. + * anything, and that the directory's lock neither holds a process up nor is + * relied on. */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() @@ -141,16 +141,14 @@ describe.if(isLinux)('The mount point manifests', () => { /** * Runs `source` with the module as `m` in a process of its own, which is - * killed if it has not ended by itself: what these cases guard against blocks - * the thread it happens on, so a test in this process could not time it out. - * `first` runs before the module is loaded, and an `env` entry that is - * undefined is one the child does not have. + * killed if it does not end by itself: what these cases guard against blocks + * the thread. `first` runs before the module is loaded, and an `env` entry + * that is undefined is removed from the child's environment. * - * The child looks where this process does for the runtime directory, and - * has a temp dir of its own, which is also what stands in for `/tmp`: it is - * where the manifests go when the runtime directory will not do, and the - * real one is every other process's. `places` puts something else in the - * place of either. It has no `XDG_RUNTIME_DIR` unless `env` gives it one. + * The child looks where this process does for the runtime directory and has a + * temp dir of its own, which also stands in for `/tmp`. `places` puts + * something else in the place of either. It has no `XDG_RUNTIME_DIR` unless + * `env` gives it one. */ function inAChild( source: string, @@ -217,14 +215,12 @@ describe.if(isLinux)('The mount point manifests', () => { const COLLECT = `m.collectMountPoints()` const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` - // ---- the directory's lock never holds a process up --------------------- + // ---- the directory's lock never holds a process up ---- // - // Whatever is at the lock's name, a collect and a publish both come back: - // by the deadline when it is somebody's lock, and at once when it is nothing - // this process could ever take. Each used to go round for ever, on the one - // thread, in the wrap, in the clean-up after a command and in the exit - // handler. "At once" is held to well under the two seconds somebody's lock - // is waited for: the wait is made four times over by one command. + // Whatever is at the lock's name, a collect and a publish both come back: by + // the deadline when it is somebody's lock, and at once when it is nothing + // this process could ever take. "At once" is held to well under the + // two-second wait, which one command makes four times over. const AT_ONCE_MS = 1500 const notALock: [string, () => void, boolean][] = [ @@ -278,10 +274,9 @@ describe.if(isLinux)('The mount point manifests', () => { it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( 'comes back from a collect at once where the directory is read-only from here, and collects nothing on its word', () => { - // What a process inside a sandbox sees of the directory: bound - // read-only, with a lock in it that it could never remove. It keeps its - // own manifests elsewhere, and what is in this one is not for it to - // judge: it could remove the mount point and not the manifest. + // What a process inside a sandbox sees of the directory: bound read-only, + // with a lock in it that it could never remove. It keeps its own + // manifests elsewhere and does not judge what is in this one. const lock = `${deadPid()} 1 ${OWN_NAMESPACE}\n` writeFileSync(LOCK, lock) leftover(X) @@ -327,11 +322,9 @@ describe.if(isLinux)('The mount point manifests', () => { }, 15000) it('believes a lock that names nobody until it is old, and breaks it then', () => { - // An empty lock file is what a lock made in two steps looked like for a - // moment, and a waiter that read it then took it for nobody's and removed - // it at once: two processes inside. The lock is made whole now, and one - // that says nothing is believed like any other whose holder cannot be - // asked after. + // An empty lock file says nothing about its holder. It is believed like any + // other lock whose holder cannot be asked after, not taken for nobody's and + // removed. writeFileSync(LOCK, '') leftover(X) manifestOfADeadProcess([X]) @@ -402,11 +395,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('keeps a mount point named by a manifest that is published while the pass is under way', () => { - // The lock does not exclude: its holder can be held up past the age at - // which it is broken, breaking one is not atomic, and a publish that - // cannot have it goes ahead without. So a manifest can appear between a - // pass listing the directory and its removals, naming a path the pass is - // about to remove, with a sandbox starting under it. + // The lock does not exclude, so a manifest can appear between a pass + // listing the directory and its removals, naming a path the pass is about + // to remove, with a sandbox starting under it. leftover(X) manifestOfADeadProcess([X]) let published: { status: number | null; stdout: string } | undefined @@ -431,8 +422,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('gives up only a lock that is still its own', () => { // Held up for longer than a lock is believed, a pass finds on its way out - // that the lock at that name is somebody else's by now. Removing it by - // name let a third process in beside the second. + // that the lock at that name is somebody else's by now, and must leave it. const theirs = `${process.ppid} 1 pid:[1]\n` let mine = '' const pass = duringTheNextPass(() => { @@ -478,9 +468,8 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('looks at /proc/locks a bounded number of times, not once for every mount point', () => { - // The list is the host's, and reading all of it for each removal made a - // pass over many mount points take seconds, under a lock whose waiters - // give up after two. + // The list is the host's: reading all of it for each removal would make a + // pass over many mount points outlast the lock's waiters. const many = 500 const paths: string[] = [] for (let i = 0; i < many; i++) { @@ -504,8 +493,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(removed.length).toBe(many) // One to begin with, one before the first removal, one for every 64 // removals after that, and one more for every two milliseconds the pass - // took, since a look goes out of date with time as well. No fewer than - // that either: one look is not good for ever. + // took. No fewer either: one look is not good for ever. expect(looks).toBeGreaterThanOrEqual(1 + Math.ceil(many / 64)) expect(looks).toBeLessThanOrEqual( 3 + Math.ceil(many / 64) + Math.ceil(tookMs / 2), @@ -518,7 +506,7 @@ describe.if(isLinux)('The mount point manifests', () => { () => { // Seen read-only from here, as from inside a sandbox that may not write // the directory it is in. The manifest is all that says the file is a - // mount point and whose; with it gone the file stayed for good. + // mount point, so it must stay while the file does. const readOnly = join(BASE, 'read-only-from-here') mkdirSync(readOnly) const inside = join(readOnly, 'config.lock') @@ -537,14 +525,11 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) - // ---- a manifest goes last, this process's own like anybody's ----------- + // ---- a manifest goes last, this process's own like anybody's ---- // - // A manifest is all that names its mount points. One this process wrote - // used to be unlinked as soon as the caller said its command was over, - // before the pass knew whether it would get as far as the removals or - // whether they would work; after a pass that turned back, or a removal that - // was refused, nothing on disk named the mount points and no later pass - // removed them. + // A manifest is all that names its mount points. It stays until a pass has + // removed what it names: after a pass that turned back, or a removal that was + // refused, a later pass still has it to go by. it.if(NOT_ROOT)( 'keeps the manifest of a wrap of its own whose mount point it could not remove, for a pass that can', @@ -573,8 +558,8 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = publishMountPointManifest([X], [])!.file // Another version of this library publishes, in a layout this one cannot - // read, after the pass has listed the directory: what that names cannot be - // kept path by path, so the pass stops short of removing anything. + // read, after the pass has listed the directory: the pass stops short of + // removing anything. const stranger = join(DIR, '4242-0123456789abcdef.json') const pass = duringTheNextPass(() => writeFileSync(stranger, '{"version":2}'), @@ -637,8 +622,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('keeps what a finished manifest names when its lock shows on the look before the removals', () => { // The bubblewrap a wrap has handed to its caller takes its lock whenever - // the caller starts it, which no lock of this library's can put off: the - // kernel is asked again before anything goes. + // the caller starts it, so the kernel is asked again before anything goes. leftover(X) const manifest = manifestOfADeadProcess([X]) const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n` @@ -665,10 +649,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('lists the directory again between removals, not only before the first', () => { - // A sandbox about to start on a path shows as a manifest that was not - // there when the pass began, and it can appear at any point of a pass over - // many mount points. One listing is good for a quarter of a millisecond, - // far less than a sandbox needs to get to its binds. + // A sandbox about to start on a path shows as a manifest that was not there + // when the pass began, at any point of a pass over many mount points. One + // listing is good for a quarter of a millisecond. const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -679,8 +662,8 @@ describe.if(isLinux)('The mount point manifests', () => { unlink(file) if (file === X && arrived === undefined) { // Published the moment the first mount point has gone, with the pass - // held up for a millisecond before it goes on to the next: less than - // the two for which it trusts what it read from /proc/locks. + // held up for a millisecond before the next: less than the two for + // which it trusts what it read from /proc/locks. arrived = manifestOfADeadProcess([Y], { pid: process.pid, created: Date.now(), @@ -789,12 +772,11 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(young)).toBe(true) }) - // ---- liveness is relative to a PID namespace --------------------------- + // ---- liveness is relative to a PID namespace ---- // - // /proc/locks lists a lock only when its holder has a pid in the namespace - // of the /proc being read, and /proc/PID is local to it. From another - // namespace a running sandbox's manifest looks exactly like one a dead - // process left. + // /proc/locks lists a lock only when its holder has a pid in the namespace of + // the /proc being read, and /proc/PID is local to it. From another namespace + // a running sandbox's manifest looks like one a dead process left. it.each([ ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], @@ -859,9 +841,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'is not kept live by a lock on a file with the same inode number on another filesystem', () => { // Inode numbers are per filesystem, and on a young tmpfs they are small. - // A manifest whose number some unrelated locked file shared read as - // live for as long as that lock was held: never released, and every - // path it named a mount point to every later wrap. + // A manifest that shares its number with an unrelated locked file on + // another filesystem must not read as live. leftover(X) const manifest = publishMountPointManifest([X], [])!.file const inode = statSync(manifest).ino @@ -911,8 +892,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'are never kept under /dev, which the sandbox mounts afresh over them', () => { // bubblewrap opens the manifest after its mounts, and the wrap mounts a - // new /dev after every bind: a manifest under /dev is not there to be - // opened, and every command with a mount point refused to start. + // new /dev after every bind: a manifest under /dev would not be there to + // be opened. const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) chmodSync(shm, 0o700) try { @@ -946,9 +927,8 @@ describe.if(isLinux)('The mount point manifests', () => { ) it("are not kept behind a link planted at the directory's name, whose target is left as it was", () => { - // The name is predictable and sits where sandboxed commands commonly - // write. A link there is refused, not followed: its target is somebody's - // directory, and would have had its mode changed. + // The name is predictable and sits where sandboxed commands commonly write. + // A link there is refused, not followed. const target = join(BASE, 'somebody-elses') mkdirSync(target) chmodSync(target, 0o755) @@ -970,8 +950,7 @@ describe.if(isLinux)('The mount point manifests', () => { 'go to a directory this process can write when the usual one is read-only from here', () => { // A process inside a sandbox sees the directory bound read-only. It is - // ours in every other respect, and used to be settled on, after which - // every wrap of that process failed to record its mount points. + // ours in every other respect, and must not be settled on. const elsewhere = TMP const published = inAChild(PUBLISH, { launcher: [ @@ -994,14 +973,12 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) - // ---- which directory is written to, and which are read ----------------- + // ---- which directory is written to, and which are read ---- // - // Where the manifests went used to be the environment's to say: under - // $XDG_RUNTIME_DIR, else under $TMPDIR. Two processes of one user that - // differed in either kept their manifests apart, and neither knew of the - // sandboxes of the other. The user id and the file system say it first - // now, the same for every process, and what they name is read by every - // process. + // Two processes of one user that differ in $XDG_RUNTIME_DIR or $TMPDIR must + // still find each other's manifests. The user id and the file system say + // where they go first, the same for every process, and what they name is read + // by every process. /** * Four places of their own under `base`, none of them with a manifest @@ -1447,16 +1424,14 @@ describe.if(isLinux)('The mount point manifests', () => { } }) - // What the environment names is kept out of a sandboxed command's reach by - // the processes whose environment names it, and by no other: a process with - // another temp dir has its sandboxes write there, if its caller lets them. - // So a manifest found there is somebody's word only to a process that has - // nowhere else to keep its own. + // What the environment names is kept out of a sandboxed command's reach only + // by processes whose environment names it. So a manifest found there is + // believed only by a process that has nowhere else to keep its own. /** - * In each directory the environment names: the manifest of a sandbox that - * is running, which would keep X, and one a dead process left, which would - * have Y removed. + * In each directory the environment names: the manifest of a running sandbox, + * which would keep X, and one a dead process left, which would have Y + * removed. */ function whereTheEnvironmentSays( four: ReturnType, @@ -1606,12 +1581,10 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(notBelieved)).toBe(true) }, 15000) - // ---- what is read as a manifest ---------------------------------------- + // ---- what is read as a manifest ---- // - // Whatever ends in `.json` in the directory used to be opened as it stood: - // a link was followed to wherever it led, and a FIFO held the thread for - // good, in the wrap, in the clean-up and in the exit handler. Only a - // regular file of the user's, of a size a manifest can have, is one. + // Only a regular file of the user's, of a size a manifest can have, is read: + // a link is not followed and a FIFO is not opened. /** Thirty seconds old: nothing can be starting under it any more. */ function aged(file: string): string { @@ -1725,12 +1698,11 @@ describe.if(isLinux)('The mount point manifests', () => { }) }, 15000) - // ---- which mount points a running sandbox relies on -------------------- + // ---- which mount points a running sandbox relies on ---- // - // A caller that removes paths of its own accord after a command removes by - // path, and removing a mount point from under a running sandbox lifts the - // deny there. So it can ask first which paths to leave out, and what it is - // told to leave out is never more than an empty placeholder. + // A caller that removes paths itself after a command can ask first which to + // leave out. What it is told to leave out is never more than an empty + // placeholder. /** The set, as a sorted list. */ function live(): string[] { @@ -1864,10 +1836,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('holds what any manifest names while a manifest that cannot be read may have a sandbox under it', () => { - // What another version of this library wrote, say. What it names cannot - // be listed, and whether the sandboxes of the others are over cannot be - // told apart from it, for as long as the clean-up takes it to be live: - // while it is new, or a lock is on it. + // What another version of this library wrote, say. What it names cannot be + // listed, so while the clean-up takes it to be live every named path + // counts. const written = join(BASE, 'written-to') writeFileSync(written, 'mine') chmodSync(written, 0o444) @@ -2005,8 +1976,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('does nothing at all where the platform is not Linux', () => { // Every clean-up after a command, on every platform, comes through here. - // Off Linux it made the directory and its lock each time, and on a - // platform with no uids a fresh temporary directory each time. + // Off Linux it must make nothing. leftover(X) manifestOfADeadProcess([X]) const before = readdirSync(DIR).sort() diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index ec65c6f8a..c9e74a933 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -47,18 +47,14 @@ function run( /** * A denyWrite path that does not exist gets `--ro-bind /dev/null `, and - * bwrap creates the mount point for it on the host: an empty file, mode 0444. - * A cleanup removes it once no sandbox relies on it. A process killed before - * it can clean up leaves the file behind, and it used to stay for good: every - * later wrap saw an existing file, bound it onto itself and never removed it. - * For a path whose existence is its meaning that is a lasting fault on the - * host: a leftover `.git/config.lock` makes every `git config` write outside - * the sandbox fail with "could not lock config file". + * bwrap creates the mount point for it on the host: an empty file, mode 0444. A + * process killed before it can clean up leaves the file behind. For a path + * whose existence is its meaning that is a lasting fault: a leftover + * `.git/config.lock` makes every `git config` write outside the sandbox fail. * * The killed process leaves its manifest as well, and that is what says the - * file is a leftover. The file alone does not: an empty read-only file is - * also what somebody keeps at such a path on purpose, and one taken for a - * leftover by its shape was removed after the first command. + * file is a leftover. The file alone does not: an empty read-only file is also + * what somebody keeps at such a path on purpose. */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const runtime = usePrivateManifestDirectory() @@ -106,8 +102,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } /** - * The manifest of a wrap that named `paths`, written by `pid`: a process - * that is gone, a minute ago, unless told otherwise. Where it is written. + * The manifest of a wrap that named `paths`, written a minute ago by `pid`, + * by default a process that is gone. Returns where it is written. */ function manifestNaming( paths: string[], @@ -188,11 +184,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { await wrap([LOCK]) await wrap([LOCK]) - // Two wraps handed out, one cleaned up after: the other command may not - // have started yet, and a command that starts after its mount point or - // its manifest has gone either refuses to start or runs without its deny. - // A call cannot tell which command it is for, so this process gives up - // nothing of its own until it has been called for both. + // Two wraps handed out, one cleaned up after. A call cannot tell which + // command it is for, so this process gives up nothing of its own until it + // has been called for both. cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) @@ -255,10 +249,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { }, ) - // ---- an empty read-only file that no manifest names -------------------- + // ---- an empty read-only file that no manifest names ---- // - // It is the caller's own, whatever it looks like: an empty `.mcp.json` of - // mode 0444, a lockfile somebody keeps in place. It is denied like any + // It is the caller's own, whatever it looks like. It is denied like any // existing path, by a read-only bind onto itself, and nothing ever removes // it. @@ -331,9 +324,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } expect(namedBy(command)).not.toContain(mandatory) expect(namedBy(command)).not.toContain(LOCK) - // The wrap has mount points of its own, for the mandatory denies that - // are absent: the clean-up below has something to remove, and removes - // that and nothing else. + // The wrap has mount points of its own, for the mandatory denies that are + // absent: the clean-up below removes those and nothing else. expect(namedBy(command)).toContain(join(project, '.bashrc')) const session = run(command, project) @@ -356,11 +348,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { ) it('is asked after again, where the manifests were read before another sandbox named it and made it', async () => { - // A wrap reads the manifests once. What was read before the file was - // there cannot say whose it is: a sandbox that started in between - // published its manifest first and had bubblewrap make the file after. - // Taken for the caller's own it was bound onto itself and named by this - // wrap nowhere, and went from under its sandbox at the other's clean-up. + // A wrap reads the manifests once. What was read before the file was there + // cannot say whose it is: a sandbox that started in between published its + // manifest first and had bubblewrap make the file after. const earlier = join(GIT_DIR, 'earlier.lock') plantLeftover(earlier) const exists = fs.existsSync @@ -387,9 +377,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it('is taken for absent where it has gone, with the manifest that named it, while the wrap was looking', async () => { // Seen with the shape of a mount point, and named by no manifest a moment - // later: the sandbox it was made for has ended and been cleaned up after - // in between. Bound onto itself it was a source that is not there, and - // the command did not start. + // later: the sandbox it was made for has ended and been cleaned up after in + // between. const left = plantLeftover(LOCK) const readdir = fs.readdirSync let collected = false @@ -524,9 +513,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) // A manifest whose writer is gone and that no sandbox has locked counts - // as live for half a second, in case the sandbox it covers is starting: - // the killed process left one, so the cleanup that takes its mount - // point away is the first one after that. + // as live for half a second, so the cleanup that takes its mount point + // away is the first one after that. await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) From 1312f18d44647c350fb8fa07f837eed5360a22ac Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:02:11 +0000 Subject: [PATCH 081/141] linux: a sandbox vouches for its own mount points; no lock file, no directory lock Whether a sandbox still ran was read from /proc/locks, which is not a snapshot: it is read a page at a time, and a sandboxed command that holds a few hundred locks and churns one makes a held lock vanish from a reading. Whenever only the lock vouched (an extra clean-up call, reset(), the wrapping process gone), the mount point was then removed and the denied write landed. The wrapped command now records itself before bubblewrap starts: `sh -c 'read -r s >"$1" && shift && exec "$@"'` appends the shell's own stat line, and the exec makes that pid and start time bubblewrap's. A manifest is live while a process on its record exists. Whatever cannot be read or asked counts as live. The manifest is the first thing bound after the root, so a command started after a clean-up has taken its manifest fails before bubblewrap makes anything. A clean-up renames a finished manifest to a claim, reads every manifest again, and removes a path only when every manifest naming it is claimed with no process on its record; it gives back a claim it made when that reading finds one. It takes no lock and waits for nothing: `--lock-file`, /proc/locks and directory.lock are gone, and with them the two-second stalls a stale lock caused. A manifest that can be recorded nowhere is kept in memory and removed by the process that made it. README: what is left (two wraps of one path and a clean-up held up between a listing and a removal). --- README.md | 21 +- src/sandbox/bwrap-mount-manifests.ts | 1122 ++++++++++---------------- src/sandbox/linux-sandbox-utils.ts | 132 ++- 3 files changed, 550 insertions(+), 725 deletions(-) diff --git a/README.md b/README.md index 3dcc8d56c..3cadee567 100644 --- a/README.md +++ b/README.md @@ -542,7 +542,7 @@ Watchman accesses files outside the sandbox boundaries, which will trigger permi **Linux requires:** -- `bubblewrap` - Container runtime +- `bubblewrap`, 0.3.1 or later - Container runtime - Ubuntu/Debian: `apt-get install bubblewrap` - Fedora: `dnf install bubblewrap` - Arch: `pacman -S bubblewrap` @@ -736,24 +736,25 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory. The command line it returns is a shell that first appends its own line of `/proc//stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`Can't find source path`) and leaves nothing on the host, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of the manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. +- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full temp dir), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Can't find source path") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or something in the directory cannot be read and may have a sandbox under it (every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken from one listing of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. - **Known limits.** - - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. + - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _Between looking and removing._ The directory lock does not exclude: a holder can be held up past the minute after which its lock is broken, and a wrap that cannot have the lock within two seconds records its manifest without it. A cleanup pass therefore reads `/proc/locks` and lists the directory again immediately before it removes anything, and then again whenever its listing is a quarter of a millisecond old, and what it read from `/proc/locks` two milliseconds old or 64 removals ago. It removes a manifest last, after everything it names, and keeps one whose mount point it could not remove. A pass that is held up for longer than a sandbox takes to start (a few milliseconds) between such a look and a removal can still take away the mount point of a sandbox that started on the same path in that time. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before its first removal and whenever that listing is a quarter of a millisecond old: a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap that publishes after the last such listing, and whose sandbox reaches its bind over a mount point that a claimed manifest names too before the removal that follows, loses that mount point: the cleanup would have to be held up between the listing and the removal for as long as a sandbox takes to start (a few milliseconds). + - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them. - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. -**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. +**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string run after that refuses to start rather than running without its mount points. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string run after that refuses to start rather than running without its mount points, and makes nothing on the host. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. - Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index eb15db090..f26cb5542 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -8,12 +8,21 @@ * only when no running sandbox relies on it, and no process can decide that by * counting for itself. * - * The kernel is asked instead. Each wrap writes a manifest naming its mount - * points into a per-user directory and passes `--lock-file ` to - * bwrap, whose sandbox init process holds a read lock on it for the sandbox's - * lifetime; the kernel drops it however the sandbox ends. /proc/locks then - * tells any process whether a sandbox that named a mount point is running, so - * cleanup is a garbage collect any process may run at any time. + * The sandbox vouches for itself. Each wrap writes a manifest naming its mount + * points, `.json`, into a per-user directory. The command line it hands out + * is a shell that appends its own /proc/$$/stat line to `.started` and then + * execs bubblewrap: the process on record is bubblewrap, recorded before it has + * made a mount, and the wrap always passes --die-with-parent, so the sandbox + * dies with it. A manifest with a record is live exactly while a process with a + * recorded pid and start time exists, which any process can ask, so cleanup is + * a garbage collect any process may run at any time. + * + * bubblewrap binds the manifest before it makes a mount point, and a pass + * claims a finished manifest, renaming it to `.claimed`, before it removes + * what that names. A command started after the claim fails having made nothing; + * one that got past it has its record on disk, which the pass reads after the + * claim. Whatever cannot be read or asked (a manifest, a record, /proc) counts + * as live: only "no such process" and a different start time end a sandbox. * * The manifest directories are bound read-only into every sandbox that * restricts writes, and the directories above them inside a write root are @@ -23,9 +32,9 @@ * directory elsewhere, and a runtime or temp directory that is a link inside a * write root or not there yet. * - * /proc/locks and /proc/PID are relative to a PID namespace, so a manifest - * records the namespace that wrote it and only a process in that namespace - * judges it; to any other it is live. + * /proc/PID is relative to a PID namespace, so a manifest records the namespace + * that wrote it and only a process in that namespace judges it; to any other it + * is live. */ import { randomBytes } from 'node:crypto' @@ -39,58 +48,46 @@ import { isAbsenceErrno } from './sandbox-utils.js' /** The only manifest layout this version writes and reads. */ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' +const STARTED_SUFFIX = '.started' +const CLAIMED_SUFFIX = '.claimed' /** - * A manifest this young counts as live with no lock on it. bubblewrap takes the - * lock a few milliseconds after the wrap returns; a wrapping process killed in - * between must not leave a starting sandbox's mount point unclaimed. + * A manifest this young counts as live with no started record yet, so that a + * command is not refused its start because the process that wrapped it was + * killed a moment before. */ const MANIFEST_GRACE_MS = 500 -/** How long a collect or a publish waits for the directory lock. */ -const DIRECTORY_LOCK_WAIT_MS = 2_000 - -/** Between attempts at the directory lock. */ -const DIRECTORY_LOCK_RETRY_MS = 25 - /** - * A directory lock this old is broken whoever holds it: the only way out for a - * holder that cannot be asked after (another PID namespace, unreadable - * content). A holder that is gone is broken at once. + * For how long one listing of the manifest directory is good during the + * removals: well under the time a starting sandbox needs to reach its binds, + * and a time rather than "before every removal" so a directory of thousands is + * not listed thousands of times. */ -const DIRECTORY_LOCK_STALE_MS = 60_000 - -/** - * How many mount points a pass removes on one read of /proc/locks, and for how - * long one read is good. A sandbox needs several milliseconds from its manifest - * being published to its binds being in place, so both stay under that; one - * read per removal would make a pass on a busy host outlast the directory lock - * wait. - */ -const REMOVALS_PER_LOOK = 64 -const LOCKS_GOOD_FOR_MS = 2 +const LISTING_GOOD_FOR_MS = 0.25 /** - * For how long one listing of the manifest directory is good: well under the - * time a starting sandbox needs to reach its binds, and a time rather than - * "before every removal" so a directory of thousands is not listed thousands of - * times. + * How often a reading starts over because a listed manifest was gone when it + * was opened (claimed, given back or collected meanwhile) before it gives up. */ -const LISTING_GOOD_FOR_MS = 0.25 +const LISTING_ATTEMPTS = 4 /** - * An unreadable manifest with no lock on it is dropped once it is this old: - * long enough for another version of this library to keep its own format. + * What is at a manifest's name and is not one this version reads counts as + * live until it is this old, and is dropped then: long enough for another + * version of this library to keep its own format. */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 /** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 +/** The largest started record read: a line of some 300 bytes for each run. */ +const RECORD_MAX_BYTES = 64 * 1024 + /** - * Suffix of a manifest or directory lock before it is moved into place. One - * left by a killed process is removed at {@link - * UNREADABLE_MANIFEST_MAX_AGE_MS}. + * Suffix of a manifest before it is moved into place. One left by a killed + * process is removed at {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. */ const TEMPORARY_SUFFIX = '.tmp' @@ -100,8 +97,8 @@ const ManifestSchema = z.object({ pid: z.number().int().nonnegative(), start: z.string(), /** - * The writer's PID namespace (`readlink /proc/self/ns/pid`): `pid` and the - * lock can only be asked after from there. Absent in older manifests. + * The writer's PID namespace (`readlink /proc/self/ns/pid`): a pid can only + * be asked after from there. Absent where it could not be read. */ ns: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ @@ -117,27 +114,35 @@ const ManifestSchema = z.object({ }) type Manifest = z.infer & { + /** Where it is: at its own name, `.json`, or at a claim's. */ file: string - /** The inode /proc/locks reports a lock on, and the device it is on. */ - inode: string - device: string - /** - * This process wrote it, the caller says the command is over, and nothing - * held the lock when the pass began: only a lock can still make it live. - */ + claimed: boolean + /** Its started record, `.started`. */ + record: string + /** This process wrote it and the caller says its command is over. */ released: boolean } /** - * Manifests this process wrote that are still on disk, with the command key the - * caller gave and whether that command is over. Remembered rather than acted on - * at once: a manifest must stay on disk until a pass removes what it names. + * Manifests this process wrote that are still on disk, by their own name, with + * the command key the caller gave and whether that command is over. Remembered + * rather than acted on at once: a manifest must stay on disk until a pass + * removes what it names. */ const ownManifests = new Map< string, { commandKey: string | undefined; over: boolean } >() +/** + * The mount points, and the directories they bind from, of wraps that could + * record them nowhere. No other process knows of them, so they are this + * process's to remove once none of its wraps is outstanding: in a pass like + * any other where there is a directory by then, so that a path another + * process's manifest names is kept, and on its own word where there is none. + */ +const unrecorded = { paths: new Set(), sources: new Set() } + /** * Which of this process's own manifests a collect may release. Only the caller * knows that a command is over: @@ -147,9 +152,8 @@ const ownManifests = new Map< * processes have finished with is collected; * - one command: that command is over, whatever else is running. * - * Never release the manifest of a wrap whose command has not started: - * bubblewrap opens the manifest to lock it, and the command would refuse to - * start or run with nothing on disk naming its mount points. + * A released manifest whose command has not started is collected, and the + * command is then refused its start. */ export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } @@ -165,14 +169,19 @@ let manifestDirectoryUnavailable = false const onLinux = (): boolean => process.platform === 'linux' /** - * Field 22 of /proc/PID/stat, the start time in clock ticks, counted from the - * last ')' because the comm field can hold spaces and parentheses. + * Field 22 of a /proc/PID/stat line, the start time in clock ticks, counted + * from the last ')' because the comm field can hold spaces and parentheses. + * `undefined` for a line that is cut short or is not one. */ function startTimeFromProcStat(stat: string): string | undefined { - return stat - .slice(stat.lastIndexOf(')') + 1) + const comm = stat.lastIndexOf(')') + const start = stat + .slice(comm + 1) .trim() .split(' ')[19] + return comm > 0 && start !== undefined && /^\d+$/.test(start) + ? start + : undefined } function processStartTime(pid: number): string | undefined { @@ -184,18 +193,22 @@ function processStartTime(pid: number): string | undefined { } /** - * Whether the process a manifest names is still running. An unreadable /proc - * answers yes: it must not make a live sandbox's manifest collectable. + * Whether the process with that pid and start time is running. Only "no such + * process" and a start time that differs say no: a sandboxed command can use up + * what this user may open, and a /proc that cannot be read must not make a live + * sandbox's mount points collectable. An unknown `start` leaves the pid alone + * to go by. */ -function writerIsRunning(pid: number, start: string): boolean { +function isRunning(pid: number, start: string): boolean { + let stat: string try { - return ( - startTimeFromProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')) === - start - ) + stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8') } catch (e) { - return !isAbsenceErrno(e) + const code = (e as NodeJS.ErrnoException).code + return code !== 'ENOENT' && code !== 'ESRCH' } + const found = startTimeFromProcStat(stat) + return found === undefined || !/^\d+$/.test(start) || found === start } let pidNamespace: string | undefined | null = null @@ -212,93 +225,6 @@ function ownPidNamespace(): string | undefined { return pidNamespace } -/** `major:minor` in decimal, as /proc/locks prints a device. */ -function deviceOf(dev: bigint): string { - const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) - const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) - return `${major}:${minor}` -} - -/** - * Filesystems on which stat reports the device /proc/locks prints. Elsewhere (a - * btrfs subvolume, some overlays) only the inode number can be compared. - */ -const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ - 0x01021994, // tmpfs, which is what $XDG_RUNTIME_DIR is - 0xef53, // ext2, ext3, ext4 - 0x58465342, // xfs -]) - -function reportsTheLockedDevice(dir: string): boolean { - try { - return FILESYSTEMS_WITH_ONE_DEVICE.has(Number(fs.statfsSync(dir).type)) - } catch { - return false - } -} - -/** The locks /proc/locks lists, of any kind, by what they are on. */ -type Locks = { - /** Every inode number with a lock on it, whatever filesystem it is on. */ - inodes: Set - /** The same locks as `major:minor:inode`, in decimal. */ - files: Set - /** Whether a manifest's device can be held against `files`. */ - comparesDevices: boolean -} - -/** - * Whether /proc/locks lists a lock on this file; nothing but bubblewrap's - * sandbox init locks a manifest. The device is compared where it can be: inode - * numbers are per filesystem, and an unrelated locked file with the same number - * would otherwise keep a manifest live. Where it cannot, the inode alone - * decides, which errs towards "locked". - */ -function holdsLock( - locks: Locks, - file: { inode: string; device: string }, -): boolean { - return locks.comparesDevices - ? locks.files.has(`${file.device}:${file.inode}`) - : locks.inodes.has(file.inode) -} - -/** - * What /proc/locks lists. Only locks whose holder has a pid in this PID - * namespace appear. `undefined` when it cannot be read, which every caller - * treats as "every manifest is locked". - */ -function readLocks(dir: string): Locks | undefined { - let text: string - try { - text = fs.readFileSync('/proc/locks', 'utf8') - } catch (e) { - logForDebugging( - `[Sandbox Linux] /proc/locks could not be read (${String(e)}) - leaving every mount point where it is`, - { level: 'warn' }, - ) - return undefined - } - const inodes = new Set() - const files = new Set() - for (const line of text.split('\n')) { - // The major:minor:inode word, which sits one field later on the lines that - // describe a blocked request ("2: -> POSIX ADVISORY WRITE ..."). - for (const word of line.split(/\s+/)) { - const match = /^([0-9a-f]+):([0-9a-f]+):(\d+)$/.exec(word) - if (match !== null) { - const inode = String(BigInt(match[3]!)) - inodes.add(inode) - files.add( - `${parseInt(match[1]!, 16)}:${parseInt(match[2]!, 16)}:${inode}`, - ) - break - } - } - } - return { inodes, files, comparesDevices: reportsTheLockedDevice(dir) } -} - /** Whether `dir` is a directory of ours that nobody else can write, and we can. */ function isOurPrivateDirectory(dir: string): boolean { try { @@ -430,7 +356,7 @@ function ensureManifestDirectory(): string | undefined { if (!directoryFailureLogged) { directoryFailureLogged = true logForDebugging( - `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are left on the host`, + `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are kept in memory and removed at its own clean-up`, { level: 'warn' }, ) } @@ -498,245 +424,125 @@ export function removePrivateManifestDirectory(): void { manifestDirectoryIsPrivate = false } -/** Block this thread, the only sleep available to a synchronous cleanup. */ -function sleep(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) -} - -/** What the directory lock holds: who took it, and in which PID namespace. */ -function directoryLockContent(): string { - return `${process.pid} ${processStartTime(process.pid) ?? '?'} ${ownPidNamespace() ?? '?'}\n` -} - -/** - * Take the directory lock: returns what it now holds, `held` when the name is - * taken, or `unavailable` when this process cannot make one there. The content - * is written under another name and hard-linked into place, so a waiter never - * reads a lock that is still empty and takes it for abandoned. - */ -function takeDirectoryLock( - dir: string, - lockFile: string, -): { content: string } | 'held' | 'unavailable' { - const content = directoryLockContent() - const temporary = path.join( - dir, - `directory.lock.${process.pid}.${randomBytes(8).toString('hex')}${TEMPORARY_SUFFIX}`, - ) - try { - fs.writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' }) - } catch { - return 'unavailable' - } - try { - fs.linkSync(temporary, lockFile) - return { content } - } catch (e) { - return (e as NodeJS.ErrnoException).code === 'EEXIST' - ? 'held' - : 'unavailable' - } finally { - try { - fs.unlinkSync(temporary) - } catch { - // Gone with the directory. - } - } -} - -/** - * Give the directory lock up if it is still this process's. One broken for its - * age meanwhile belongs to whoever took it next. - */ -function releaseDirectoryLock(lockFile: string, content: string): void { - try { - if (fs.readFileSync(lockFile, 'utf8') === content) { - fs.unlinkSync(lockFile) - } - } catch { - // Broken by another process while we held it: nothing to undo. - } -} +/** What is at a name and is not what this library keeps there. */ +class NotOurs extends Error {} /** - * What stands at the directory lock's name once taking it has failed: - * - * - `free`: nothing, or the lock of a holder that is gone, now removed; - * - `held`: somebody's lock, to be waited for; - * - `stuck`: something this process can neither read as a lock nor remove. - * - * Breaking a lock is read, ask, unlink, not one step, so a slow waiter's unlink - * can land on a lock a third process has taken since. Nothing prevents that, - * which is why nothing rests on this lock (see {@link withDirectoryLock}). + * What `file` holds, or `undefined` where nothing is there. Only a regular file + * of the user's, no larger than `max`, is read, through a descriptor opened + * without following a link and without blocking. Throws {@link NotOurs} for + * anything else that is there, and the error itself where that cannot be told. */ -function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { - let holder: string - let age: number +function readOwnFile(file: string, max: number): string | undefined { + let fd: number try { - // Opened without blocking or following a link: opening a FIFO waits for a - // writer, and only a regular file is a lock this library made. - if (!fs.lstatSync(lockFile).isFile()) { - return 'stuck' - } - const fd = fs.openSync( - lockFile, - fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW, + fd = fs.openSync( + file, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, ) - try { - const stat = fs.fstatSync(fd) - if (!stat.isFile()) { - return 'stuck' - } - age = Date.now() - stat.mtimeMs - holder = fs.readFileSync(fd, 'utf8') - } finally { - fs.closeSync(fd) - } } catch (e) { - // Gone meanwhile: the next attempt takes it. Anything else cannot be read. - return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' - } - // A holder can be asked after only in its own PID namespace. One that cannot - // be, or that did not say who it is, is believed until the lock is stale. - const who = /^(\d+) (\d+) (\S+)\n$/.exec(holder) - const held = - age < DIRECTORY_LOCK_STALE_MS && - (who === null || - who[3] !== ownPidNamespace() || - writerIsRunning(Number(who[1]), who[2]!)) - if (held) { - return 'held' + const code = (e as NodeJS.ErrnoException).code + if (code === 'ENOENT') return undefined + // What O_NOFOLLOW says of a link. + throw code === 'ELOOP' ? new NotOurs(`${file} is a link`) : e } try { - fs.unlinkSync(lockFile) - } catch (e) { - // Another process broke it first, or it cannot be removed from here. - return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' - } - return 'free' -} - -/** - * Run `body` while holding the manifest directory's lock, and report whether it - * ran and, if not, whether the lock was `held` throughout. Gives up after - * {@link DIRECTORY_LOCK_WAIT_MS}, and at once when what is at the lock's name - * can never be taken: the wait blocks the wrap, the clean-up after every - * command and the exit handler. - * - * The lock keeps srt processes out of each other's way; it does not exclude, - * and nothing may rest on it. Its holder can outlive the age at which it is - * broken, the break is not atomic, and a publish that cannot have the lock goes - * ahead without it. What makes a mount point safe to remove is looked at again - * immediately before the removals and during them (see {@link - * collectUnderLock}). - */ -function withDirectoryLock( - dir: string, - body: () => T, -): { ran: true; value: T } | { ran: false; held: boolean } { - const lockFile = path.join(dir, 'directory.lock') - const deadline = Date.now() + DIRECTORY_LOCK_WAIT_MS - for (;;) { - const taken = takeDirectoryLock(dir, lockFile) - if (taken === 'unavailable') { - return { ran: false, held: false } - } - if (taken !== 'held') { - try { - return { ran: true, value: body() } - } finally { - releaseDirectoryLock(lockFile, taken.content) - } - } - if (examineDirectoryLock(lockFile) === 'stuck') { - logForDebugging( - `[Sandbox Linux] ${lockFile} is not a lock this process can take or break - going on without it`, - { level: 'warn' }, - ) - return { ran: false, held: false } - } - // On every round: a lock that others keep breaking and retaking is as good - // as held. - if (Date.now() >= deadline) { - return { ran: false, held: true } + const stat = fs.fstatSync(fd) + if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.size > max) { + throw new NotOurs(`${file} is not a small regular file of this user's`) } - sleep(DIRECTORY_LOCK_RETRY_MS) + return fs.readFileSync(fd, 'utf8') + } finally { + fs.closeSync(fd) } } +/** `/` of a manifest, its claim or its started record. */ +const idOf = (file: string): string => file.slice(0, file.lastIndexOf('.')) + /** - * What is at a manifest's name, read as one, or `undefined` when it is not a - * manifest this version can read. Only a regular file of the user's, no larger - * than {@link MANIFEST_MAX_BYTES}, is read, through a descriptor opened without - * following a link and without blocking. + * The manifest at `file`, which is its own name or its claim's, or `undefined` + * where nothing is there. Throws like {@link readOwnFile}, and {@link NotOurs} + * for what is not a manifest of this version. */ function readManifest(file: string): Manifest | undefined { - let inode: string - let device: string - let text: string - try { - const fd = fs.openSync( - file, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, - ) - try { - const stat = fs.fstatSync(fd, { bigint: true }) - if ( - !stat.isFile() || - Number(stat.uid) !== process.getuid?.() || - stat.size > MANIFEST_MAX_BYTES - ) { - return undefined - } - inode = String(stat.ino) - device = deviceOf(stat.dev) - text = fs.readFileSync(fd, 'utf8') - } finally { - fs.closeSync(fd) - } - } catch { + const text = readOwnFile(file, MANIFEST_MAX_BYTES) + if (text === undefined) { return undefined } let json: unknown try { json = JSON.parse(text) } catch { - return undefined + throw new NotOurs(`${file} is not JSON`) } const parsed = ManifestSchema.safeParse(json) - return parsed.success - ? { ...parsed.data, file, inode, device, released: false } - : undefined + if (!parsed.success) { + throw new NotOurs( + `${file} is not a manifest of version ${MANIFEST_VERSION}`, + ) + } + const id = idOf(file) + return { + ...parsed.data, + file, + claimed: file.endsWith(CLAIMED_SUFFIX), + record: `${id}${STARTED_SUFFIX}`, + released: ownManifests.get(`${id}${MANIFEST_SUFFIX}`)?.over === true, + } } /** - * Whether a sandbox that named this manifest may still be running, as far as - * this process can tell. It can tell only for a manifest written in its own PID - * namespace; one from another namespace is always live here and is collected by - * a process in the namespace that wrote it. One that names no namespace is live - * while its writer can be seen, and otherwise until {@link - * UNREADABLE_MANIFEST_MAX_AGE_MS}. + * Whether a process on the started record is running, or `undefined` with no + * record. A record that cannot be read, is not the user's small regular file, + * or is not whole /proc/PID/stat lines vouches: only a process that is gone + * says its sandbox has ended. */ -function isLive(manifest: Manifest, locks: Locks): boolean { - if (holdsLock(locks, manifest)) { +function recordVouches(record: string): boolean | undefined { + let lines: string[] + try { + const text = readOwnFile(record, RECORD_MAX_BYTES) + if (text === undefined) { + return undefined + } + lines = text.split('\n') + } catch { return true } - if (manifest.released) { - return false + // Empty between the command line's shell making it and writing to it. + if (lines.pop() !== '' || lines.length === 0) { + return true } - const here = ownPidNamespace() - if (manifest.ns === undefined || here === undefined) { + return lines.some(line => { + const pid = /^(\d+) \(/.exec(line)?.[1] + const start = startTimeFromProcStat(line) return ( - writerIsRunning(manifest.pid, manifest.start) || - Date.now() - manifest.created < UNREADABLE_MANIFEST_MAX_AGE_MS + pid === undefined || start === undefined || isRunning(Number(pid), start) ) - } - if (manifest.ns !== here) { + }) +} + +/** + * Whether a sandbox that named this manifest may be running, or may yet start. + * Only a process in the PID namespace that wrote it can tell; to any other it + * is live, and is collected by a process in its own. With a started record, the + * record alone says. With none, no sandbox has got past a claim, and one the + * caller has not released is live while it is young or its writer runs. + */ +function isLive(manifest: Manifest): boolean { + const here = ownPidNamespace() + if (here === undefined || manifest.ns !== here) { return true } + const vouched = recordVouches(manifest.record) + if (vouched !== undefined) { + return vouched + } return ( - Date.now() - manifest.created < MANIFEST_GRACE_MS || - writerIsRunning(manifest.pid, manifest.start) + !manifest.claimed && + !manifest.released && + (Date.now() - manifest.created < MANIFEST_GRACE_MS || + isRunning(manifest.pid, manifest.start)) ) } @@ -874,18 +680,19 @@ function removeMountPoint(mountPoint: string): Removal { export type MountPointManifest = { /** The directory to bind read-only inside the sandbox. */ dir: string - /** The manifest, for bubblewrap's --lock-file. */ + /** The manifest, for bubblewrap to bind before it makes a mount point. */ file: string + /** Its started record, for the command line to append to before bubblewrap. */ + started: string } /** * Record the mount points this wrap relies on, and the empty directories they - * bind from, before the sandbox can start, and say where to point bubblewrap's - * --lock-file. + * bind from, before the sandbox can start. * * `undefined` when there is nothing to record or no manifest could be written. - * The caller must then not track those mount points: what this process cannot - * record it does not remove, and nobody else will. + * What could not be recorded is kept in memory and removed at this process's + * own clean-up, since no other process will know of it. */ export function publishMountPointManifest( mountPoints: readonly string[], @@ -896,90 +703,177 @@ export function publishMountPointManifest( return undefined } const dir = ensureManifestDirectory() - if (dir === undefined) { - return undefined - } - const file = path.join( - dir, - `${process.pid}-${randomBytes(8).toString('hex')}${MANIFEST_SUFFIX}`, - ) - const temporary = `${file}${TEMPORARY_SUFFIX}` - const body: z.infer = { - version: MANIFEST_VERSION, - pid: process.pid, - start: processStartTime(process.pid) ?? '', - ns: ownPidNamespace(), - created: Date.now(), - paths: [...new Set(mountPoints)], - sources: [...new Set(sources)], - } - // Written beside the manifest and renamed onto it, so a collect reads it - // whole or not at all. Under the directory lock where that can be had; a wrap - // is not refused for want of one, because every pass lists the directory - // again before it removes anything (see collectUnderLock). - try { - fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) - const publish = (): void => fs.renameSync(temporary, file) - if (!withDirectoryLock(dir, publish).ran) { - publish() + if (dir !== undefined) { + const id = path.join( + dir, + `${process.pid}-${randomBytes(8).toString('hex')}`, + ) + const file = `${id}${MANIFEST_SUFFIX}` + const temporary = `${file}${TEMPORARY_SUFFIX}` + const body: z.infer = { + version: MANIFEST_VERSION, + pid: process.pid, + start: processStartTime(process.pid) ?? '', + ns: ownPidNamespace(), + created: Date.now(), + paths: [...new Set(mountPoints)], + sources: [...new Set(sources)], } - } catch (e) { + // Written beside the manifest and renamed onto it, so that it is read whole + // or not at all. try { - fs.unlinkSync(temporary) - } catch { - // Never made. + fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) + fs.renameSync(temporary, file) + ownManifests.set(file, { commandKey, over: false }) + return { dir, file, started: `${id}${STARTED_SUFFIX}` } + } catch (e) { + try { + fs.unlinkSync(temporary) + } catch { + // Never made. + } + logForDebugging( + `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are kept in memory and removed at this process's own clean-up`, + { level: 'warn' }, + ) } - logForDebugging( - `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are left on the host`, - { level: 'warn' }, - ) - return undefined } - ownManifests.set(file, { commandKey, over: false }) - return { dir, file } + for (const mountPoint of mountPoints) unrecorded.paths.add(mountPoint) + for (const source of sources) unrecorded.sources.add(source) + return undefined } /** * Drop the manifest of a wrap that never produced a command. No sandbox can - * hold its lock, so the mount points it named are no one's. + * start under it, so the mount points it named are no one's. */ export function discardMountPointManifest(file: string): void { ownManifests.delete(file) + drop(file) +} + +function drop(file: string): boolean { try { fs.unlinkSync(file) + return true } catch { - // Collected already. + // Gone already, or not a file. + return false + } +} + +/** One reading of a manifest directory. */ +type Reading = { + /** Every name in it. */ + names: string[] + /** The manifests that could be read, claimed ones among them. */ + manifests: Manifest[] + /** + * Why a sandbox may be running on paths that this reading cannot list, if one + * may: nothing may then be removed on it. + */ + inDoubt?: string + /** What is there and is of no use to anybody any more. */ + spent: string[] +} + +/** + * Reads the manifests in `dir`, every one that the listing showed. + * + * A pass renames a manifest to claim it and to give it back, which can hide it + * from a listing under way and from the open that follows one. So a listed + * manifest that is gone when opened starts the reading over, and a started + * record on which a process runs, with no manifest listed beside it, puts the + * reading in doubt: a record is never renamed. + * + * So does whatever is at a manifest's name and cannot be read: always where + * that is an error (a sandboxed command can use up what this user may open), + * and where it is not a manifest of this version, while a process on its record + * runs or it is younger than {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. + */ +function readManifests(dir: string): Reading { + const old = (file: string): boolean => { + try { + return ( + Date.now() - fs.lstatSync(file).mtimeMs > UNREADABLE_MANIFEST_MAX_AGE_MS + ) + } catch { + return false + } + } + for (let attempt = 1; ; attempt++) { + let names: string[] + try { + names = fs.readdirSync(dir) + } catch (e) { + return { names: [], manifests: [], spent: [], inDoubt: String(e) } + } + const reading: Reading = { names, manifests: [], spent: [] } + const listed = new Set() + let moved = false + for (const name of names) { + if (!name.endsWith(MANIFEST_SUFFIX) && !name.endsWith(CLAIMED_SUFFIX)) { + continue + } + const file = path.join(dir, name) + listed.add(idOf(file)) + try { + const manifest = readManifest(file) + if (manifest !== undefined) reading.manifests.push(manifest) + else moved = true + } catch (e) { + if ( + e instanceof NotOurs && + recordVouches(`${idOf(file)}${STARTED_SUFFIX}`) !== true && + old(file) + ) { + reading.spent.push(file) + } else { + reading.inDoubt ??= `${file}: ${String(e)}` + } + } + } + if (moved && attempt < LISTING_ATTEMPTS) { + continue + } + if (moved) { + reading.inDoubt ??= `${dir} kept changing` + } + for (const name of names) { + const file = path.join(dir, name) + if (name.endsWith(TEMPORARY_SUFFIX)) { + // Left by a process killed between writing and moving into place. + if (old(file)) reading.spent.push(file) + } else if (name.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { + if (recordVouches(file) === true) { + reading.inDoubt ??= `${file} has no manifest` + } else { + reading.spent.push(file) + } + } + } + return reading } } /** * The mount points the manifests name, on one reading of the directory: `live` - * by a manifest whose sandbox may still be running, `named` by any manifest. - * What only a finished manifest names is a leftover the next pass removes. + * by a manifest whose sandbox may still be running, `named` by any manifest, a + * claimed one included. What only a finished manifest names is a leftover the + * next pass removes. * - * `live` is empty when /proc/locks cannot be read, and both are when the - * directory cannot be: a wrap takes a path for a mount point on a manifest's - * word only, so it then takes every existing path for the caller's own. + * With no directory, or none that can be read, both hold only what this process + * could record nowhere: a wrap takes a path for a mount point on a manifest's + * word only, so it then takes every other existing path for the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { - const live = new Set() - const named = new Set() + const live = new Set(unrecorded.paths) + const named = new Set(unrecorded.paths) const dir = onLinux() ? ensureManifestDirectory() : undefined - if (dir === undefined) { - return { live, named } - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { - return { live, named } - } - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const manifest = readManifest(path.join(dir, name)) - if (manifest === undefined) continue - const isLiveOne = locks !== undefined && isLive(manifest, locks) + for (const manifest of dir === undefined + ? [] + : readManifests(dir).manifests) { + const isLiveOne = isLive(manifest) for (const mountPoint of manifest.paths) { named.add(mountPoint) if (isLiveOne) { @@ -1027,15 +921,15 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command: removing a mount point from under a running * sandbox lifts the deny there. * - * A snapshot, taken without a lock, that may be out of date by the time the - * caller acts. Its only safe use is to SKIP a removal; a path not being in it - * never means the path is free to write. + * A snapshot that may be out of date by the time the caller acts. Its only safe + * use is to SKIP a removal; a path not being in it never means the path is free + * to write. * * A path is in the set only when both hold: * - * - a manifest names it, and that manifest is live by the clean-up's rule or - * its liveness cannot be told (/proc/locks unreadable, or an unreadable - * manifest that is locked or new), in which case every named path counts; + * - a manifest names it, and that manifest is live by the clean-up's rule, or + * something in the directory cannot be read and may have a sandbox under it, + * in which case every named path counts; * - what is at the path now is still an empty placeholder: an empty regular * file with no write bit and one link, or an empty directory, and the user's. * So a stale or forged manifest never makes a caller spare a file with @@ -1049,50 +943,18 @@ function isEmptyMountPoint(p: string): boolean { */ export function liveMountPoints(): ReadonlySet { const spared = new Set() - const dir = onLinux() ? existingManifestDirectory() : undefined - if (dir === undefined) { - return spared - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { + if (!onLinux()) { return spared } - const live = new Set() - const named = new Set() - let cannotTell = locks === undefined - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) - const manifest = readManifest(file) - if (manifest === undefined) { - // Gone since the listing, or not to be read: held to what the clean-up - // holds it to. - try { - const stat = fs.statSync(file, { bigint: true }) - cannotTell ||= - locks === undefined || - holdsLock(locks, { - inode: String(stat.ino), - device: deviceOf(stat.dev), - }) || - Date.now() - Number(stat.mtimeMs) < MANIFEST_GRACE_MS - } catch { - // Gone. - } - continue - } - const isLiveOne = locks !== undefined && isLive(manifest, locks) - for (const mountPoint of manifest.paths) { - named.add(mountPoint) - if (isLiveOne) { - live.add(mountPoint) - } + const dir = existingManifestDirectory() + const reading = dir === undefined ? undefined : readManifests(dir) + const named = new Set(unrecorded.paths) + for (const manifest of reading?.manifests ?? []) { + if (reading?.inDoubt !== undefined || isLive(manifest)) { + for (const mountPoint of manifest.paths) named.add(mountPoint) } } - for (const mountPoint of cannotTell ? named : live) { + for (const mountPoint of named) { if (isEmptyMountPoint(mountPoint)) { spared.add(mountPoint) } @@ -1123,270 +985,180 @@ export function collectMountPoints( } } const dir = ensureManifestDirectory() - if (dir === undefined) { - return [] + if (dir !== undefined) { + return collect(dir, release === 'all') } - const collected = withDirectoryLock(dir, () => collectUnderLock(dir)) - if (!collected.ran) { - logForDebugging( - collected.held - ? `[Sandbox Linux] The lock on the mount point directory could not be had, leaving this pass to whoever has it: ${dir}` - : `[Sandbox Linux] No lock can be taken on the mount point directory from here, so nothing is collected from it until that is put right: ${dir}`, - ) - return [] + const removed: string[] = [] + if (release === 'all') { + for (const mountPoint of unrecorded.paths) { + if (removeMountPoint(mountPoint) === 'removed') removed.push(mountPoint) + } + for (const source of unrecorded.sources) { + if (removeMountSource(source) === 'removed') removed.push(source) + } + unrecorded.paths.clear() + unrecorded.sources.clear() } - return collected.value + return removed } /** - * One pass, made under the directory lock where the caller could take it. + * One pass over a manifest directory. It takes no lock and waits for nothing; + * any number may run at once, over the same claims. + * + * INVARIANT: a path is removed only when every manifest that names it is + * claimed and has no process on its record, by a reading made after the claims. + * bubblewrap binds the manifest before it makes a mount point, so a start under + * a claimed manifest is refused, and a sandbox that got past the claim wrote + * its record first: every reading after the claim finds it. A manifest at its + * own name keeps what it names, live or not, since a start under it can succeed + * at any moment. Acting on a reading made before the claims would remove a + * mount point from under a sandbox that started in between. * - * A manifest is all that names its mount points, so it is removed last, and - * only when every path it names is gone or is no longer a mount point: a pass - * that turns back, or a removal that is refused, leaves it for a later pass. - * Manifests the caller has said are over are judged as if their writer were - * gone. + * A pass gives back the claims it made itself, and only when that reading finds + * a process on the record. So what remains is a manifest that comes to its own + * name after the reading, published or given back, whose sandbox reaches its + * bind on a path a claimed manifest names too. The directory is listed again + * before the first removal and whenever that listing is {@link + * LISTING_GOOD_FOR_MS} old, and what has come to its name keeps what it names, + * so the pass would have to be held up between a listing and the removal that + * follows it for as long as a sandbox takes to start. * - * The lock does not exclude (see {@link withDirectoryLock}), so before the - * first removal, and again at {@link LISTING_GOOD_FOR_MS}, the pass lists the - * directory again: a manifest that was not there at first keeps everything it - * names, which is how a sandbox about to start on the same path shows. And - * every {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS} it - * reads /proc/locks again: a manifest locked since keeps what it names. What - * remains is a pass held up between a look and the removal for longer than a - * sandbox takes to reach its binds; it then removes that sandbox's mount point. + * A manifest is all that names its mount points, so its claim is dropped last, + * and stays, for a later pass, when the pass turns back or a removal is refused. */ -function collectUnderLock(dir: string): string[] { - let names: string[] - try { - names = fs.readdirSync(dir) - } catch (e) { - logForDebugging( - `[Sandbox Linux] The mount point manifests could not be listed (${String(e)}) - nothing removed`, - { level: 'warn' }, - ) - return [] - } - const manifests: Manifest[] = [] - const unreadable: string[] = [] - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) - const manifest = readManifest(file) - if (manifest === undefined) { - unreadable.push(file) - } else { - manifests.push(manifest) - } - } - const locks = readLocks(dir) - if (locks === undefined) { - return [] - } - // The first listing: whatever is found later is a newcomer. - const known = new Set(names) - // Forget own manifests that something else has removed. - for (const file of ownManifests.keys()) { - if (path.dirname(file) === dir && !known.has(path.basename(file))) { - ownManifests.delete(file) - } - } - - // An unreadable manifest names mount points that cannot be honoured one by - // one. While it is locked, or new enough that a sandbox may be starting, - // nothing at all is removed. - let unreadableIsLive = false - for (const file of unreadable) { - let stat: fs.BigIntStats - try { - stat = fs.statSync(file, { bigint: true }) - } catch { - continue +function collect(dir: string, unrecordedToo: boolean): string[] { + const before = readManifests(dir) + before.spent.forEach(drop) + const own = new Set() + let claims = false + if (before.inDoubt === undefined) { + // Forget own manifests that something else has removed. + const there = new Set(before.names.map(name => idOf(path.join(dir, name)))) + for (const file of ownManifests.keys()) { + if (path.dirname(file) === dir && !there.has(idOf(file))) { + ownManifests.delete(file) + } } - const age = Date.now() - Number(stat.mtimeMs) - if ( - holdsLock(locks, { - inode: String(stat.ino), - device: deviceOf(stat.dev), - }) || - age < MANIFEST_GRACE_MS - ) { - unreadableIsLive = true - } else if (age > UNREADABLE_MANIFEST_MAX_AGE_MS) { - try { - fs.unlinkSync(file) - } catch { - // Gone already. + for (const manifest of before.manifests) { + if (!manifest.claimed && !isLive(manifest)) { + const claim = `${idOf(manifest.file)}${CLAIMED_SUFFIX}` + try { + fs.renameSync(manifest.file, claim) + own.add(claim) + } catch { + // Another pass has it. + } } + claims ||= manifest.claimed || own.size > 0 } } - if (unreadableIsLive) { + const reading = claims ? readManifests(dir) : before + if (reading.inDoubt !== undefined) { logForDebugging( - '[Sandbox Linux] A mount point manifest a sandbox is running under could not be read - leaving every mount point where it is', + `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${reading.inDoubt})`, { level: 'warn' }, ) return [] } - // What a process killed between writing a file and moving it into place - // left. Nothing reads these, and nothing else removes them. - for (const name of names) { - if (!name.endsWith(TEMPORARY_SUFFIX)) continue - const file = path.join(dir, name) - try { - if ( - Date.now() - fs.lstatSync(file).mtimeMs > - UNREADABLE_MANIFEST_MAX_AGE_MS - ) { - fs.unlinkSync(file) - } - } catch { - // Moved into place, or removed by its writer, in the meantime. - } - } - - // Over, by the caller's word, unless a sandbox still holds the lock. - for (const manifest of manifests) { - manifest.released = - ownManifests.get(manifest.file)?.over === true && - !holdsLock(locks, manifest) - } - - const finished: Manifest[] = [] - const claimed = new Set() - const claim = (manifest: Manifest): void => { + const kept = new Set() + const keep = (manifest: Manifest): void => { for (const named of [...manifest.paths, ...manifest.sources]) { - claimed.add(named) + kept.add(named) } } - for (const manifest of manifests) { - if (isLive(manifest, locks)) { - claim(manifest) - } else { + const finished: Manifest[] = [] + for (const manifest of reading.manifests) { + if (manifest.claimed && !isLive(manifest)) { finished.push(manifest) + continue + } + keep(manifest) + if (own.has(manifest.file)) { + try { + fs.renameSync(manifest.file, `${idOf(manifest.file)}${MANIFEST_SUFFIX}`) + } catch { + // Stays claimed, and kept while a process on its record runs. + } } } + // Each path once, and as a mount point where a manifest names it as one. const candidates = new Map() - for (const manifest of finished) { + for (const manifest of unrecordedToo ? [...finished, unrecorded] : finished) { for (const mountPoint of manifest.paths) { - if (!claimed.has(mountPoint)) { - candidates.set(mountPoint, false) - } + candidates.set(mountPoint, false) } for (const source of manifest.sources) { - if (!claimed.has(source) && !candidates.has(source)) { + if (!candidates.has(source)) { candidates.set(source, true) } } } - - // `revived`: manifests that read as finished at first and as locked on a - // later look. Only the lock can change; a gone writer does not come back. - const revived = new Set() - let removalsOnThisLook = 0 - let lookedAt: number | undefined - let listedAt: number | undefined - const locksAreFresh = (): boolean => - lookedAt !== undefined && - removalsOnThisLook < REMOVALS_PER_LOOK && - performance.now() - lookedAt < LOCKS_GOOD_FOR_MS - const listingIsFresh = (): boolean => - listedAt !== undefined && performance.now() - listedAt < LISTING_GOOD_FOR_MS - const readTheLocksAgain = (): boolean => { - const locksNow = readLocks(dir) - if (locksNow === undefined) { - return false - } - for (const manifest of finished) { - if (!revived.has(manifest) && holdsLock(locksNow, manifest)) { - revived.add(manifest) - claim(manifest) - } - } - removalsOnThisLook = 0 - lookedAt = performance.now() - return true - } - const listTheDirectoryAgain = (): boolean => { - let namesNow: string[] + const known = new Set(reading.names) + let listedAt = -Infinity + const newcomersKeepWhatTheyName = (): boolean => { try { - namesNow = fs.readdirSync(dir) - } catch { - return false - } - for (const name of namesNow) { - if (!name.endsWith(MANIFEST_SUFFIX) || known.has(name)) continue - known.add(name) - const file = path.join(dir, name) - const arrived = readManifest(file) - if (arrived !== undefined) { - // Published since the pass began: a sandbox is about to start under - // it, or has. Whatever it names stays, with no further question. - claim(arrived) - } else if (fs.existsSync(file)) { - // There, and not to be read: what it names cannot be kept path by - // path, so everything is. - logForDebugging( - `[Sandbox Linux] A mount point manifest that appeared during the pass could not be read - leaving the rest where it is: ${file}`, - { level: 'warn' }, - ) - return false + for (const name of fs.readdirSync(dir)) { + if (known.has(name) || !name.endsWith(MANIFEST_SUFFIX)) continue + // Published, or given back, since the reading. One that is gone again + // is looked for the next time: it may be given back once more. + const arrived = readManifest(path.join(dir, name)) + if (arrived === undefined) continue + keep(arrived) + known.add(name) } + } catch (e) { + logForDebugging( + `[Sandbox Linux] The mount point manifests could not be read again (${String(e)}) - leaving the rest where it is`, + { level: 'warn' }, + ) + return false } listedAt = performance.now() return true } - // Locks first, listing last: reading /proc/locks can take milliseconds, and a - // listing made before it would be out of date. - const mayStillRemove = (): boolean => - (locksAreFresh() || readTheLocksAgain()) && - (listingIsFresh() || listTheDirectoryAgain()) const removed: string[] = [] const notRemoved = new Set() for (const [candidate, isSource] of candidates) { - if (!mayStillRemove()) { + if ( + performance.now() - listedAt >= LISTING_GOOD_FOR_MS && + !newcomersKeepWhatTheyName() + ) { return removed } - if (claimed.has(candidate)) { + if (kept.has(candidate)) { continue } const outcome = isSource ? removeMountSource(candidate) : removeMountPoint(candidate) - removalsOnThisLook++ if (outcome === 'removed') { removed.push(candidate) } else if (outcome === 'failed') { notRemoved.add(candidate) } } - // The manifests themselves, last. Only a lock taken since can speak for one - // of these; a manifest somebody else has published in the meantime cannot. - for (const manifest of finished) { - if (!locksAreFresh() && !readTheLocksAgain()) { - return removed - } - if (revived.has(manifest)) { - continue + if (unrecordedToo) { + for (const named of [unrecorded.paths, unrecorded.sources]) { + for (const one of named) if (!notRemoved.has(one)) named.delete(one) } + } + for (const manifest of finished) { // A mount point that could not be removed from here is still somebody's to - // remove, and the manifest is all that says so. + // remove, and the manifest is all that says so. The record goes only with + // the claim: a manifest given back since may have a sandbox on its record. if ( - [...manifest.paths, ...manifest.sources].some(named => + ![...manifest.paths, ...manifest.sources].some(named => notRemoved.has(named), - ) + ) && + drop(manifest.file) ) { - continue - } - try { - fs.unlinkSync(manifest.file) - } catch { - // Collected by another process already. + drop(manifest.record) + ownManifests.delete(`${idOf(manifest.file)}${MANIFEST_SUFFIX}`) } - ownManifests.delete(manifest.file) - removalsOnThisLook++ } return removed } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 45e74897e..28ca5f932 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -737,11 +737,14 @@ export type LinuxSandboxProfileErrorCode = * configuration expands to is past a limit, or, for `command_too_long` and * `nul_in_path`, what the caller passed in is. The command was not run and no * profile file stays open; the wrap has already let go of the mount points it - * had named, so the per-command cleanup (`cleanupAfterCommand()`, - * `cleanupBwrapMountPoints()`) is neither needed nor harmful after one. - * Branch on `.code`, never on `.message`, which carries the sizes of the - * moment. Other wrap-time failures (a shell that is not on PATH, a bridge - * socket that is gone) are plain Errors. + * had named and is not counted among the commands to clean up after. So do not + * make the per-command cleanup call (`cleanupAfterCommand()`, + * `cleanupBwrapMountPoints()`) for a wrap that threw: it counts for another + * wrap of this process, whose command is then refused its start if it has not + * started yet. It takes nothing from a running sandbox. Branch on `.code`, + * never on `.message`, which carries the sizes of the moment. Other wrap-time + * failures (a shell that is not on PATH, a bridge socket that is gone) are + * plain Errors. */ export class LinuxSandboxProfileError extends Error { readonly code: LinuxSandboxProfileErrorCode @@ -776,13 +779,45 @@ export class LinuxSandboxProfileError extends Error { * again through /proc. Every other word, the per-command environment and the * command among them, stays on the line. The result stays a simple command, * so a prefix (`exec`, `timeout 30`) or a suffix (`&& next`) still composes. + * + * With `started`, the record of a wrap that named mount points, the string is + * a shell that appends its own /proc/$$/stat line to that record and then execs + * bwrap in its place: the pid on record is bwrap's, written before bwrap has + * made a mount. Appended, not written over, so that every run of the string is + * on record while it lasts. One shell does both where both are needed. * Throws {@link LinuxSandboxProfileError} when the profile cannot run. */ function renderBwrapInvocation( bwrapBinary: string, bwrapArgs: string[], mounts: { start: number; end: number }, + started?: string, ): string { + /** What the string's own shell does, to the file it is given as "$1". */ + type Step = { run: string; file: string } + const record: Step[] = + started === undefined + ? [] + : [ + { + run: 'read -r s >"$1"', + file: started, + }, + ] + /** `words`, run by a shell named `name` once it has taken `steps`. */ + const afterSteps = (steps: Step[], name: string, words: string[]): string => + steps.length === 0 + ? quote(words) + : quote([ + '/bin/sh', + '-c', + [...steps.map(step => `${step.run} && shift`), 'exec "$@"'].join( + ' && ', + ), + name, + ...steps.map(step => step.file), + ...words, + ]) if (bwrapArgs.length > BWRAP_MAX_ARGS) { throw new LinuxSandboxProfileError( 'too_many_arguments', @@ -798,7 +833,7 @@ function renderBwrapInvocation( 'Sandbox profile contains a path with a NUL byte, which neither a command line nor a file of bwrap arguments can carry', ) } - const inline = quote([bwrapBinary, ...bwrapArgs]) + const inline = afterSteps(record, 'srt', [bwrapBinary, ...bwrapArgs]) const inlineBytes = Buffer.byteLength(inline, 'utf8') const limit = maxArgStrlen() - 1 if (inlineBytes <= limit - ARG_HEADROOM_BYTES) { @@ -825,18 +860,23 @@ function renderBwrapInvocation( } // /bin/sh opens the profile on the fd and execs bwrap, which reads it to // EOF and closes it before running the command. - const viaArgsFile = quote([ - '/bin/sh', - '-c', - `exec ${BWRAP_ARGS_FD}<"$1" && shift && exec "$@"`, + const viaArgsFile = afterSteps( + [ + ...record, + { + run: `exec ${BWRAP_ARGS_FD}<"$1"`, + file: bwrapArgsProfilePath(argsFd), + }, + ], 'srt-args', - bwrapArgsProfilePath(argsFd), - bwrapBinary, - ...bwrapArgs.slice(0, mounts.start), - '--args', - String(BWRAP_ARGS_FD), - ...bwrapArgs.slice(mounts.end), - ]) + [ + bwrapBinary, + ...bwrapArgs.slice(0, mounts.start), + '--args', + String(BWRAP_ARGS_FD), + ...bwrapArgs.slice(mounts.end), + ], + ) const viaArgsFileBytes = Buffer.byteLength(viaArgsFile, 'utf8') if (viaArgsFileBytes > limit) { closeBwrapArgsProfile(argsFd) @@ -897,11 +937,11 @@ function registerExitCleanupHandler(): void { * once; * - with `force`: everything, because the process or session is ending. * - * A mount point goes only when no running sandbox relies on it, which the - * kernel answers (see bwrap-mount-manifests.ts). A call too many takes nothing - * from a running sandbox, but counts for another wrap of this process, whose - * command then refuses to start if it has not started by the time the count - * reaches zero. Does nothing except on Linux. + * A mount point goes only when no running sandbox relies on it, which each + * sandbox's own process answers (see bwrap-mount-manifests.ts). A call too many + * takes nothing from a running sandbox, but counts for another wrap of this + * process, whose command is then refused its start if it has not started by the + * time the count reaches zero. Does nothing except on Linux. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean @@ -1618,7 +1658,12 @@ async function generateFilesystemArgs( allowGitConfig = false, abortSignal?: AbortSignal, commandKey?: string, -): Promise<{ args: string[]; manifest: MountPointManifest | undefined }> { +): Promise<{ + args: string[] + manifest: MountPointManifest | undefined + /** Where the manifest's started record is, as the binds spell its directory. */ + started: string | undefined +}> { const args: string[] = [] // fs already imported @@ -1912,12 +1957,15 @@ async function generateFilesystemArgs( // beneath the allow binds, or after them under a '/' write root that would // otherwise bury them (see ancestorPinArgs). let ancestorPinInsertAt: number + // Just past the bind of the root. + let afterRootBind: number // Determine initial root mount based on write restrictions if (writeConfig) { // Write restrictions: Start with read-only root, then allow writes to specific paths args.push('--ro-bind', '/', '/') const beneathAllowBinds = args.length + afterRootBind = beneathAllowBinds // Allow writes to specific paths for (const pathPattern of writeConfig.allowOnly || []) { @@ -2574,7 +2622,7 @@ async function generateFilesystemArgs( // specially. Nothing is ever restored for it: no read-deny tmpfs lands // at '/'. allowedWritePaths.push('/') - ancestorPinInsertAt = args.length + ancestorPinInsertAt = afterRootBind = args.length } // denyWriteArgs is emitted after the denyRead loop below. @@ -2911,9 +2959,8 @@ async function generateFilesystemArgs( } } - // Name the mount points this wrap relies on before the sandbox can start, and - // have bwrap hold a lock on the manifest for as long as it runs. Mount points - // that could not be recorded are left on the host. + // Name the mount points this wrap relies on before the sandbox can start. + // Those that could be recorded nowhere are this process's to remove. // // INVARIANT: a manifest directory must never be writable from inside a // sandbox. A command that could delete a manifest could make a running @@ -2933,17 +2980,20 @@ async function generateFilesystemArgs( emptySource === undefined ? [] : [emptySource], commandKey, ) + let started: string | undefined if (manifest !== undefined) { - // This wrap needs the directory there: bubblewrap opens the manifest in - // it, after the mounts. const dir = canonicalForm(manifest.dir) - args.push( - '--ro-bind', - dir, - dir, - '--lock-file', - path.join(dir, path.basename(manifest.file)), - ) + const file = path.join(dir, path.basename(manifest.file)) + started = path.join(dir, path.basename(manifest.started)) + // INVARIANT: the manifest is the first thing bound after the root. A + // command started once a clean-up has claimed its manifest then fails + // ("Can't find source path") before bubblewrap has made a mount point. + // Bound any later, a claim that lands while bubblewrap is at its mounts + // would leave on the host mount points that nothing names or removes. + args.splice(afterRootBind, 0, '--ro-bind', file, file) + args.push('--ro-bind', dir, dir) + } + if (mountPoints.length > 0 || emptySource !== undefined) { registerExitCleanupHandler() } if (writeConfig !== undefined) { @@ -2985,7 +3035,7 @@ async function generateFilesystemArgs( args.push('--ro-bind', dir, dir) } - return { args, manifest } + return { args, manifest, started } } /** @@ -3029,7 +3079,8 @@ async function generateFilesystemArgs( * CALLER OBLIGATION: the euid and capability decisions behind the returned * string are made here, in the process that builds it, so the string must be * run by a process with the same euid and the same capability bounding and - * inheritable sets. + * inheritable sets, and in the same PID namespace: a command that names mount + * points records its own pid, which a clean-up asks after from here. */ export async function wrapCommandWithSandboxLinux( params: LinuxSandboxParams, @@ -3373,6 +3424,7 @@ export async function wrapCommandWithSandboxLinux( bwrapPath ?? 'bwrap', bwrapArgs, mounts, + filesystem.started, ) const restrictions = [] @@ -3388,8 +3440,8 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // No command came of this wrap, so no sandbox runs under its manifest and - // the mount points it named are no one's. + // No command came of this wrap, so no sandbox starts under its manifest + // and the mount points it named are no one's. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } From efc401cba6bdda57ff1a6772c3bbc224a8ce0e51 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:02:11 +0000 Subject: [PATCH 082/141] test: liveness by the sandbox's own record, claims, and refused starts --- test/helpers/bwrap-argv.ts | 10 + test/helpers/private-manifest-directory.ts | 42 +- test/sandbox/linux-bwrap-args-file.test.ts | 11 +- test/sandbox/mount-point-liveness.test.ts | 331 +++-- test/sandbox/mount-point-manifests.test.ts | 1508 ++++++++++++-------- test/sandbox/stale-mount-point.test.ts | 4 +- 6 files changed, 1205 insertions(+), 701 deletions(-) diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index e72246c60..4a1d1a9f1 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -63,6 +63,16 @@ function runIndices(command: string, words: readonly string[]): number[] { return found } +/** + * The manifest the wrap recorded its mount points in, by the started record + * the command's own shell is given, or `undefined` for a wrap that recorded + * none. + */ +export function manifestOf(command: string): string | undefined { + const id = / srt(?:-args)? (\S+)\.started /.exec(command)?.[1] + return id === undefined ? undefined : `${id}.json` +} + /** How many times that whole mount appears. */ export function countMounts(command: string, ...words: MountWords): number { return runIndices(command, words).length diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index cb555144a..10a57b67e 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,19 +1,21 @@ import { afterAll, beforeAll } from 'bun:test' -import { chmodSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' /** - * Gives the enclosing `describe` a runtime directory of its own for the mount - * point manifests, for as long as its tests run. + * Gives the enclosing `describe` a runtime directory and a temp dir of its own, + * for as long as its tests run. * * The manifests of every srt process of a user live in one directory, and a * collect reads, judges and removes all of them, so a test that lists or * attacks that directory would otherwise act on whatever else the user is - * running. `$XDG_RUNTIME_DIR` is where the library looks first and child - * processes inherit it. + * running. The library looks under `$XDG_RUNTIME_DIR` first, and every wrap + * that restricts writes makes the directory under the temp dir as well, so both + * are replaced, before anything is cleaned up: nothing is made or removed in + * the directories the user's own processes keep. * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so: the library's count of wraps is shared by every test file of a @@ -22,34 +24,38 @@ import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.j * Call it inside the `describe` callback. Returns where the manifests go. */ export function usePrivateManifestDirectory(): { manifestDir(): string } { - const saved = process.env.XDG_RUNTIME_DIR - let runtimeDir: string | undefined + const replaced = ['XDG_RUNTIME_DIR', 'TMPDIR'] as const + const saved = replaced.map(name => process.env[name]) + let base: string | undefined beforeAll(() => { - // In the directory the earlier suites used, before it is left behind. - cleanupBwrapMountPoints({ force: true }) - runtimeDir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-runtime-'))) - chmodSync(runtimeDir, 0o700) - process.env.XDG_RUNTIME_DIR = runtimeDir + base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-'))) + for (const name of replaced) { + process.env[name] = join(base, name) + mkdirSync(process.env[name], { mode: 0o700 }) + } forgetMountPointManifestDirectory() + cleanupBwrapMountPoints({ force: true }) }) afterAll(() => { cleanupBwrapMountPoints({ force: true }) - if (saved === undefined) delete process.env.XDG_RUNTIME_DIR - else process.env.XDG_RUNTIME_DIR = saved + replaced.forEach((name, i) => { + if (saved[i] === undefined) delete process.env[name] + else process.env[name] = saved[i] + }) forgetMountPointManifestDirectory() - if (runtimeDir !== undefined) { - rmSync(runtimeDir, { recursive: true, force: true }) + if (base !== undefined) { + rmSync(base, { recursive: true, force: true }) } }) return { manifestDir: () => { - if (runtimeDir === undefined) { + if (base === undefined) { throw new Error('asked for before the tests began') } - return join(runtimeDir, 'srt-mount-points') + return join(base, 'XDG_RUNTIME_DIR', 'srt-mount-points') }, } } diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 14e8e414d..cc57c0150 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -59,9 +59,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // the NUL, less the 4 KiB left for a prefix of the caller's own. const INLINE_MAX = MAX_ARG_STRLEN - 1 - 4096 // The one rendered shape: the profile's path, then the options left before - // and the words left after `--args 9`. + // and the words left after `--args 9`. A wrap that named mount points has + // the same shell put itself on the manifest's record first. const VIA_ARGS_FILE = - /^\/bin\/sh -c 'exec 9<"\$1" && shift && exec "\$@"' srt-args (\S+) bwrap (.*?) ?--args 9 (.*)$/s + /^\/bin\/sh -c '(?:read -r s <\/proc\/\$\$\/stat && printf "%s\\n" "\$s" >>"\$1" && shift && )?exec 9<"\$1" && shift && exec "\$@"' srt-args (?:\S+\.started )?(\S+) bwrap (.*?) ?--args 9 (.*)$/s const MODULE = join( import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts', @@ -549,6 +550,12 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { expect(argsPathOf(wrapped)).toMatch( new RegExp(`^/proc/${process.pid}/fd/\\d+$`), ) + // This wrap names mount points, under the temp dir: one shell puts the + // command on record and opens the profile. + expect(wrapped).toMatch( + / srt-args \S+\/srt-mount-points\/\d+-[0-9a-f]{16}\.started \/proc\/\d+\/fd\/\d+ bwrap /, + ) + expect(wrapped.match(/\/bin\/sh -c /g)).toHaveLength(1) const run = spawnSync(`timeout 60 ${wrapped} && echo AFTER`, { shell: true, encoding: 'utf8', diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index a54a9b63d..2bb0fd4d6 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -16,14 +16,18 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { liveMountPoints } from '../../src/index.js' import { cleanupBwrapMountPoints, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' -import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' +import { + countMounts, + indexOfMount, + manifestOf as recordedBy, +} from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -32,8 +36,10 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo * A denyWrite path that does not exist is blocked by binding over a mount point * bwrap makes on the host. Unlinking one while a sandbox is bound over it * detaches the mount inside that sandbox, and the denied path can then be - * created on the host. So whether a sandbox still runs is asked of the kernel: - * every wrap names its mount points in a manifest that bwrap holds a lock on. + * created on the host. So every wrap names its mount points in a manifest, and + * the command it hands out puts the process it starts bubblewrap in on that + * manifest's record: a mount point goes only when no process on the record of + * a manifest that names it is running. */ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // These tests list the manifests, attack them from inside a sandbox and @@ -127,9 +133,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return existsSync(file) ? readFileSync(file, 'utf8') : '' } - /** Where the wrap pointed bubblewrap's --lock-file. */ + /** The manifest the wrap recorded its mount points in. */ function manifestOf(command: string): string { - const file = /--lock-file (\S+)/.exec(command)?.[1] + const file = recordedBy(command) expect(file).toBeDefined() return file! } @@ -268,8 +274,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const holderExited = new Promise(resolve => holder.on('exit', resolve)) try { await waitFor(SIGNAL) - // Past the grace, so that only the sandbox's lock and its writer - // speak for the manifest. + // Past the grace, so that only the process on its record and its + // writer speak for the manifest. await sleep(600) expect([...liveMountPoints()]).toEqual([LOCK]) } finally { @@ -449,7 +455,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { writeConfig: { allowOnly: [AREA, directory], denyWithinAllow: [] }, }) // The point of the case: this wrap relies on no mount point. - expect(command).not.toContain('--lock-file') + expect(recordedBy(command)).toBeUndefined() const result = spawnSync(command, { shell: true, @@ -533,7 +539,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it.if(BWRAP_CAN_NAMESPACE)( - 'refuses to start a command wrapped before a cleanup that has since let go of its manifest', + 'refuses to start a command wrapped before a cleanup that has since let go of its manifest, and makes nothing for it', async () => { const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) const manifest = manifestOf(command) @@ -548,17 +554,41 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { encoding: 'utf8', timeout: 60000, }) - // bubblewrap sets the mounts up before it opens the lock file, so the - // mount point is back on the host; the command never runs. + // The manifest is the first thing bubblewrap binds, so it has made no + // mount point by then: nothing is on the host that no manifest names. expect(result.status).not.toBe(0) expect(`${result.stdout}${result.stderr}`).toContain( - 'Unable to open lock file', + `Can't find source path ${manifest}`, ) - expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(LOCK)).toBe(false) + // What the command line recorded of itself goes at the next clean-up. + expect(readdirSync(dirname(manifest))).toEqual([ + basename(manifest).replace(/json$/, 'started'), + ]) + cleanupBwrapMountPoints() + expect(readdirSync(dirname(manifest))).toEqual([]) }, 90000, ) + it('hands out a command that puts itself on record and then binds its manifest, before any other mount', async () => { + const command = await wrap('true') + const manifest = manifestOf(command) + const record = manifest.replace(/json$/, 'started') + // One shell, which bubblewrap takes the place of: the pid on record is + // bubblewrap's own, and the sandbox dies with it. + expect(command).toStartWith( + `/bin/sh -c 'read -r s >"$1" && shift && exec "$@"' srt ${record} bwrap --new-session --die-with-parent `, + ) + expect(command).toContain( + ` --ro-bind / / --ro-bind ${manifest} ${manifest} --`, + ) + // A wrap that names no mount point has nothing to vouch for. + expect(await wrap('true', [])).toStartWith( + 'bwrap --new-session --die-with-parent ', + ) + }) + // ---- two commands of one process in flight ---- // // A call to clean up cannot tell which command it is for, so while a wrap is @@ -583,7 +613,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const result = run(second) const said = `${result.stdout}${result.stderr}` - expect(said).not.toContain('Unable to open lock file') expect(said).not.toContain('bwrap:') expect(result.status).toBe(0) expect(result.stdout).toContain('second-ran') @@ -596,64 +625,124 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + /** + * Runs `during` once in the next clean-up, when it has claimed the first + * manifest it takes for finished and before it goes on. + */ + function duringTheNextCleanUp(during: () => void): { restore(): void } { + const rename = fs.renameSync + let done = false + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + if (to.endsWith('.claimed') && !done) { + done = true + during() + } + rename(from, to) + }) as never) + return { restore: () => spy.mockRestore() } + } + + /** + * Waits to be told, then tries the denied write the hard way: a mount point + * that was removed and made again is an empty file without a write bit, which + * refuses a plain write by its mode alone, so the file is removed first. Only + * a bind still in place refuses that. + */ + function heldCommandThatRemovesFirst(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?` + } + it.if(BWRAP_CAN_NAMESPACE)( - 'a sandbox that takes its lock while another command is being cleaned up after keeps its manifest and its deny', + 'a sandbox that starts while a clean-up is about to claim its manifest keeps its manifest and its deny', async () => { - // The interleaving made certain: the collect reads /proc/locks while the - // second command's manifest is not yet locked, and that command's sandbox - // takes its lock before the collect goes on. - const first = await wrap('true') - const second = await wrap(heldCommand()) - const manifest = manifestOf(second) - expect(spawnSync(first, { shell: true, timeout: 60000 }).status).toBe(0) - - const readFile = fs.readFileSync - let started = false - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, - options: unknown, - ) => { - const value = (readFile as (f: unknown, o: unknown) => unknown)( - file, - options, - ) - if (file === '/proc/locks' && !started) { - started = true - // `{ ...; true; }` keeps a shell as bubblewrap's parent. - spawnSync('sh', ['-c', `{ ${second}; true; } > ${OUT} 2>&1 &`], { - stdio: 'ignore', - }) - const deadline = Date.now() + 20000 - while (!existsSync(SIGNAL)) { - if (Date.now() > deadline) throw new Error('sandbox never came up') - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) - } + // The interleaving made certain: the clean-up has read the manifest as + // finished, the command's sandbox then starts and binds over its mount + // point, and only then does the clean-up claim the manifest. + const command = await wrap(heldCommandThatRemovesFirst()) + const manifest = manifestOf(command) + let inode: number | undefined + const pass = duringTheNextCleanUp(() => { + // `{ ...; true; }` keeps a shell as bubblewrap's parent. + spawnSync('sh', ['-c', `{ ${command}; true; } > ${OUT} 2>&1 &`], { + stdio: 'ignore', + }) + const deadline = Date.now() + 20000 + while (!existsSync(SIGNAL)) { + if (Date.now() > deadline) throw new Error('sandbox never came up') + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) } - return value - }) as never) + inode = lstatSync(LOCK).ino + }) try { - cleanupBwrapMountPoints() // after the first + // The one clean-up this wrap is owed, made too early: the command is + // taken for over, and nothing but a process on its record can speak + // for its manifest. + cleanupBwrapMountPoints() } finally { - spy.mockRestore() + pass.restore() } - expect(started).toBe(true) - // The second is running, and what it relies on is still on record. - expect(existsSync(manifest)).toBe(true) + expect(inode).toBeDefined() + // The claim was given back, and what the sandbox is bound over is what + // it was bound over. + expect(readdirSync(dirname(manifest)).sort()).toEqual([ + basename(manifest), + basename(manifest).replace(/json$/, 'started'), + ]) + expect(lstatSync(LOCK).ino).toBe(inode!) // A later command in the same repository names the same mount point. const third = await wrap('true') expect(spawnSync(third, { shell: true, timeout: 60000 }).status).toBe(0) cleanupBwrapMountPoints() // after the third - expect(existsSync(LOCK)).toBe(true) + expect(lstatSync(LOCK).ino).toBe(inode!) writeFileSync(GO, '') const deadline = Date.now() + 20000 while (!/rc=\d/.test(contentAt(OUT))) { - if (Date.now() > deadline) throw new Error('the second never finished') + if (Date.now() > deadline) throw new Error('the command never finished') await sleep(25) } expect(contentAt(OUT)).toMatch(/rc=[1-9]/) expect(contentAt(LOCK)).toBe('') + expect(lstatSync(LOCK).ino).toBe(inode!) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'a command started once a clean-up has claimed its manifest is refused, and nothing is removed or made under it', + async () => { + const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) + let refused: { status: number | null; said: string } | undefined + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + rename(from, to) + if (to.endsWith('.claimed') && refused === undefined) { + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + refused = { status: run.status, said: `${run.stdout}${run.stderr}` } + } + }) as never) + try { + cleanupBwrapMountPoints() // too early, and the command starts under it + } finally { + spy.mockRestore() + } + expect(refused).toBeDefined() + expect(refused?.status).not.toBe(0) + expect(refused?.said).toContain("Can't find source path") + expect(refused?.said).not.toContain('rc=') + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(dirname(manifestOf(command)))).toEqual([]) }, 90000, ) @@ -732,7 +821,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(second).not.toContain(`--ro-bind /dev/null ${LOCK}`) expect(second).toContain(`--ro-bind ${LOCK} ${LOCK}`) // Nor does the second wrap name it: it is not its to remove. - expect(second).not.toContain('--lock-file') + expect(recordedBy(second)).toBeUndefined() }) it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { @@ -858,7 +947,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { inThatPlace('nothing-to-record.ts', []).stdout, ) as { wrapped: string; status: number; made: string[] } expect(nothingToRecord.status).toBe(0) - expect(nothingToRecord.wrapped).not.toContain('--lock-file') + expect(recordedBy(nothingToRecord.wrapped)).toBeUndefined() expect(manifestDirectories(nothingToRecord.made)).toEqual([]) expect(manifestDirectories(readdirSync(TMP))).toEqual([]) @@ -878,6 +967,69 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + it.if(BWRAP_CAN_NAMESPACE && process.getuid?.() !== 0)( + 'is removed by the process that made it, at its clean-up or as it ends, where it could be recorded nowhere', + async () => { + // No runtime directory, and nothing can be made under the temp dir: no + // other process can know of the mount point. + const TMP = join(BASE, 'read-only-tmp') + mkdirSync(TMP, { mode: 0o500 }) + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR + const inThatPlace = (script: string, after: string) => + spawnSync( + process.execPath, + [ + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { existsSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(`echo pwned > ${LOCK}; echo rc=$?`)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `const made = existsSync(${JSON.stringify(LOCK)})`, + after, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? ''), made, left: existsSync(${JSON.stringify(LOCK)}) }))`, + ].join('\n'), + ), + ], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }, + ) + for (const [script, after, left] of [ + ['cleans-up.ts', 'cleanupBwrapMountPoints()', false], + ['only-ends.ts', '', true], + ] as const) { + const ran = JSON.parse(inThatPlace(script, after).stdout) as { + wrapped: string + said: string + made: boolean + left: boolean + } + expect(recordedBy(ran.wrapped)).toBeUndefined() + expect(ran.wrapped).toStartWith('bwrap ') + expect(ran.said).toMatch(/rc=[1-9]/) + expect(ran).toMatchObject({ made: true, left }) + expect(existsSync(LOCK)).toBe(false) + } + }, + 90000, + ) + it('lets go of the mount points of a wrap that produced no command', async () => { // Left where a process killed before its cleanup would leave it, with the // manifest it would leave, so that both processes below name it. @@ -927,10 +1079,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) /** - * The one case where nothing but the kernel's lock keeps a mount point: the - * process that wrapped the command is gone and the sandbox it started is still - * running. Every test above has the wrapping process alive, which alone keeps - * the manifest live. + * The process that wrapped the command is gone and the sandbox it started is + * still running: nothing but the process on the manifest's record keeps the + * mount point. * * The wrap always asks for --die-with-parent, so a sandbox outlives its wrapper * only when something else is bubblewrap's parent: here a shell in a session of @@ -944,8 +1095,7 @@ describe.if(isLinux)( const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), ) - // Comfortably past the grace a new manifest is given (500 ms): after this - // only the writer being alive, or the lock, can make the manifest live. + // Comfortably past the grace a new manifest is given (500 ms). const PAST_GRACE_MS = 1200 let BASE: string @@ -1058,7 +1208,7 @@ describe.if(isLinux)( * result, and then stays: the sandbox is still running when it is killed. */ function heldCommand(): string { - return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` } /** @@ -1199,7 +1349,7 @@ describe.if(isLinux)( `stays, and the deny holds, while other processes collect after its writer ${how}; goes once the sandbox is gone`, async () => { const info = await sandboxWhoseWriterHasGone(leave, signal) - const manifest = /--lock-file (\S+)/.exec(info.wrapped)?.[1] + const manifest = recordedBy(info.wrapped) expect(manifest).toBeDefined() // Still naming the deny path, and the process that is gone. const written = JSON.parse(readFileSync(manifest!, 'utf8')) as { @@ -1243,7 +1393,8 @@ describe.if(isLinux)( // The sandbox is still there. Its denied write must fail inside, and // nothing may land on the host. (A mount point removed and made again // by the second wrap is on the host but is no longer what this - // sandbox is bound over, so only the write tells.) + // sandbox is bound over: the command removes the file before it + // writes, which only a bind still in place refuses.) expect(runningTree(info.launcher).length).toBeGreaterThan(1) expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ stayed: { @@ -1262,8 +1413,8 @@ describe.if(isLinux)( expect(runScript('collector.ts', collectorScript())).toBe(0) expect(existsSync(LOCK)).toBe(true) - // Kill the sandbox. The kernel drops the lock, and the next collect - // by anyone removes the mount point and the manifest. + // Kill the sandbox. The next collect by anyone removes the mount + // point and the manifest. await killTree(info.launcher) launcher = undefined expect(runScript('collector.ts', collectorScript())).toBe(0) @@ -1279,8 +1430,8 @@ describe.if(isLinux)( async () => { // A collector that never has a candidate proves nothing. Here the // writer is gone and the grace is over before the write is tried, so - // the lock is all that stands between the collector and the mount - // point. + // the process on record is all that stands between the collector and + // the mount point. const loopFile = join(BASE, 'collect-loop.ts') writeFileSync( loopFile, @@ -1553,7 +1704,7 @@ describe.if(isLinux)( /** The mount points the wrap named in its manifest, none without one. */ function namedBy(command: string): string[] { - const file = /--lock-file (\S+)/.exec(command)?.[1] + const file = recordedBy(command) return file === undefined ? [] : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths @@ -1596,7 +1747,7 @@ describe.if(isLinux)( expect(readFileSync(join(DIRECTORY, 'settings.json'), 'utf8')).toBe('{}') // Nothing names it any more: it is the user's directory from here on. expect(existsSync(left)).toBe(false) - expect(existsSync(/--lock-file (\S+)/.exec(command)![1]!)).toBe(false) + expect(existsSync(recordedBy(command)!)).toBe(false) expect(namedBy(await wrap('true', [DIRECTORY]))).toEqual([]) }) @@ -1818,7 +1969,7 @@ describe.if(isLinux)( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, { XDG_RUNTIME_DIR: RUN }, ) - expect(command).toContain(`--lock-file ${RUN}/srt-mount-points/`) + expect(dirname(recordedBy(command)!)).toBe(`${RUN}/srt-mount-points`) expectPinned(command, RUN) expectPinned(command, join(HOME, 'run')) }) @@ -1831,8 +1982,8 @@ describe.if(isLinux)( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, { XDG_RUNTIME_DIR: undefined }, ) - expect(command).toMatch( - new RegExp(`--lock-file ${TMP}/srt-mount-points-[A-Za-z0-9]{6}/`), + expect(dirname(recordedBy(command)!)).toMatch( + new RegExp(`^${TMP}/srt-mount-points-[A-Za-z0-9]{6}$`), ) expectPinned(command, TMP) }) @@ -2095,7 +2246,7 @@ describe.if(isLinux)( const recording = wrappedAndRun(through, 'true', [GUARD]).wrapped expect(countMounts(recording, '--ro-bind', kept, kept)).toBe(1) - expect(recording).toContain(`--lock-file ${kept}/`) + expect(dirname(recordedBy(recording)!)).toBe(kept) expect(recording).not.toContain(named) // A wrap with nothing to record keeps the command out of it as well. @@ -2148,8 +2299,8 @@ describe.if(isLinux)( expect(recording.said).not.toContain('bwrap:') expect(recording.status).toBe(0) expect(recording.said).toContain('started') - // Its deny holds, under the lock on its manifest, and the manifest - // directory is out of its reach by either name. + // Its deny holds, and the manifest directory is out of its reach by + // either name. expect(recording.said).toMatch(/rc=[1-9]/) expect(recording.said).toMatch(/real=[1-9]/) expect(recording.said).toMatch(/through=[1-9]/) @@ -2200,10 +2351,10 @@ describe.if(isLinux)( ) /** - * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID, - * and /proc/locks, which leaves out every lock whose holder has no pid there. A - * process cleaning up from another PID namespace that shares the manifest - * directory sees a running sandbox's manifest as it would see a dead process's. + * A manifest's witnesses are processes, and /proc/PID is relative to a PID + * namespace. A process cleaning up from another PID namespace that shares the + * manifest directory sees a running sandbox's manifest as it would see a dead + * process's. */ describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', @@ -2268,7 +2419,7 @@ describe.if(isLinux)( } it.if(CAN)( - 'survives a cleanup run where neither its writer nor its lock can be seen', + 'survives a cleanup run where neither its writer nor the process on its record can be seen', async () => { const command = `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` const holder = spawn( @@ -2296,12 +2447,10 @@ describe.if(isLinux)( let manifest: string try { await waitFor(SIGNAL) - manifest = /--lock-file (\S+)/.exec( - readFileSync(WRAPPED, 'utf8'), - )![1]! + manifest = recordedBy(readFileSync(WRAPPED, 'utf8'))! expect(existsSync(manifest)).toBe(true) expect(lstatSync(LOCK).size).toBe(0) - // Past the grace: only the writer's pid and the kernel's lock vouch + // Past the grace: only the writer and the process on record vouch // for the manifest now, and neither can be seen from over there. await new Promise(resolve => setTimeout(resolve, 800)) @@ -2311,12 +2460,12 @@ describe.if(isLinux)( ...IN_NEW_PID_NAMESPACE.slice(1), process.execPath, writeScript('collector.ts', [ - `import { existsSync, readFileSync, statSync } from 'node:fs'`, + `import { existsSync, readFileSync } from 'node:fs'`, `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - // The premise: from here the kernel's list really does leave - // the sandbox's lock out, and the writer's pid is nobody's. - `const inode = statSync(${JSON.stringify(manifest)}).ino`, - `if (readFileSync('/proc/locks', 'utf8').includes(':' + inode + ' ')) process.exit(7)`, + // The premise: from here the pid on record and the writer's + // are nobody's. + `const record = readFileSync(${JSON.stringify(manifest.replace(/json$/, 'started'))}, 'utf8')`, + `if (existsSync('/proc/' + record.split(' ')[0])) process.exit(7)`, `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, `cleanupBwrapMountPoints()`, ]), diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 61ba353c0..64a3fb249 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import { spawnSync } from 'node:child_process' import * as fs from 'node:fs' import { - accessSync, chmodSync, - constants, existsSync, linkSync, + lstatSync, + lutimesSync, mkdirSync, mkdtempSync, readFileSync, @@ -15,15 +15,15 @@ import { realpathSync, rmSync, statSync, - statfsSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { collectMountPoints, + forgetMountPointManifestDirectory, liveMountPoints, namedMountPoints, publishMountPointManifest, @@ -34,9 +34,8 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo /** * The manifests and the pass that collects on their word, below the level of a - * sandbox: what a pass believes, what it looks at again before it removes - * anything, and that the directory's lock neither holds a process up nor is - * relied on. + * sandbox: which process a pass asks after, what it claims and looks at again + * before it removes anything, and that what it cannot tell it leaves. */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() @@ -52,7 +51,6 @@ describe.if(isLinux)('The mount point manifests', () => { let BASE: string let DIR: string // where the manifests go - let LOCK: string // the directory's lock let X: string // a mount point an earlier sandbox left let TMP: string // the temp dir of every child process @@ -63,7 +61,6 @@ describe.if(isLinux)('The mount point manifests', () => { DIR = runtime.manifestDir() mkdirSync(DIR, { recursive: true, mode: 0o700 }) chmodSync(DIR, 0o700) - LOCK = join(DIR, 'directory.lock') X = join(BASE, 'config.lock') }) @@ -122,6 +119,57 @@ describe.if(isLinux)('The mount point manifests', () => { return file } + /** Field 22 of a /proc/PID/stat line: when that process started. */ + function startOf(stat: string): string { + return stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19]! + } + + /** This process, which is running, as a manifest or a record names one. */ + function thisProcess(): { pid: number; start: string } { + return { + pid: process.pid, + start: startOf(readFileSync('/proc/self/stat', 'utf8')), + } + } + + /** + * The line the wrapper shell records for a process, with a name that holds + * what a careless reading would trip over. + */ + function statLine(who: { pid: number; start: string }): string { + const after = ['S', ...Array(18).fill('0'), who.start, '0', '0'] + return `${who.pid} (a) b (c) ${after.join(' ')}\n` + } + + /** Where the started record of `manifest` is. */ + function recordOf(manifest: string): string { + return manifest.replace(/\.json$/, '.started') + } + + /** Puts `manifest` on record as started by those processes, in that order. */ + function started( + manifest: string, + ...by: { pid: number; start: string }[] + ): string { + writeFileSync(recordOf(manifest), by.map(statLine).join(''), { + mode: 0o600, + }) + return manifest + } + + /** The claims in the manifest directory, by name. */ + function claims(): string[] { + return readdirSync(DIR).filter(name => name.endsWith('.claimed')) + } + + /** The name a pass gives `manifest` when it claims it. */ + function claimOf(manifest: string): string { + return basename(manifest).replace(/\.json$/, '.claimed') + } + /** * Runs `source` with the module as `m` in a process of its own, which is * killed if it does not end by itself: what these cases guard against blocks @@ -163,72 +211,15 @@ describe.if(isLinux)('The mount point manifests', () => { } const COLLECT = `m.collectMountPoints()` - const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` - - // ---- the directory's lock never holds a process up ---- - // - // Whatever is at the lock's name, a collect and a publish both come back: by - // the deadline when it is somebody's lock, and at once when it is nothing - // this process could ever take. "At once" is held to well under the - // two-second wait, which one command makes four times over. + // Well under the time a read that blocks would hold a child up for. const AT_ONCE_MS = 1500 - - const notALock: [string, () => void, boolean][] = [ - ['a directory', () => mkdirSync(LOCK), true], - [ - 'a FIFO nobody writes to', - () => expect(spawnSync('mkfifo', [LOCK]).status).toBe(0), - true, - ], - [ - 'a link to nothing', - () => symlinkSync(join(BASE, 'nothing-here'), LOCK), - true, - ], - [ - 'a file that cannot be read', - () => { - writeFileSync(LOCK, `${deadPid()} 1 ${OWN_NAMESPACE}\n`) - chmodSync(LOCK, 0o000) - }, - NOT_ROOT, - ], - ] - for (const [what, plant, applies] of notALock) { - it.if(applies)( - `comes back from a collect when its lock is ${what}`, - () => { - plant() - const collected = inAChild(COLLECT) - expect(collected.status).toBe(0) - expect(collected.ms).toBeLessThan(AT_ONCE_MS) - }, - 15000, - ) - - it.if(applies)( - `records a wrap's mount points all the same when its lock is ${what}`, - () => { - plant() - const published = inAChild(PUBLISH) - expect(published.status).toBe(0) - expect(published.ms).toBeLessThan(AT_ONCE_MS) - // A wrap is not refused, nor left unrecorded, for want of the lock. - const manifest = JSON.parse(published.stdout) as { file: string } - expect(readFileSync(manifest.file, 'utf8')).toContain('/nonexistent/x') - }, - 15000, - ) - } + const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( - 'comes back from a collect at once where the directory is read-only from here, and collects nothing on its word', + 'collects nothing on the word of a directory that is read-only from here', () => { - // What a process inside a sandbox sees of the directory: bound read-only, - // with a lock in it that it could never remove. It keeps its own + // What a process inside a sandbox sees of the directory. It keeps its own // manifests elsewhere and does not judge what is in this one. - const lock = `${deadPid()} 1 ${OWN_NAMESPACE}\n` - writeFileSync(LOCK, lock) leftover(X) const manifest = manifestOfADeadProcess([X]) const collected = inAChild(COLLECT, { @@ -244,115 +235,51 @@ describe.if(isLinux)('The mount point manifests', () => { ], }) expect(collected.status).toBe(0) - expect(collected.ms).toBeLessThan(AT_ONCE_MS) - expect(readFileSync(LOCK, 'utf8')).toBe(lock) expect(existsSync(X)).toBe(true) expect(existsSync(manifest)).toBe(true) }, 15000, ) - it('waits for a lock somebody holds, and no longer than the wait', () => { - // This process, which is running, in this namespace. - const start = readFileSync('/proc/self/stat', 'utf8') - .split(') ') - .pop()! - .split(' ')[19] - writeFileSync(LOCK, `${process.pid} ${start} ${OWN_NAMESPACE}\n`) - leftover(X) - manifestOfADeadProcess([X]) - - const collected = inAChild(COLLECT) - expect(collected.status).toBe(0) - expect(collected.ms).toBeGreaterThan(1500) - expect(collected.ms).toBeLessThan(5500) - // The pass was left to the holder: nothing was removed, its lock stands. - expect(existsSync(X)).toBe(true) - expect(readFileSync(LOCK, 'utf8')).toContain(`${process.pid} `) - }, 15000) - - it('believes a lock that names nobody until it is old, and breaks it then', () => { - // An empty lock file says nothing about its holder. It is believed like any - // other lock whose holder cannot be asked after, not taken for nobody's and - // removed. - writeFileSync(LOCK, '') - leftover(X) - manifestOfADeadProcess([X]) - - const young = inAChild(COLLECT) - expect(young.status).toBe(0) - expect(young.ms).toBeGreaterThan(1500) - expect(existsSync(LOCK)).toBe(true) - expect(existsSync(X)).toBe(true) - - const longAgo = new Date(Date.now() - 61_000) - utimesSync(LOCK, longAgo, longAgo) - const old = inAChild(COLLECT) - expect(old.status).toBe(0) - expect(old.ms).toBeLessThan(4000) - expect(existsSync(LOCK)).toBe(false) - expect(existsSync(X)).toBe(false) - }, 20000) - - it('breaks at once the lock of a holder in this namespace that is gone, and not one from another namespace', () => { - leftover(X) - manifestOfADeadProcess([X]) - - // Its pid is no process here, which says nothing about a holder whose pid - // was given in another namespace. - writeFileSync(LOCK, `${deadPid()} 1 pid:[1]\n`) - const elsewhere = inAChild(COLLECT) - expect(elsewhere.status).toBe(0) - expect(elsewhere.ms).toBeGreaterThan(1500) - expect(existsSync(X)).toBe(true) - - writeFileSync(LOCK, `${deadPid()} 1 ${OWN_NAMESPACE}\n`) - const here = inAChild(COLLECT) - expect(here.status).toBe(0) - // The pass ran, which it does not after waiting in vain. - expect(existsSync(X)).toBe(false) - expect(existsSync(LOCK)).toBe(false) - }, 20000) - - // ---- nothing rests on the lock ----------------------------------------- + // ---- what a pass removes ----------------------------------------------- /** - * Runs `during` once, in the middle of the next pass: after it has listed - * the manifests and decided what is finished, before it removes anything. + * Runs `during` once, in the middle of the next pass: when it has claimed + * the first finished manifest, before it reads that manifest's record again + * and before it removes anything. */ function duringTheNextPass(during: () => void): { restore(): void } { - const readFile = fs.readFileSync + const rename = fs.renameSync let done = false - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, - options: unknown, + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, ) => { - if (file === '/proc/locks' && !done) { + rename(from, to) + if (to.endsWith('.claimed') && !done) { done = true during() } - return (readFile as (f: unknown, o: unknown) => unknown)(file, options) }) as never) return { restore: () => spy.mockRestore() } } it('removes what a finished manifest names once nothing else names it', () => { leftover(X) - const manifest = manifestOfADeadProcess([X]) + started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) expect(collectMountPoints()).toEqual([X]) expect(existsSync(X)).toBe(false) - expect(existsSync(manifest)).toBe(false) + // The manifest, its record and the claim on it went with it. + expect(readdirSync(DIR)).toEqual([]) }) it('keeps a mount point named by a manifest that is published while the pass is under way', () => { - // The lock does not exclude, so a manifest can appear between a pass - // listing the directory and its removals, naming a path the pass is about - // to remove, with a sandbox starting under it. + // Nothing keeps a wrap from publishing while a pass is under way, naming a + // path the pass is about to remove, with a sandbox starting under it. leftover(X) manifestOfADeadProcess([X]) let published: { status: number | null; stdout: string } | undefined const pass = duringTheNextPass(() => { - rmSync(LOCK, { force: true }) // what a waiter breaking the lock does published = inAChild( `console.log(JSON.stringify(m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null))`, ) @@ -370,87 +297,6 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(X)).toBe(true) }, 15000) - it('gives up only a lock that is still its own', () => { - // Held up for longer than a lock is believed, a pass finds on its way out - // that the lock at that name is somebody else's by now, and must leave it. - const theirs = `${process.ppid} 1 pid:[1]\n` - let mine = '' - const pass = duringTheNextPass(() => { - mine = readFileSync(LOCK, 'utf8') - rmSync(LOCK) - writeFileSync(LOCK, theirs) - }) - try { - collectMountPoints() - } finally { - pass.restore() - } - // Whole while it was held: who, since when, and where that can be asked. - expect(mine).toMatch( - new RegExp( - `^${process.pid} \\d+ ${OWN_NAMESPACE.replace(/[[\]]/g, '\\$&')}\n$`, - ), - ) - expect(readFileSync(LOCK, 'utf8')).toBe(theirs) - }) - - it('makes its lock whole: it is never there with nothing in it', () => { - // Linked into place from a file already written, so that nobody who finds - // it can find it empty. - const linked: string[] = [] - const link = fs.linkSync - const spy = spyOn(fs, 'linkSync').mockImplementation((( - from: string, - to: string, - ) => { - if (to === LOCK) linked.push(readFileSync(from, 'utf8')) - return link(from, to) - }) as never) - try { - collectMountPoints() - } finally { - spy.mockRestore() - } - expect(linked.length).toBe(1) - expect(linked[0]).toMatch(/^\d+ \d+ \S+\n$/) - // And nothing of the making is left behind. - expect(readdirSync(DIR)).toEqual([]) - }) - - it('looks at /proc/locks a bounded number of times, not once for every mount point', () => { - // The list is the host's: reading all of it for each removal would make a - // pass over many mount points outlast the lock's waiters. - const many = 500 - const paths: string[] = [] - for (let i = 0; i < many; i++) { - const p = join(BASE, `.placeholder-${i}`) - leftover(p) - paths.push(p) - } - expect(publishMountPointManifest(paths, [])).toBeDefined() - const spy = spyOn(fs, 'readFileSync') - let removed: string[] - let looks: number - let tookMs: number - try { - const began = Date.now() - removed = collectMountPoints() - tookMs = Date.now() - began - looks = spy.mock.calls.filter(([file]) => file === '/proc/locks').length - } finally { - spy.mockRestore() - } - expect(removed.length).toBe(many) - // One to begin with, one before the first removal, one for every 64 - // removals after that, and one more for every two milliseconds the pass - // took. No fewer either: one look is not good for ever. - expect(looks).toBeGreaterThanOrEqual(1 + Math.ceil(many / 64)) - expect(looks).toBeLessThanOrEqual( - 3 + Math.ceil(many / 64) + Math.ceil(tookMs / 2), - ) - expect(looks).toBeLessThan(many) - }) - it.if(NOT_ROOT)( 'keeps the manifest of a mount point it could not remove, for a pass that can', () => { @@ -466,12 +312,14 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(collectMountPoints()).toEqual([]) expect(existsSync(inside)).toBe(true) - expect(existsSync(manifest)).toBe(true) + // Under its claim, which any later pass takes up. + expect(claims()).toEqual([claimOf(manifest)]) + expect([...namedMountPoints().named]).toEqual([inside]) } finally { chmodSync(readOnly, 0o755) } expect(collectMountPoints()).toEqual([inside]) - expect(existsSync(manifest)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) }, ) @@ -493,14 +341,13 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(collectMountPoints('all')).toEqual([]) expect(existsSync(inside)).toBe(true) - expect(existsSync(manifest)).toBe(true) + expect(claims()).toEqual([claimOf(manifest)]) } finally { chmodSync(readOnly, 0o755) } - // Whatever the next pass is told to release: that this wrap is over has - // been said already. + // Whatever the next pass is told to release. expect(collectMountPoints('none')).toEqual([inside]) - expect(existsSync(manifest)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) }, ) @@ -508,8 +355,8 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = publishMountPointManifest([X], [])!.file // Another version of this library publishes, in a layout this one cannot - // read, after the pass has listed the directory: the pass stops short of - // removing anything. + // read, after the pass has claimed: the pass stops short of removing + // anything. const stranger = join(DIR, '4242-0123456789abcdef.json') const pass = duringTheNextPass(() => writeFileSync(stranger, '{"version":2}'), @@ -520,126 +367,589 @@ describe.if(isLinux)('The mount point manifests', () => { pass.restore() } expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) + expect(claims()).toEqual([claimOf(manifest)]) rmSync(stranger) expect(collectMountPoints('none')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('lists the directory again between removals, not only before the first', () => { + // A sandbox about to start on a path shows as a manifest that was not there + // when the pass began, at any point of a pass over many mount points. One + // listing is good for a quarter of a millisecond. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X, Y]) + const unlink = fs.unlinkSync + let arrived: string | undefined + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + unlink(file) + if (file === X && arrived === undefined) { + // Published the moment the first mount point has gone, with the pass + // held up for a millisecond before the next. + arrived = manifestOfADeadProcess([Y], { + pid: process.pid, + created: Date.now(), + }) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) + } + }) as never) + let removed: string[] + try { + removed = collectMountPoints() + } finally { + spy.mockRestore() + } + expect(arrived).toBeDefined() + expect(removed).toEqual([X]) + expect(existsSync(Y)).toBe(true) + }) + + it('is live for half a second from when it was written, though its writer is gone', () => { + // A command whose wrapping process was killed a moment ago is not refused + // its start for that. + leftover(X) + const manifest = manifestOfADeadProcess([X], { created: Date.now() }) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + // ---- a sandbox vouches for itself ---- + // + // The command line a wrap hands out records the process it starts bubblewrap + // in. With a record, that process alone says whether the manifest is live. + + it('is live while a process on its record runs, whoever wrote it and whatever the caller has said', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const theirs = started(manifestOfADeadProcess([X]), thisProcess()) + const own = started(publishMountPointManifest([Y], [])!.file, thisProcess()) + + expect(collectMountPoints('all')).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) + for (const kept of [X, Y, theirs, own, recordOf(theirs), recordOf(own)]) { + expect(existsSync(kept)).toBe(true) + } + expect([...namedMountPoints().live].sort()).toEqual([X, Y].sort()) + }) + + it('is finished once no process on its record runs, though the process that wrote it does', () => { + leftover(X) + const manifest = started(manifestOfADeadProcess([X], thisProcess()), { + pid: deadPid(), + start: '1', + }) + expect([...namedMountPoints().live]).toEqual([]) + expect(collectMountPoints('none')).toEqual([X]) expect(existsSync(manifest)).toBe(false) + expect(existsSync(recordOf(manifest))).toBe(false) + }) + + it('tells a process from a later one with its pid by when it started', () => { + leftover(X) + const later = String(Number(thisProcess().start) + 1) + started(manifestOfADeadProcess([X]), { pid: process.pid, start: later }) + expect(collectMountPoints()).toEqual([X]) + }) + + it('is live while any of the runs on its record lasts', () => { + // The same command line run again, before the first run is over or after. + leftover(X) + const gone = { pid: deadPid(), start: '1' } + started(manifestOfADeadProcess([X]), gone, thisProcess(), gone) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + }) + + it('removes a record left without its manifest once no process on it runs', () => { + // What a command line run after its manifest was collected leaves: it + // records itself, and bubblewrap then finds no manifest to bind. + const left = join(DIR, '4242-0123456789abcdef.started') + writeFileSync(left, statLine({ pid: deadPid(), start: '1' })) + collectMountPoints() + expect(existsSync(left)).toBe(false) + }) + + // ---- nothing is removed but under a claim ---- + // + // bubblewrap binds the manifest before it makes a mount point. A pass renames + // a finished manifest before it removes what that names, so a command that + // starts from then on fails having made nothing, and reads the directory + // after that, for a command that started just before. + + /** Runs `at` just before the pass removes `mountPoint`. */ + function atTheRemovalOf( + mountPoint: string, + at: () => void, + ): { restore(): void } { + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === mountPoint) at() + unlink(file) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('claims a finished manifest before it removes what that names', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const seen: unknown[] = [] + const removal = atTheRemovalOf(X, () => + seen.push({ manifest: existsSync(manifest), claims: claims() }), + ) + try { + expect(collectMountPoints()).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([{ manifest: false, claims: [claimOf(manifest)] }]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('gives its claim back, and removes nothing, when a process has started under the manifest by then', () => { + // The command was started between the pass reading the manifest as finished + // and its claim: bubblewrap has bound the manifest and goes on to its mount + // points. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const pass = duringTheNextPass(() => started(manifest, thisProcess())) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + expect(claims()).toEqual([]) + }) + + /** Claims `manifest`, as a pass does. */ + function claimed(manifest: string): string { + const claim = join(DIR, claimOf(manifest)) + fs.renameSync(manifest, claim) + return claim + } + + // ---- a path is kept while any manifest that names it may have a sandbox ---- + + it('keeps a path that a live manifest names too, whichever manifest the pass is collecting', () => { + // The same project wrapped twice: both wraps name the mount point. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const finished = manifestOfADeadProcess([X, Y]) + const live = started(manifestOfADeadProcess([X]), thisProcess()) + expect(collectMountPoints()).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(finished)).toBe(false) + expect(existsSync(live)).toBe(true) + + // It goes once that sandbox has ended too. + started(live, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + }) + + it('keeps a path that another manifest names when a command starts under that one during the pass', () => { + // Both read as finished, and both are claimed. The reading that follows the + // claims finds a process on the record of one: it keeps the path for both. + leftover(X) + const one = manifestOfADeadProcess([X]) + const other = manifestOfADeadProcess([X]) + const pass = duringTheNextPass(() => started(other, thisProcess())) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(other)).toBe(true) + expect(existsSync(one)).toBe(false) + expect(claims()).toEqual([]) + }) + + it('keeps what a manifest at its own name names, finished or not', () => { + // A command can start under it at any moment: only a claim refuses one. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const refused = manifestOfADeadProcess([X]) + manifestOfADeadProcess([X, Y]) + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + if (from === refused) { + throw Object.assign(new Error(`EACCES: ${from}`), { code: 'EACCES' }) + } + rename(from, to) + }) as never) + try { + expect(collectMountPoints()).toEqual([Y]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(refused)).toBe(true) + }) + + // ---- any number of passes at once ---- + // + // A pass takes no lock and waits for nobody. A claim is anybody's to act on: + // with no process on its record nothing can start under it again. + + it('collects on a claim that another pass made, or that a killed one left', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + // Though its writer runs: whoever claimed it knew its command to be over. + const claim = claimed(manifestOfADeadProcess([X], thisProcess())) + manifestOfADeadProcess([X, Y]) + expect([...namedMountPoints().named].sort()).toEqual([X, Y].sort()) + expect(live()).toEqual([]) + expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + expect(existsSync(claim)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('keeps what a claimed manifest names while a process on its record runs, and leaves the claim to the pass that made it', () => { + leftover(X) + const claim = claimed(started(manifestOfADeadProcess([X]), thisProcess())) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([basename(claim)]) + }) + + it('leaves a manifest and its record that were given back while it removed what they name', () => { + // The pass that made the claim found a process on the record after this one + // had read it, and a sandbox now runs under the manifest again. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const manifest = manifestOfADeadProcess([X, Y]) + const claim = claimed(manifest) + const removal = atTheRemovalOf(X, () => { + fs.renameSync(claim, manifest) + started(manifest, thisProcess()) + // A sandbox takes longer than this to start, and a listing is good for a + // quarter of a millisecond. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) + }) + try { + // Y stays: the manifest is at its own name by the time Y's turn comes. + expect(collectMountPoints()).toEqual([X]) + } finally { + removal.restore() + } + expect(existsSync(Y)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual( + [basename(manifest), basename(recordOf(manifest))].sort(), + ) }) - it('turns back, keeping everything, at an unreadable manifest that may have a sandbox starting under it', () => { + it('finds a manifest that is claimed, or given back, between being listed and being opened', () => { + // Taken for gone, it would name nothing: a wrap would take its mount point + // for the caller's own file, and a pass would remove what it names. + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + const open = fs.openSync + let claim: string | undefined + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: string, + ...rest: unknown[] + ) => { + if (file === manifest && claim === undefined) { + claim = claimed(manifest) + } else if (file === claim && !existsSync(manifest)) { + fs.renameSync(claim, manifest) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect([...namedMountPoints().named]).toEqual([X]) + expect(claim).toBeDefined() + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + }) + + it('removes nothing while a listed manifest keeps being gone when it is opened', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([Y]) + const elusive = started(manifestOfADeadProcess([X]), thisProcess()) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: string, + ...rest: unknown[] + ) => { + if (file === elusive) { + throw Object.assign(new Error(`ENOENT: ${file}`), { code: 'ENOENT' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + // What it names cannot be listed, so what any other names is held. + expect(live()).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(Y)).toBe(true) + }) + + it('leaves a claim on a manifest written in another PID namespace', () => { + leftover(X) + const claim = claimed(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(claim)).toBe(true) + expect(existsSync(X)).toBe(true) + }) + + it('never blocks the thread it runs on', () => { + // A clean-up runs on its caller's one thread, after every command. + leftover(X) + claimed(manifestOfADeadProcess([X])) + manifestOfADeadProcess([X]) + const wait = spyOn(Atomics, 'wait') + try { + expect(collectMountPoints()).toEqual([X]) + expect(wait).not.toHaveBeenCalled() + } finally { + wait.mockRestore() + } + }) + + // ---- what cannot be told is live ---- + // + // Only a process that is gone says its sandbox has ended. A sandboxed command + // can use up what this user may open or map, so a read that fails in a pass + // must never end in a removal. + + /** Has reading `target` fail with `code`: opening it, or reading what was opened. */ + function failing( + target: string, + code: string, + at: 'open' | 'read' = 'open', + ): { restore(): void } { + const fail = (): never => { + throw Object.assign(new Error(`${code}: ${target}`), { code }) + } + const inode = existsSync(target) ? statSync(target).ino : undefined + const open = fs.openSync + const read = fs.readFileSync + const fstat = fs.fstatSync + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => + at === 'open' && file === target + ? fail() + : (open as (...args: unknown[]) => number)(file, ...rest)) as never), + spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => + file === target || + (at === 'read' && typeof file === 'number' && fstat(file).ino === inode) + ? fail() + : (read as (f: unknown, o: unknown) => unknown)( + file, + options, + )) as never), + ] + return { restore: () => spies.forEach(spy => spy.mockRestore()) } + } + + /** What a pass removes, and the set a caller is given, with that in place. */ + function withThat(injected: { restore(): void }): { + removed: string[] + held: string[] + } { + try { + return { held: live(), removed: collectMountPoints() } + } finally { + injected.restore() + } + } + + const NOTHING_REMOVED = { removed: [], held: [expect.any(String)] } + + for (const code of ['EACCES', 'EMFILE', 'ENFILE', 'ENOMEM', 'EIO']) { + it(`removes nothing when it cannot ask after the process on a record: ${code}`, () => { + leftover(X) + const gone = { pid: deadPid(), start: '1' } + started(manifestOfADeadProcess([X]), gone) + expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + + it(`removes nothing when it cannot ask after the writer of a manifest with no record: ${code}`, () => { + leftover(X) + const gone = { pid: deadPid(), start: '1' } + manifestOfADeadProcess([X], gone) + expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + + for (const at of ['open', 'read'] as const) { + it(`removes nothing at all when a manifest cannot be read: ${code} at the ${at}`, () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([Y]) + const manifest = manifestOfADeadProcess([X]) + // However old it is, and with no process on its record: this is no + // file to drop for being unreadable. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(manifest, hoursAgo, hoursAgo) + // What it names cannot be listed, so what any other names is held. + expect(withThat(failing(manifest, code, at))).toEqual({ + removed: [], + held: [Y], + }) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + it(`keeps what a manifest names when its record cannot be read: ${code} at the ${at}`, () => { + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), { + pid: deadPid(), + start: '1', + }) + expect(withThat(failing(recordOf(manifest), code, at))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + } + } + + it('removes nothing when what it reads of the process on a record is cut short', () => { + leftover(X) + started(manifestOfADeadProcess([X]), thisProcess()) + const read = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const whole = (read as (f: unknown, o: unknown) => unknown)(file, options) + return file === `/proc/${process.pid}/stat` + ? String(whole).split(' ').slice(0, 12).join(' ') + : whole + }) as never) + expect(withThat({ restore: () => spy.mockRestore() })).toEqual( + NOTHING_REMOVED, + ) + }) + + const gone = (): string => statLine({ pid: deadPid(), start: '1' }) + const notARecord: [string, (record: string) => void][] = [ + ['empty, as between the shell making it and writing to it', r => write(r)], + ['cut short of its end of line', r => write(r, gone().slice(0, -1))], + ['cut short of the start time', r => write(r, `${gone().slice(0, 30)}\n`)], + ['not the line of a process', r => write(r, 'ended\n')], + [ + 'a line of a process and then something else', + r => write(r, `${gone()}?\n`), + ], + [ + 'larger than a record can be', + r => write(r, gone().repeat(1 + Math.ceil(65536 / gone().length))), + ], + [ + 'a link to the record of a process that is gone', + r => { + write(join(BASE, 'elsewhere.started'), gone()) + symlinkSync(join(BASE, 'elsewhere.started'), r) + }, + ], + ['a directory', r => mkdirSync(r)], + [ + 'a FIFO nobody writes to', + r => expect(spawnSync('mkfifo', [r]).status).toBe(0), + ], + ] + function write(file: string, text = ''): void { + writeFileSync(file, text, { mode: 0o600 }) + } + for (const [what, plant] of notARecord) { + it(`keeps what a manifest names while its record is ${what}`, () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + plant(recordOf(manifest)) + // In a process of its own: opening a FIFO to read waits for a writer, on + // the one thread there is. + const looked = inAChild( + `console.log(JSON.stringify({ held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + ) + expect(looked.status).toBe(0) + expect(looked.ms).toBeLessThan(AT_ONCE_MS) + expect(JSON.parse(looked.stdout)).toEqual({ held: [X], removed: [] }) + expect(existsSync(manifest)).toBe(true) + }, 15000) + } + + it("keeps what a manifest names while its record is somebody else's", () => { leftover(X) - const theirs = manifestOfADeadProcess([X]) - const own = publishMountPointManifest([join(BASE, 'other')], [])!.file - const stranger = join(DIR, '1-garbage.json') - writeFileSync(stranger, '{ not json') - expect(collectMountPoints('all')).toEqual([]) + const manifest = started(manifestOfADeadProcess([X]), { + pid: deadPid(), + start: '1', + }) + const theirs = asSomebodyElses(recordOf(manifest)) + expect(withThat(theirs)).toEqual(NOTHING_REMOVED) expect(existsSync(X)).toBe(true) - expect(existsSync(theirs)).toBe(true) - expect(existsSync(own)).toBe(true) - // Once it is old enough that nothing can be starting under it, and no - // lock is on it, it stands in the way of nothing. - const halfAMinuteAgo = new Date(Date.now() - 30_000) - utimesSync(stranger, halfAMinuteAgo, halfAMinuteAgo) - expect(collectMountPoints('none')).toEqual([X]) - expect(existsSync(own)).toBe(false) - expect(existsSync(stranger)).toBe(true) }) - it('removes nothing when /proc/locks cannot be read', () => { + it('removes nothing while a process that runs is on a record whose manifest is not to be found', () => { + // A claim is a rename, and a rename can hide a name from a listing that is + // under way: the manifest may be there all the same. leftover(X) - const manifest = manifestOfADeadProcess([X]) - const readFile = fs.readFileSync - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, - options: unknown, - ) => { - if (file === '/proc/locks') { - throw Object.assign(new Error('EACCES: /proc/locks'), { - code: 'EACCES', - }) - } - return (readFile as (f: unknown, o: unknown) => unknown)(file, options) - }) as never) - try { - expect(collectMountPoints()).toEqual([]) - } finally { - spy.mockRestore() - } + manifestOfADeadProcess([X]) + const record = join(DIR, '4242-0123456789abcdef.started') + writeFileSync(record, statLine(thisProcess())) + // However old it is. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(record, hoursAgo, hoursAgo) + expect(collectMountPoints()).toEqual([]) expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) + expect(existsSync(record)).toBe(true) }) - it('keeps what a finished manifest names when its lock shows on the look before the removals', () => { - // The bubblewrap a wrap has handed to its caller takes its lock whenever - // the caller starts it, so the kernel is asked again before anything goes. + it('removes nothing when the manifests cannot be listed', () => { leftover(X) - const manifest = manifestOfADeadProcess([X]) - const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n` - const readFile = fs.readFileSync - let reads = 0 - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, + manifestOfADeadProcess([X]) + const readdir = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, options: unknown, ) => { - if (file === '/proc/locks') { - reads++ - return reads === 1 ? '' : line + if (dir === DIR) { + throw Object.assign(new Error(`EMFILE: ${DIR}`), { code: 'EMFILE' }) } - return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + return (readdir as (d: unknown, o: unknown) => unknown)(dir, options) }) as never) try { expect(collectMountPoints()).toEqual([]) } finally { spy.mockRestore() } - expect(reads).toBeGreaterThan(1) - expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) - }) - - it('lists the directory again between removals, not only before the first', () => { - // A sandbox about to start on a path shows as a manifest that was not there - // when the pass began, at any point of a pass over many mount points. One - // listing is good for a quarter of a millisecond. - const Y = join(BASE, 'second.lock') - leftover(X) - leftover(Y) - manifestOfADeadProcess([X, Y]) - const unlink = fs.unlinkSync - let arrived: string | undefined - const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { - unlink(file) - if (file === X && arrived === undefined) { - // Published the moment the first mount point has gone, with the pass - // held up for a millisecond before the next: less than the two for - // which it trusts what it read from /proc/locks. - arrived = manifestOfADeadProcess([Y], { - pid: process.pid, - created: Date.now(), - }) - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) - } - }) as never) - let removed: string[] - try { - removed = collectMountPoints() - } finally { - spy.mockRestore() - } - expect(arrived).toBeDefined() - expect(removed).toEqual([X]) - expect(existsSync(Y)).toBe(true) - }) - - it('is live for half a second from when it was written, though its writer is gone', () => { - // bubblewrap takes the lock in the sandbox's init process, a moment after - // a wrapping process killed just then could still have vouched for it. - leftover(X) - const manifest = manifestOfADeadProcess([X], { created: Date.now() }) - expect(collectMountPoints()).toEqual([]) expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) }) // ---- a manifest is only a claim about a path --------------------------- @@ -708,10 +1018,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('takes away what a killed process left half written, once it is old', () => { - const old = [ - join(DIR, 'directory.lock.4242.0123456789abcdef.tmp'), - join(DIR, '4242-0123456789abcdef.json.tmp'), - ] + const old = [join(DIR, '4242-0123456789abcdef.json.tmp')] const young = join(DIR, '4243-0123456789abcdef.json.tmp') for (const file of [...old, young]) writeFileSync(file, '{}') const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) @@ -724,9 +1031,8 @@ describe.if(isLinux)('The mount point manifests', () => { // ---- liveness is relative to a PID namespace ---- // - // /proc/locks lists a lock only when its holder has a pid in the namespace of - // the /proc being read, and /proc/PID is local to it. From another namespace - // a running sandbox's manifest looks like one a dead process left. + // /proc/PID is local to a PID namespace. From another namespace a running + // sandbox's manifest looks like one a dead process left. it.each([ ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], @@ -738,12 +1044,12 @@ describe.if(isLinux)('The mount point manifests', () => { ['that does not say where it was written', 'kept', { ns: undefined }], [ 'that does not say where it was written, from hours ago', - 'collected', + 'kept', { ns: undefined, created: Date.now() - 2 * 3600 * 1000 }, ], ['written in this PID namespace', 'collected', {}], ] as const)( - 'a manifest %s, with no writer and no lock to be seen, is %s', + 'a manifest %s, with no writer and no process on record to be seen, is %s', (_what, outcome, fields) => { leftover(X) const manifest = manifestOfADeadProcess([X], fields) @@ -753,126 +1059,49 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) - // ---- an inode number is not a file ------------------------------------- - - /** Has the next passes read `text` for /proc/locks. */ - function withProcLocks(text: string): { restore(): void } { - const readFile = fs.readFileSync - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, - options: unknown, - ) => - file === '/proc/locks' - ? text - : (readFile as (f: unknown, o: unknown) => unknown)( - file, - options, - )) as never) - return { restore: () => spy.mockRestore() } - } - - /** `major:minor` of the filesystem `p` is on, as /proc/locks prints it. */ - function deviceOf(p: string): string { - const dev = statSync(p, { bigint: true }).dev - const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) - const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) - const hex = (n: bigint): string => n.toString(16).padStart(2, '0') - return `${hex(major)}:${hex(minor)}` - } - - // tmpfs, ext2/3/4 and xfs report for a file the device /proc/locks prints. - const DEVICES_COMPARE = - isLinux && - [0x01021994, 0xef53, 0x58465342].includes( - Number(statfsSync(realpathSync(tmpdir())).type), - ) - - it.if(DEVICES_COMPARE)( - 'is not kept live by a lock on a file with the same inode number on another filesystem', - () => { - // Inode numbers are per filesystem, and on a young tmpfs they are small. - // A manifest that shares its number with an unrelated locked file on - // another filesystem must not read as live. - leftover(X) - const manifest = publishMountPointManifest([X], [])!.file - const inode = statSync(manifest).ino - const elsewhere = withProcLocks( - `1: POSIX ADVISORY WRITE 4242 fe:77:${inode} 0 EOF\n`, - ) - try { - expect(collectMountPoints()).toEqual([X]) - } finally { - elsewhere.restore() - } - expect(existsSync(manifest)).toBe(false) - }, - ) - - it('is kept live by a lock on the manifest itself', () => { + it('is live when written in another PID namespace, whatever process is on its record', () => { + // The pid on the record was given in that namespace too. leftover(X) - const manifest = publishMountPointManifest([X], [])!.file - const inode = statSync(manifest).ino - const onIt = withProcLocks( - `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${inode} 0 EOF\n`, - ) - try { - expect(collectMountPoints()).toEqual([]) - } finally { - onIt.restore() - } - expect(existsSync(manifest)).toBe(true) + const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { + pid: deadPid(), + start: '1', + }) + expect(collectMountPoints()).toEqual([]) expect(existsSync(X)).toBe(true) - // And goes with the lock. - expect(collectMountPoints()).toEqual([X]) + expect(existsSync(manifest)).toBe(true) }) // ---- where the manifests are kept -------------------------------------- - const SHM = '/dev/shm' - const SHM_WRITABLE = (() => { - try { - accessSync(SHM, constants.W_OK | constants.X_OK) - return true - } catch { - return false - } - })() - - it.if(SHM_WRITABLE)( + it.if(BWRAP_CAN_NAMESPACE)( 'are never kept under /dev, which the sandbox mounts afresh over them', () => { - // bubblewrap opens the manifest after its mounts, and the wrap mounts a - // new /dev after every bind: a manifest under /dev would not be there to - // be opened. - const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) - chmodSync(shm, 0o700) - try { - const elsewhere = TMP - const fellBack = inAChild(PUBLISH, { - env: { XDG_RUNTIME_DIR: shm, TMPDIR: elsewhere }, - }) - expect(fellBack.status).toBe(0) - const manifest = JSON.parse(fellBack.stdout) as { file: string } - expect(manifest.file.startsWith(`${elsewhere}/`)).toBe(true) - expect(readdirSync(shm)).toEqual([]) - - // With nowhere else to go it records nothing, rather than somewhere - // bubblewrap cannot reach. - const env: Record = { ...process.env, TMPDIR: shm } - delete env.XDG_RUNTIME_DIR - const script = join(BASE, 'nowhere.ts') - writeFileSync(script, `const m = await import(${MODULE})\n${PUBLISH}\n`) - const nowhere = spawnSync(process.execPath, [script], { - env, - encoding: 'utf8', - timeout: 6000, - }) - expect(nowhere.status).toBe(0) - expect(JSON.parse(nowhere.stdout)).toBe(null) - expect(readdirSync(shm)).toEqual([]) - } finally { - rmSync(shm, { recursive: true, force: true }) + // The wrap mounts a new /dev after every bind: a manifest directory under + // /dev could be neither bound nor kept out of the command's reach. Looked + // at with a /dev/shm of the child's own, not the one every process has. + const SHM = '/dev/shm' + const launcher = ['bwrap', '--dev-bind', '/', '/', '--tmpfs', SHM, '--'] + const publishAndList = `const { readdirSync } = await import('node:fs')\nconsole.log(JSON.stringify({ manifest: m.publishMountPointManifest(['/nonexistent/x'], []) ?? null, made: readdirSync(${JSON.stringify(SHM)}) }))` + const fellBack = inAChild(publishAndList, { + launcher, + env: { XDG_RUNTIME_DIR: SHM }, + }) + expect(fellBack.status).toBe(0) + const recorded = JSON.parse(fellBack.stdout) as { + manifest: { file: string } + made: string[] } + expect(recorded.manifest.file.startsWith(`${TMP}/`)).toBe(true) + expect(recorded.made).toEqual([]) + + // With nowhere else to go it records nothing, rather than somewhere + // bubblewrap cannot reach. + const nowhere = inAChild(publishAndList, { + launcher, + env: { XDG_RUNTIME_DIR: undefined, TMPDIR: SHM }, + }) + expect(nowhere.status).toBe(0) + expect(JSON.parse(nowhere.stdout)).toEqual({ manifest: null, made: [] }) }, 15000, ) @@ -922,15 +1151,181 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) + // ---- which directory is believed ---- + // + // A pass removes what it finds named in the directory, so the directory has + // to be the user's alone, and is looked at again at every use. + + /** Has `file` look like another user's: nothing here can make one. */ + function asSomebodyElses(file: string): { restore(): void } { + const inode = lstatSync(file).ino + const spies = (['lstatSync', 'fstatSync'] as const).map(name => { + const real = fs[name] as (f: unknown, o: unknown) => fs.Stats + return spyOn(fs, name).mockImplementation((( + target: unknown, + options: unknown, + ) => { + const stat = real(target, options) + if (stat.ino !== inode) return stat + const theirs = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(theirs, stat, { uid: stat.uid + 1 }) + }) as never) + }) + return { restore: () => spies.forEach(spy => spy.mockRestore()) } + } + + /** Where this process, having settled on nothing yet, records X. */ + function recordedIn(): string { + forgetMountPointManifestDirectory() + return dirname(publishMountPointManifest([X], [])!.file) + } + + it("are not kept in a directory that is somebody else's", () => { + const theirs = asSomebodyElses(DIR) + try { + expect(recordedIn()).toBe( + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ) + } finally { + theirs.restore() + forgetMountPointManifestDirectory() + } + expect(readdirSync(DIR)).toEqual([]) + }) + + it("are kept in a directory that others could read or write only once it has been made the user's alone", () => { + chmodSync(DIR, 0o755) + expect(recordedIn()).toBe(DIR) + expect(statSync(DIR).mode & 0o777).toBe(0o700) + }) + + it("are not kept in a directory that cannot be made the user's alone", () => { + chmodSync(DIR, 0o750) + const spy = spyOn(fs, 'fchmodSync').mockImplementation((() => { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) + }) as never) + try { + expect(recordedIn()).not.toBe(DIR) + } finally { + spy.mockRestore() + forgetMountPointManifestDirectory() + } + expect(readdirSync(DIR)).toEqual([]) + }) + + it('are kept in a directory that is looked at again at every use', () => { + expect(recordedIn()).toBe(DIR) + rmSync(DIR, { recursive: true }) + + // Opened up since: made the user's alone again before it is used. + mkdirSync(DIR, { mode: 0o777 }) + chmodSync(DIR, 0o777) + expect(dirname(publishMountPointManifest([X], [])!.file)).toBe(DIR) + expect(statSync(DIR).mode & 0o777).toBe(0o700) + rmSync(DIR, { recursive: true }) + + // Swapped for a link since: not followed, and what it leads to left alone. + const target = join(BASE, 'somebody-elses') + mkdirSync(target, { mode: 0o700 }) + symlinkSync(target, DIR) + try { + expect(dirname(publishMountPointManifest([X], [])!.file)).not.toBe(DIR) + expect(readdirSync(target)).toEqual([]) + } finally { + rmSync(DIR) + mkdirSync(DIR, { mode: 0o700 }) + forgetMountPointManifestDirectory() + } + }) + + // ---- with nowhere to record ---- + // + // No other process can know of these mount points, so the process that made + // them removes them itself, once none of its wraps is outstanding. + + it.if(NOT_ROOT)( + 'keeps in memory what it could record nowhere, and removes it at its own clean-up', + () => { + const directory = join(BASE, 'empty-directory') + const source = join(BASE, 'source') + const readOnly = join(BASE, 'read-only-tmp') + leftover(X) + mkdirSync(directory) + mkdirSync(source, { mode: 0o700 }) + mkdirSync(readOnly, { mode: 0o500 }) + const nowhere = inAChild( + [ + `const at = ${JSON.stringify({ X, directory, source })}`, + `const { existsSync } = await import('node:fs')`, + `const there = () => [at.X, at.directory, at.source].map(p => existsSync(p))`, + `const published = m.publishMountPointManifest([at.X, at.directory], [at.source]) ?? null`, + `const named = m.namedMountPoints()`, + `const held = [...m.liveMountPoints()].sort()`, + `const some = { removed: m.collectMountPoints('none'), there: there() }`, + `const all = { removed: m.collectMountPoints('all').sort(), there: there() }`, + `console.log(JSON.stringify({ published, named: [...named.named].sort(), live: [...named.live].sort(), held, some, all, again: m.collectMountPoints('all') }))`, + ].join('\n'), + // No runtime directory, and nothing can be made under the temp dir. + { env: { XDG_RUNTIME_DIR: undefined, TMPDIR: readOnly } }, + ) + expect(nowhere.status).toBe(0) + expect(JSON.parse(nowhere.stdout)).toEqual({ + published: null, + named: [X, directory].sort(), + live: [X, directory].sort(), + held: [X, directory].sort(), + some: { removed: [], there: [true, true, true] }, + all: { + removed: [X, directory, source].sort(), + there: [false, false, false], + }, + again: [], + }) + }, + 15000, + ) + + it('keeps what it could not record while a manifest of another process names it, where there is a directory to go by', () => { + // The manifest could not be written, as with the temp dir full, which a + // sandboxed command can bring about. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + started(manifestOfADeadProcess([X]), thisProcess()) + const write = fs.writeFileSync + const spy = spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.json.tmp')) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + return (write as (...args: unknown[]) => void)(file, ...rest) + }) as never) + try { + expect(publishMountPointManifest([X, Y], [])).toBeUndefined() + } finally { + spy.mockRestore() + } + expect([...namedMountPoints().named].sort()).toEqual([X, Y].sort()) + expect(collectMountPoints('none')).toEqual([]) + expect(collectMountPoints('all')).toEqual([Y]) + expect(existsSync(X)).toBe(true) + }) + // ---- what is read as a manifest ---- // // Only a regular file of the user's, of a size a manifest can have, is read: - // a link is not followed and a FIFO is not opened. + // a link is not followed and a FIFO is not opened. What is there instead + // names paths nobody can list, so while it may have a sandbox under it + // nothing at all is removed. - /** Thirty seconds old: nothing can be starting under it any more. */ + /** Two hours old: past the hour for which what is no manifest counts as live. */ function aged(file: string): string { - const halfAMinuteAgo = new Date(Date.now() - 30_000) - utimesSync(file, halfAMinuteAgo, halfAMinuteAgo) + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + lutimesSync(file, hoursAgo, hoursAgo) return file } @@ -948,97 +1343,89 @@ describe.if(isLinux)('The mount point manifests', () => { (name, paths) => { const elsewhere = join(BASE, 'elsewhere.json') writeFileSync(elsewhere, finishedManifestText(paths), { mode: 0o600 }) - aged(elsewhere) symlinkSync(elsewhere, name) }, ], [ 'a manifest of more than a mebibyte', - (name, paths) => { + (name, paths) => writeFileSync( name, finishedManifestText(paths) + ' '.repeat(1024 * 1024), { mode: 0o600 }, - ) - aged(name) - }, + ), ], [ - 'a directory', - name => { - mkdirSync(name) - aged(name) - }, + 'a manifest cut short', + (name, paths) => + writeFileSync(name, finishedManifestText(paths).slice(0, -1)), + ], + [ + 'a manifest of another version', + (name, paths) => + writeFileSync( + name, + finishedManifestText(paths).replace('"version":1', '"version":2'), + ), + ], + ['a directory', name => mkdirSync(name)], + [ + 'a FIFO nobody writes to', + name => expect(spawnSync('mkfifo', [name]).status).toBe(0), ], ] for (const [what, plant] of notAManifest) { - it(`passes over ${what}, and what it names`, () => { + it(`removes nothing beside ${what} until it is an hour old, and passes over it then`, () => { const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) - plant(join(DIR, '4242-0123456789abcdef.json'), [X]) + const junk = join(DIR, '4242-0123456789abcdef.json') + plant(junk, [X]) manifestOfADeadProcess([Y]) + // In a process of its own: opening a FIFO to read waits for a writer, on + // the one thread there is. + const look = (): unknown => { + const looked = inAChild( + `console.log(JSON.stringify({ named: [...m.namedMountPoints().named], held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + ) + expect(looked.status).toBe(0) + expect(looked.ms).toBeLessThan(AT_ONCE_MS) + return JSON.parse(looked.stdout) + } + // It names nothing that can be read, and may name anything. + expect(look()).toEqual({ named: [Y], held: [Y], removed: [] }) - expect([...namedMountPoints().named]).toEqual([Y]) - // Nor does it stand in the way of the manifests beside it. - expect(collectMountPoints()).toEqual([Y]) + // Nor while a process is on its record, however old it is. + aged(junk) + writeFileSync(recordOf(junk), statLine(thisProcess())) + expect(look()).toEqual({ named: [Y], held: [Y], removed: [] }) + + rmSync(recordOf(junk)) + expect(look()).toEqual({ named: [Y], held: [], removed: [Y] }) expect(existsSync(X)).toBe(true) - }) + // Dropped, where it is a file that can be. + expect(existsSync(junk)).toBe(what === 'a directory') + }, 30000) } - it("passes over a manifest that is somebody else's", () => { + it("removes nothing beside a manifest that is somebody else's until it is an hour old, and passes over it then", () => { const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) - const theirs = aged(manifestOfADeadProcess([X])) - const inode = statSync(theirs, { bigint: true }).ino + const junk = manifestOfADeadProcess([X]) manifestOfADeadProcess([Y]) - // As another user's would look: nothing here can make one. - const fstat = fs.fstatSync - const spy = spyOn(fs, 'fstatSync').mockImplementation((( - fd: unknown, - options: unknown, - ) => { - const stat = (fstat as (f: unknown, o: unknown) => fs.BigIntStats)( - fd, - options, - ) - if (stat.ino !== inode) return stat - const other = Object.create( - Object.getPrototypeOf(stat) as object, - ) as fs.BigIntStats - return Object.assign(other, stat, { uid: stat.uid + 1n }) - }) as never) + const theirs = asSomebodyElses(junk) try { expect([...namedMountPoints().named]).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + aged(junk) expect(collectMountPoints()).toEqual([Y]) } finally { - spy.mockRestore() + theirs.restore() } expect(existsSync(X)).toBe(true) }) - it('passes over a FIFO nobody writes to, and is not held up by it', () => { - const Y = join(BASE, 'second.lock') - leftover(Y) - manifestOfADeadProcess([Y]) - const fifo = join(DIR, '4242-0123456789abcdef.json') - expect(spawnSync('mkfifo', [fifo]).status).toBe(0) - aged(fifo) - // In a process of its own: opening a FIFO to read waits for a writer, on - // the one thread there is. - const looked = inAChild( - `console.log(JSON.stringify({ named: [...m.namedMountPoints().named], live: [...m.liveMountPoints()], collected: m.collectMountPoints() }))`, - ) - expect(looked.status).toBe(0) - expect(looked.ms).toBeLessThan(AT_ONCE_MS) - expect(JSON.parse(looked.stdout)).toEqual({ - named: [Y], - live: [], - collected: [Y], - }) - }, 15000) - // ---- which mount points a running sandbox relies on ---- // // A caller that removes paths itself after a command can ask first which to @@ -1050,23 +1437,6 @@ describe.if(isLinux)('The mount point manifests', () => { return [...liveMountPoints()].sort() } - /** Has /proc/locks refuse to be read. */ - function withUnreadableProcLocks(): { restore(): void } { - const readFile = fs.readFileSync - const spy = spyOn(fs, 'readFileSync').mockImplementation((( - file: unknown, - options: unknown, - ) => { - if (file === '/proc/locks') { - throw Object.assign(new Error('EACCES: /proc/locks'), { - code: 'EACCES', - }) - } - return (readFile as (f: unknown, o: unknown) => unknown)(file, options) - }) as never) - return { restore: () => spy.mockRestore() } - } - it('holds a mount point while a live manifest names it, file or directory, and no other path', () => { const directory = join(BASE, 'empty-directory') const other = join(BASE, 'other.lock') @@ -1146,37 +1516,17 @@ describe.if(isLinux)('The mount point manifests', () => { } }) - it('holds what a finished manifest names only when a sandbox holds the lock on it', () => { + it('holds what a finished manifest names only while a process on its record runs', () => { leftover(X) const manifest = manifestOfADeadProcess([X]) expect(live()).toEqual([]) - - const onIt = withProcLocks( - `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n`, - ) - try { - expect(live()).toEqual([X]) - } finally { - onIt.restore() - } - }) - - it('holds what any manifest names where /proc/locks cannot be read, and of that only what looks like a mount point', () => { - const written = join(BASE, 'written-to') - writeFileSync(written, 'mine') - chmodSync(written, 0o444) - leftover(X) - leftover(join(BASE, 'other.lock')) - manifestOfADeadProcess([X, written]) - const unreadable = withUnreadableProcLocks() - try { - expect(live()).toEqual([X]) - } finally { - unreadable.restore() - } + started(manifest, { pid: deadPid(), start: '1' }) + expect(live()).toEqual([]) + started(manifest, thisProcess()) + expect(live()).toEqual([X]) }) - it('holds what any manifest names while a manifest that cannot be read may have a sandbox under it', () => { + it('holds what any manifest names while something in the directory cannot be read, and of that only what looks like a mount point', () => { // What another version of this library wrote, say. What it names cannot be // listed, so while the clean-up takes it to be live every named path // counts. @@ -1191,54 +1541,36 @@ describe.if(isLinux)('The mount point manifests', () => { aged(stranger) expect(live()).toEqual([]) - - const onIt = withProcLocks( - `1: POSIX ADVISORY READ 4242 ${deviceOf(stranger)}:${statSync(stranger).ino} 0 EOF\n`, - ) - try { - expect(live()).toEqual([X]) - } finally { - onIt.restore() - } }) - it('is one listing of the directory and one reading of /proc/locks, under no lock', () => { + it('is one listing of the directory, and changes nothing in it', () => { leftover(X) publishMountPointManifest([X], []) - manifestOfADeadProcess([join(BASE, 'other.lock')]) - // Somebody holds the directory's lock: this process, which is running. - const start = readFileSync('/proc/self/stat', 'utf8') - .split(') ') - .pop()! - .split(' ')[19] - const held = `${process.pid} ${start} ${OWN_NAMESPACE}\n` - writeFileSync(LOCK, held) + started(manifestOfADeadProcess([join(BASE, 'other.lock')]), { + pid: deadPid(), + start: '1', + }) + // What a pass would take away: a record whose manifest and process are gone. + writeFileSync( + join(DIR, '4242-0123456789abcdef.started'), + statLine({ pid: deadPid(), start: '1' }), + ) const before = readdirSync(DIR).sort() - const readFile = spyOn(fs, 'readFileSync') const readdir = spyOn(fs, 'readdirSync') + const rename = spyOn(fs, 'renameSync') let listed: string[] - let looks: number let listings: number - let tookMs: number try { - const began = Date.now() listed = live() - tookMs = Date.now() - began - looks = readFile.mock.calls.filter( - ([file]) => file === '/proc/locks', - ).length listings = readdir.mock.calls.filter(([dir]) => dir === DIR).length + expect(rename).not.toHaveBeenCalled() } finally { - readFile.mockRestore() readdir.mockRestore() + rename.mockRestore() } expect(listed).toEqual([X]) - expect(looks).toBe(1) expect(listings).toBe(1) - // Not waited for, not taken, not broken. - expect(tookMs).toBeLessThan(1000) - expect(readFileSync(LOCK, 'utf8')).toBe(held) expect(readdirSync(DIR).sort()).toEqual(before) }) diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 1e07417ce..bdbc8b00c 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -24,7 +24,7 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' -import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' +import { countMounts, lastMountAt, manifestOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -158,7 +158,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { /** The mount points the wrap named in its manifest, none without one. */ function namedBy(command: string): string[] { - const file = /--lock-file (\S+)/.exec(command)?.[1] + const file = manifestOf(command) return file === undefined ? [] : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths From 6f6be30112efb07f98ed8f37d467155641d21fcb Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:33:31 +0000 Subject: [PATCH 083/141] linux: list before every removal of an ordinary clean-up, and bind a deny path made meanwhile One listing of the manifest directory was good for a quarter of a millisecond, so in a pass over ten paths the last was removed on a listing up to that old, not a few microseconds old as the README said. A pass of up to 64 candidates now lists before every removal; a larger one keeps the clock, so a directory of thousands is not listed thousands of times. The deny loop asked whether a component of an absent path is a file of the path itself too. Another sandbox's bubblewrap can make its mount point there between the two looks, and the path then got no bind for the whole command. It asks about what lies above the path. --- README.md | 2 +- src/sandbox/bwrap-mount-manifests.ts | 23 ++++++++++---- src/sandbox/linux-sandbox-utils.ts | 11 ++++--- test/sandbox/mount-point-manifests.test.ts | 37 ++++++++++++++++------ 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3cadee567..3d3b22cc6 100644 --- a/README.md +++ b/README.md @@ -745,7 +745,7 @@ Filesystem restrictions are enforced at the OS level: - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before its first removal and whenever that listing is a quarter of a millisecond old: a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap that publishes after the last such listing, and whose sandbox reaches its bind over a mount point that a claimed manifest names too before the removal that follows, loses that mount point: the cleanup would have to be held up between the listing and the removal for as long as a sandbox takes to start (a few milliseconds). + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap that publishes after the last such listing, and whose sandbox reaches its bind over a mount point that a claimed manifest names too before the removal that follows, loses that mount point: the cleanup would have to be held up, in the microseconds between a listing and the removal after it, for as long as a sandbox takes to start (a few milliseconds). - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them. - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index f26cb5542..00229eded 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -59,10 +59,16 @@ const CLAIMED_SUFFIX = '.claimed' const MANIFEST_GRACE_MS = 500 /** - * For how long one listing of the manifest directory is good during the - * removals: well under the time a starting sandbox needs to reach its binds, - * and a time rather than "before every removal" so a directory of thousands is - * not listed thousands of times. + * A pass with no more candidates than this lists the manifest directory again + * before every removal, so each is judged on a listing a few microseconds old. + * An ordinary pass is a handful of paths. + */ +const LISTS_BEFORE_EACH_REMOVAL_UP_TO = 64 + +/** + * For how long one listing is good in a larger pass: well under the time a + * starting sandbox needs to reach its binds, and a time so a directory of + * thousands is not listed thousands of times. */ const LISTING_GOOD_FOR_MS = 0.25 @@ -1019,8 +1025,9 @@ export function collectMountPoints( * a process on the record. So what remains is a manifest that comes to its own * name after the reading, published or given back, whose sandbox reaches its * bind on a path a claimed manifest names too. The directory is listed again - * before the first removal and whenever that listing is {@link - * LISTING_GOOD_FOR_MS} old, and what has come to its name keeps what it names, + * before every removal (in a pass of over {@link + * LISTS_BEFORE_EACH_REMOVAL_UP_TO} candidates, whenever the listing is {@link + * LISTING_GOOD_FOR_MS} old), and what has come to its name keeps what it names, * so the pass would have to be held up between a listing and the removal that * follows it for as long as a sandbox takes to start. * @@ -1097,6 +1104,8 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } } const known = new Set(reading.names) + const goodFor = + candidates.size <= LISTS_BEFORE_EACH_REMOVAL_UP_TO ? 0 : LISTING_GOOD_FOR_MS let listedAt = -Infinity const newcomersKeepWhatTheyName = (): boolean => { try { @@ -1124,7 +1133,7 @@ function collect(dir: string, unrecordedToo: boolean): string[] { const notRemoved = new Set() for (const [candidate, isSource] of candidates) { if ( - performance.now() - listedAt >= LISTING_GOOD_FOR_MS && + performance.now() - listedAt >= goodFor && !newcomersKeepWhatTheyName() ) { return removed diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 28ca5f932..558526183 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2473,11 +2473,14 @@ async function generateFilesystemArgs( // This wrap names them in its manifest, so a collect can remove them // once no running sandbox relies on them. if (!pathExists) { - // Fix 1 (worktree): If any existing component in the deny path is a - // file (not a directory), skip the deny entirely. You can't mkdir + // Fix 1 (worktree): If any existing component above the deny path is + // a file (not a directory), skip the deny entirely. You can't mkdir // under a file, so the deny path can never be created. This handles - // git worktrees where .git is a file. - if (hasFileAncestor(normalizedPath)) { + // git worktrees where .git is a file. Asked of what lies above the + // path, not of the path: another sandbox's bubblewrap may have made a + // mount point at it since the look above, and a file there is no + // reason to leave it unbound. + if (hasFileAncestor(path.dirname(normalizedPath))) { logForDebugging( `[Sandbox Linux] Skipping deny path with file ancestor (cannot create paths under a file): ${normalizedPath}`, ) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 64a3fb249..8b493aba2 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -374,10 +374,10 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(DIR)).toEqual([]) }) - it('lists the directory again between removals, not only before the first', () => { + it('lists the directory again before every removal of an ordinary pass', () => { // A sandbox about to start on a path shows as a manifest that was not there - // when the pass began, at any point of a pass over many mount points. One - // listing is good for a quarter of a millisecond. + // when the pass began, at any point of the pass, with no time having to go + // by: each removal is judged on a listing made just before it. const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -387,13 +387,11 @@ describe.if(isLinux)('The mount point manifests', () => { const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { unlink(file) if (file === X && arrived === undefined) { - // Published the moment the first mount point has gone, with the pass - // held up for a millisecond before the next. + // Published the moment the first mount point has gone. arrived = manifestOfADeadProcess([Y], { pid: process.pid, created: Date.now(), }) - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) } }) as never) let removed: string[] @@ -407,6 +405,30 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(Y)).toBe(true) }) + it('lists by the clock, not before every removal, in a pass of many mount points', () => { + // A directory of thousands is not listed thousands of times. + const many = Array.from({ length: 200 }, (_, i) => join(BASE, `m${i}.lock`)) + many.forEach(leftover) + manifestOfADeadProcess(many) + const readdir = fs.readdirSync + let listings = 0 + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + if (args[0] === DIR) listings++ + return readdir(...args) + }) as never) + let removed: string[] + try { + removed = collectMountPoints() + } finally { + spy.mockRestore() + } + expect(removed.sort()).toEqual([...many].sort()) + expect(listings).toBeGreaterThan(2) + expect(listings).toBeLessThan(many.length) + }) + it('is live for half a second from when it was written, though its writer is gone', () => { // A command whose wrapping process was killed a moment ago is not refused // its start for that. @@ -636,9 +658,6 @@ describe.if(isLinux)('The mount point manifests', () => { const removal = atTheRemovalOf(X, () => { fs.renameSync(claim, manifest) started(manifest, thisProcess()) - // A sandbox takes longer than this to start, and a listing is good for a - // quarter of a millisecond. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1) }) try { // Y stays: the manifest is at its own name by the time Y's turn comes. From 0eaf352da505bb9ada7df5245cf6bb968be6a06b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 20:28:08 +0000 Subject: [PATCH 084/141] linux: a wrap names every directory it relies on, and any empty file a manifest names is a mount point A wrap that found another sandbox's directory mount point on the host (`.claude` in a project that has none) made its own mount points inside it and pinned it, but named only the inner paths. Once the first command ended, any clean-up removed the directory: the second command was refused its start, or lost the pin while it was starting. Two short commands at once in one process were enough. A manifest now also names every directory mount point that the wrap binds from or makes a mount point in. After publishing, the wrap looks again at what it relies on, discards the manifest and plans afresh if any of it has gone, and after three plans throws `mount_points_changed`. A clean-up removes children first, and keeps the claim on a directory that still holds what another manifest names. bubblewrap before 0.5.0 makes its file mount points with write bits. They were not recognised, never removed, and missing from liveMountPoints(). For a path a manifest names, the shape is now an empty regular file of the user's with one link, whatever its mode. Also: - The record step reads /proc/self/stat, not /proc/$$/stat, and runs in `bash -p` from /bin or /usr/bin, else /bin/sh. dash drops environment entries whose names are no identifiers; plain `bash -c` imports exported functions and reads $BASH_ENV, on the host; and a PATH look-up would run a planted copy there. - A wrap that cannot read the manifests refuses (`mount_points_unreadable`) rather than take a mount point for the user's file. liveMountPoints() answers `undefined` when it cannot tell, and does not throw. - A record with no whole line on it is no record; one with no manifest beside it no longer holds up every clean-up. - No manifest is written that the reader would refuse (`too_many_mount_points`). - A manifest of a later version is read by the fields this one knows. --- README.md | 18 +- src/sandbox/bwrap-mount-manifests.ts | 450 ++++++++++++++++----------- src/sandbox/linux-sandbox-utils.ts | 328 +++++++++++++------ src/sandbox/sandbox-manager.ts | 23 +- 4 files changed, 526 insertions(+), 293 deletions(-) diff --git a/README.md b/README.md index 3d3b22cc6..21e9aff01 100644 --- a/README.md +++ b/README.md @@ -736,28 +736,30 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory. The command line it returns is a shell that first appends its own line of `/proc//stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`Can't find source path`) and leaves nothing on the host, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every directory mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent. Where neither is there it is `/bin/sh`, and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and does not hold a manifest's fields counts so for an hour, and is dropped then; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. - **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full temp dir), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Can't find source path") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or something in the directory cannot be read and may have a sandbox under it (every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken from one listing of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (the manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken from one listing of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). Off Linux the set is always empty. - **Known limits.** - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. - - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap that publishes after the last such listing, and whose sandbox reaches its bind over a mount point that a claimed manifest names too before the removal that follows, loses that mount point: the cleanup would have to be held up, in the microseconds between a listing and the removal after it, for as long as a sandbox takes to start (a few milliseconds). + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. + - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a reboot or the last logout (`$XDG_RUNTIME_DIR` is a tmpfs that logind removes), a container restart with the project on a volume, a cleaner of the temp dir. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. + - _An older release beside this one._ A process of 0.0.77 or earlier takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running. The protection holds between processes of this release and later. - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them. - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. -**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. +**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads ` -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, and the binds of the root and of the manifest kept on the line ahead of `--args`, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string run after that refuses to start rather than running without its mount points, and makes nothing on the host. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest, and a second run is then refused. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read (this process is out of descriptors, or something that does not hold a manifest's fields, and is less than an hour old, sits among them), so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 00229eded..9758cd461 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -8,14 +8,16 @@ * only when no running sandbox relies on it, and no process can decide that by * counting for itself. * - * The sandbox vouches for itself. Each wrap writes a manifest naming its mount - * points, `.json`, into a per-user directory. The command line it hands out - * is a shell that appends its own /proc/$$/stat line to `.started` and then - * execs bubblewrap: the process on record is bubblewrap, recorded before it has - * made a mount, and the wrap always passes --die-with-parent, so the sandbox - * dies with it. A manifest with a record is live exactly while a process with a - * recorded pid and start time exists, which any process can ask, so cleanup is - * a garbage collect any process may run at any time. + * The sandbox vouches for itself. Each wrap writes a manifest, `.json`, into + * a per-user directory, naming its mount points and every directory mount point + * of another sandbox that its own mounts lie in or bind from. The command line + * it hands out is a shell that appends its own /proc/self/stat line to + * `.started` and then execs bubblewrap: the process on record is bubblewrap, + * recorded before it has made a mount, and the wrap always passes + * --die-with-parent, so the sandbox dies with it. A manifest with a record is + * live exactly while a process with a recorded pid and start time exists, which + * any process can ask, so cleanup is a garbage collect any process may run at + * any time. * * bubblewrap binds the manifest before it makes a mount point, and a pass * claims a finished manifest, renaming it to `.claimed`, before it removes @@ -41,7 +43,7 @@ import { randomBytes } from 'node:crypto' import * as fs from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { z } from 'zod' +import { z } from 'zod/v3' import { logForDebugging } from '../utils/debug.js' import { isAbsenceErrno } from './sandbox-utils.js' @@ -98,7 +100,15 @@ const RECORD_MAX_BYTES = 64 * 1024 const TEMPORARY_SUFFIX = '.tmp' const ManifestSchema = z.object({ - version: z.literal(MANIFEST_VERSION), + /** + * INVARIANT for every later version: it may add fields, and keeps these, the + * three file names and what they mean, so a reader takes a later version by + * the fields it knows. A manifest that is not read puts everything in doubt, + * which would refuse every wrap of an older process for as long as a newer + * one is at work beside it. A change that cannot keep to this takes another + * directory name. + */ + version: z.number().int().min(MANIFEST_VERSION), /** The process that wrapped, and its start time, to tell a recycled pid. */ pid: z.number().int().nonnegative(), start: z.string(), @@ -190,12 +200,23 @@ function startTimeFromProcStat(stat: string): string | undefined { : undefined } -function processStartTime(pid: number): string | undefined { +/** + * This process as the /proc it sees numbers it, which is what a pass will ask + * after. `process.pid` is another number where the process has a PID namespace + * of its own under an outer /proc (a nested sandbox): nobody's there, or an + * unrelated process's. + */ +function ownProcess(): { pid: number; start: string } { try { - return startTimeFromProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')) + const stat = fs.readFileSync('/proc/self/stat', 'utf8') + const pid = /^\d+/.exec(stat)?.[0] + if (pid !== undefined) { + return { pid: Number(pid), start: startTimeFromProcStat(stat) ?? '' } + } } catch { - return undefined + // No /proc: every manifest counts as live then (see isLive). } + return { pid: process.pid, start: '' } } /** @@ -231,8 +252,12 @@ function ownPidNamespace(): string | undefined { return pidNamespace } -/** Whether `dir` is a directory of ours that nobody else can write, and we can. */ -function isOurPrivateDirectory(dir: string): boolean { +/** + * Whether `dir` is a directory of ours that nobody else can write, and we can. + * With `orThrow`, an error that says neither "not there" nor "not to be written + * from here" is thrown, for a caller that must not take it for a no. + */ +function isOurPrivateDirectory(dir: string, orThrow = false): boolean { try { const stat = fs.lstatSync(dir) if ( @@ -246,7 +271,16 @@ function isOurPrivateDirectory(dir: string): boolean { // binds read-only reads as ours in every other respect. fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) return true - } catch { + } catch (e) { + const code = (e as NodeJS.ErrnoException).code + if ( + orThrow && + !isAbsenceErrno(e) && + code !== 'EACCES' && + code !== 'EROFS' + ) { + throw e + } return false } } @@ -469,7 +503,7 @@ const idOf = (file: string): string => file.slice(0, file.lastIndexOf('.')) /** * The manifest at `file`, which is its own name or its claim's, or `undefined` * where nothing is there. Throws like {@link readOwnFile}, and {@link NotOurs} - * for what is not a manifest of this version. + * for what does not hold a manifest's fields. */ function readManifest(file: string): Manifest | undefined { const text = readOwnFile(file, MANIFEST_MAX_BYTES) @@ -484,9 +518,7 @@ function readManifest(file: string): Manifest | undefined { } const parsed = ManifestSchema.safeParse(json) if (!parsed.success) { - throw new NotOurs( - `${file} is not a manifest of version ${MANIFEST_VERSION}`, - ) + throw new NotOurs(`${file} is not a manifest this version can read`) } const id = idOf(file) return { @@ -500,9 +532,15 @@ function readManifest(file: string): Manifest | undefined { /** * Whether a process on the started record is running, or `undefined` with no - * record. A record that cannot be read, is not the user's small regular file, - * or is not whole /proc/PID/stat lines vouches: only a process that is gone - * says its sandbox has ended. + * whole line on record. A record that cannot be read, is not the user's small + * regular file, or holds a line that is no /proc/PID/stat line vouches: only a + * process that is gone says its sandbox has ended. + * + * What follows the last newline counts as not written, an empty record + * included. The command line execs bubblewrap only once its whole line is + * written, so no sandbox is behind it yet, and one that follows is refused its + * start by a claim like any that has not put itself on record. Taking it for a + * running sandbox would keep what a shell killed there named for ever. */ function recordVouches(record: string): boolean | undefined { let lines: string[] @@ -511,13 +549,12 @@ function recordVouches(record: string): boolean | undefined { if (text === undefined) { return undefined } - lines = text.split('\n') + lines = text.split('\n').slice(0, -1) } catch { return true } - // Empty between the command line's shell making it and writing to it. - if (lines.pop() !== '' || lines.length === 0) { - return true + if (lines.length === 0) { + return undefined } return lines.some(line => { const pid = /^(\d+) \(/.exec(line)?.[1] @@ -554,16 +591,24 @@ function isLive(manifest: Manifest): boolean { /** * What became of a path a pass set out to remove: `left` when it is gone or no - * longer what bubblewrap or this library made, `failed` when it is still that - * and could not be removed from here. + * longer what bubblewrap or this library made, `occupied` when it is a directory + * with something in it, `failed` when it is still a mount point and could not be + * removed from here. */ -type Removal = 'removed' | 'left' | 'failed' +type Removal = 'removed' | 'left' | 'occupied' | 'failed' -/** Whether a failed removal means the path is no longer ours to remove. */ -function meansNothingToRemove(e: unknown): boolean { +/** What a removal that threw says of the path. */ +function afterFailedRemoval(e: unknown, what: string): Removal { const code = (e as NodeJS.ErrnoException | undefined)?.code - // Not empty after all: something was written into it meanwhile. - return isAbsenceErrno(e) || code === 'ENOTEMPTY' || code === 'EEXIST' + // Not empty after all: something was put into it meanwhile. + if (code === 'ENOTEMPTY' || code === 'EEXIST') { + return 'occupied' + } + if (isAbsenceErrno(e)) { + return 'left' + } + logForDebugging(`[Sandbox Linux] Could not remove ${what} (${String(e)})`) + return 'failed' } /** @@ -591,48 +636,38 @@ function removeMountSource(source: string): Removal { ) return 'removed' } catch (e) { - if (meansNothingToRemove(e)) { - return 'left' - } - logForDebugging( - `[Sandbox Linux] Could not remove the empty-directory mount source (${String(e)}): ${source}`, - ) - return 'failed' + return afterFailedRemoval(e, `the empty-directory mount source ${source}`) } } -/** - * Whether `p` still has the shape of a file bubblewrap made to bind onto: a - * regular file (not a link), empty, with no write bit, under one name, and - * ours. A manifest is only a claim about a path, so nothing is removed on its - * word that does not also look like what bubblewrap leaves. - */ -export function isBwrapFileMountPoint(p: string): boolean { - try { - const stat = fs.lstatSync(p) - return ( - stat.isFile() && - stat.size === 0 && - (stat.mode & 0o222) === 0 && - stat.nlink === 1 && - stat.uid === process.getuid?.() - ) - } catch { - return false +/** {@link kindOfMountPoint}, throwing what the look at `p` throws. */ +function shapeOf(p: string): 'file' | 'directory' | undefined { + const stat = fs.lstatSync(p) + if (stat.uid !== process.getuid?.()) { + return undefined } + if (stat.isDirectory()) { + return 'directory' + } + return stat.isFile() && stat.size === 0 && stat.nlink === 1 + ? 'file' + : undefined } /** - * What kind of mount point the path a live manifest names still is, by what is - * there now: an empty regular file, a directory, or neither any more. + * What kind of mount point `p` can still be, by what is there now: an empty + * regular file (not a link) under one name, or a directory, and the user's; + * `undefined` for anything else. A manifest is only a claim about a path, so + * nothing is covered, spared or removed on its word that does not look like + * what bubblewrap leaves; and the look proves nothing without a manifest. + * + * Not by its mode: bubblewrap makes the file 0444 from 0.5.0 and 0666 less the + * umask before, and one taken for the user's own on its write bits is never + * removed. */ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { try { - const stat = fs.lstatSync(p) - if (stat.isFile() && stat.size === 0) { - return 'file' - } - return stat.isDirectory() ? 'directory' : undefined + return shapeOf(p) } catch { return undefined } @@ -644,41 +679,29 @@ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { */ function removeMountPoint(mountPoint: string): Removal { try { - if (isBwrapFileMountPoint(mountPoint)) { - fs.unlinkSync(mountPoint) + const kind = shapeOf(mountPoint) + if (kind === undefined) { logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, + `[Sandbox Linux] Left a path a manifest names where it is, it no longer looks like a bwrap mount point: ${mountPoint}`, ) - return 'removed' + return 'left' } - const stat = fs.lstatSync(mountPoint) - if (stat.isDirectory() && stat.uid === process.getuid?.()) { + if (kind === 'file') { + fs.unlinkSync(mountPoint) + } else { if (fs.readdirSync(mountPoint).length > 0) { - logForDebugging( - `[Sandbox Linux] Left a bwrap mount point directory behind, something has written into it: ${mountPoint}`, - ) - return 'left' + return 'occupied' } // rmdir, not a recursive remove: it neither follows a symlink nor // descends, so a path that is no longer ours is left exactly as found. fs.rmdirSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`, - ) - return 'removed' } logForDebugging( - `[Sandbox Linux] Left a path a manifest names where it is, it no longer looks like a bwrap mount point: ${mountPoint}`, + `[Sandbox Linux] Cleaned up bwrap mount point (${kind}): ${mountPoint}`, ) - return 'left' + return 'removed' } catch (e) { - if (meansNothingToRemove(e)) { - return 'left' - } - logForDebugging( - `[Sandbox Linux] Could not remove a bwrap mount point (${String(e)}): ${mountPoint}`, - ) - return 'failed' + return afterFailedRemoval(e, `the bwrap mount point ${mountPoint}`) } } @@ -698,13 +721,15 @@ export type MountPointManifest = { * * `undefined` when there is nothing to record or no manifest could be written. * What could not be recorded is kept in memory and removed at this process's - * own clean-up, since no other process will know of it. + * own clean-up, since no other process will know of it. `too-large`, with + * nothing written or kept, for one its own reader would refuse: every pass of + * every process would turn back at it. */ export function publishMountPointManifest( mountPoints: readonly string[], sources: readonly string[], commandKey?: string, -): MountPointManifest | undefined { +): MountPointManifest | 'too-large' | undefined { if (!onLinux() || (mountPoints.length === 0 && sources.length === 0)) { return undefined } @@ -718,17 +743,20 @@ export function publishMountPointManifest( const temporary = `${file}${TEMPORARY_SUFFIX}` const body: z.infer = { version: MANIFEST_VERSION, - pid: process.pid, - start: processStartTime(process.pid) ?? '', + ...ownProcess(), ns: ownPidNamespace(), created: Date.now(), paths: [...new Set(mountPoints)], sources: [...new Set(sources)], } + const text = JSON.stringify(body) + if (Buffer.byteLength(text) > MANIFEST_MAX_BYTES) { + return 'too-large' + } // Written beside the manifest and renamed onto it, so that it is read whole // or not at all. try { - fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) + fs.writeFileSync(temporary, text, { mode: 0o600 }) fs.renameSync(temporary, file) ownManifests.set(file, { commandKey, over: false }) return { dir, file, started: `${id}${STARTED_SUFFIX}` } @@ -787,26 +815,40 @@ type Reading = { * Reads the manifests in `dir`, every one that the listing showed. * * A pass renames a manifest to claim it and to give it back, which can hide it - * from a listing under way and from the open that follows one. So a listed - * manifest that is gone when opened starts the reading over, and a started - * record on which a process runs, with no manifest listed beside it, puts the - * reading in doubt: a record is never renamed. + * from a listing under way and from the open that follows one, and one published + * while the directory is listed can be missed with its record shown. A record is + * never renamed. So the reading starts over for a listed manifest that is gone + * when opened, and for a record with no manifest listed beside it whose manifest + * is there when asked for by name. * - * So does whatever is at a manifest's name and cannot be read: always where - * that is an error (a sandboxed command can use up what this user may open), - * and where it is not a manifest of this version, while a process on its record - * runs or it is younger than {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. + * A record with no manifest at either name names no path and protects none: it + * puts the reading in doubt only while a process on it runs, and one with no + * whole line is dropped past {@link MANIFEST_GRACE_MS} (its shell may be about + * to write to it). + * + * Whatever is at a manifest's name and cannot be read puts the reading in doubt + * too: always where that is an error (a sandboxed command can use up what this + * user may open), and where it is not a manifest of this version, while a + * process on its record runs or it is younger than {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ function readManifests(dir: string): Reading { - const old = (file: string): boolean => { + const olderThan = (file: string, ms: number): boolean => { try { - return ( - Date.now() - fs.lstatSync(file).mtimeMs > UNREADABLE_MANIFEST_MAX_AGE_MS - ) + return Date.now() - fs.lstatSync(file).mtimeMs > ms } catch { return false } } + // What cannot be asked after counts as there. + const isThere = (file: string): boolean => { + try { + fs.lstatSync(file) + return true + } catch (e) { + return !isAbsenceErrno(e) + } + } for (let attempt = 1; ; attempt++) { let names: string[] try { @@ -831,7 +873,7 @@ function readManifests(dir: string): Reading { if ( e instanceof NotOurs && recordVouches(`${idOf(file)}${STARTED_SUFFIX}`) !== true && - old(file) + olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS) ) { reading.spent.push(file) } else { @@ -839,85 +881,118 @@ function readManifests(dir: string): Reading { } } } - if (moved && attempt < LISTING_ATTEMPTS) { - continue - } - if (moved) { - reading.inDoubt ??= `${dir} kept changing` - } for (const name of names) { const file = path.join(dir, name) if (name.endsWith(TEMPORARY_SUFFIX)) { // Left by a process killed between writing and moving into place. - if (old(file)) reading.spent.push(file) + if (olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS)) { + reading.spent.push(file) + } } else if (name.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { - if (recordVouches(file) === true) { + // Asked for at its own name first, and the record read last. Only a + // claim given back can then pass between the two names unseen, and a + // pass gives back only once it has found a process on the record, + // which the read below finds too. + if ( + isThere(`${idOf(file)}${MANIFEST_SUFFIX}`) || + isThere(`${idOf(file)}${CLAIMED_SUFFIX}`) + ) { + moved = true + continue + } + const vouched = recordVouches(file) + if (vouched === true) { reading.inDoubt ??= `${file} has no manifest` - } else { + } else if (vouched === false || olderThan(file, MANIFEST_GRACE_MS)) { reading.spent.push(file) } } } + if (moved && attempt < LISTING_ATTEMPTS) { + continue + } + if (moved) { + reading.inDoubt ??= `${dir} kept changing` + } return reading } } +/** What the manifests name, on one reading, and what that reading listed. */ +export type NamedMountPoints = { + paths: ReadonlySet + listed: ReadonlySet +} + /** - * The mount points the manifests name, on one reading of the directory: `live` - * by a manifest whose sandbox may still be running, `named` by any manifest, a - * claimed one included. What only a finished manifest names is a leftover the - * next pass removes. + * The mount points that any manifest names, a finished or claimed one included + * (what only those name is a leftover the next pass removes), and what this + * process could record nowhere. Makes nothing on the host. * - * With no directory, or none that can be read, both hold only what this process - * could record nowhere: a wrap takes a path for a mount point on a manifest's - * word only, so it then takes every other existing path for the caller's own. + * `undefined` where the reading is in doubt: a path it does not name may then be + * a running sandbox's mount point all the same, and a wrap that took it for the + * caller's own would not name it and would lose it under its own sandbox. + * + * `earlier` is a reading this call may answer with. A manifest is never + * rewritten, so it still holds when the directory lists nothing it did not, + * `own` (a manifest the caller has published since) apart. */ -export function namedMountPoints(): { live: Set; named: Set } { - const live = new Set(unrecorded.paths) - const named = new Set(unrecorded.paths) - const dir = onLinux() ? ensureManifestDirectory() : undefined - for (const manifest of dir === undefined - ? [] - : readManifests(dir).manifests) { - const isLiveOne = isLive(manifest) - for (const mountPoint of manifest.paths) { - named.add(mountPoint) - if (isLiveOne) { - live.add(mountPoint) +export function namedMountPoints( + earlier?: NamedMountPoints, + own?: string, +): NamedMountPoints | undefined { + try { + const dir = onLinux() ? existingManifestDirectory() : undefined + if (dir === undefined) { + return { paths: new Set(unrecorded.paths), listed: new Set() } + } + if ( + earlier !== undefined && + fs + .readdirSync(dir) + .every(n => earlier.listed.has(n) || path.join(dir, n) === own) + ) { + return earlier + } + const reading = readManifests(dir) + if (reading.inDoubt === undefined) { + return { + paths: new Set([ + ...unrecorded.paths, + ...reading.manifests.flatMap(manifest => manifest.paths), + ]), + listed: new Set(reading.names), } } + } catch { + // The directory cannot be looked at, or listed. } - return { live, named } + return undefined } /** * The directory this process keeps its manifests in, if it exists already. * Makes and settles nothing: for a look that must leave the host as found. + * Throws where it cannot tell. */ function existingManifestDirectory(): string | undefined { - return manifestDirectory !== undefined && - isOurPrivateDirectory(manifestDirectory) - ? manifestDirectory - : sharedManifestDirectoryNames().find(isOurPrivateDirectory) + return [manifestDirectory, ...sharedManifestDirectoryNames()].find( + dir => dir !== undefined && isOurPrivateDirectory(dir, true), + ) } /** * Whether `p` is still something a clean-up would remove on a manifest's word: - * the empty file bubblewrap made, or an empty directory of the user's. + * an empty file or an empty directory. One that cannot be looked at may be. */ function isEmptyMountPoint(p: string): boolean { - if (isBwrapFileMountPoint(p)) { - return true - } try { - const stat = fs.lstatSync(p) - return ( - stat.isDirectory() && - stat.uid === process.getuid?.() && - fs.readdirSync(p).length === 0 - ) - } catch { - return false + const kind = shapeOf(p) + return kind === 'directory' + ? fs.readdirSync(p).length === 0 + : kind === 'file' + } catch (e) { + return !isAbsenceErrno(e) } } @@ -927,38 +1002,49 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command: removing a mount point from under a running * sandbox lifts the deny there. * + * `undefined` when it cannot tell: the manifest directory, its listing, a + * manifest or a record without a manifest cannot be read or accounted for, so a + * sandbox may be running on paths that cannot be listed. The caller must then + * skip every removal of an empty file or an empty directory. It does not throw. + * * A snapshot that may be out of date by the time the caller acts. Its only safe * use is to SKIP a removal; a path not being in it never means the path is free * to write. * * A path is in the set only when both hold: * - * - a manifest names it, and that manifest is live by the clean-up's rule, or - * something in the directory cannot be read and may have a sandbox under it, - * in which case every named path counts; - * - what is at the path now is still an empty placeholder: an empty regular - * file with no write bit and one link, or an empty directory, and the user's. - * So a stale or forged manifest never makes a caller spare a file with - * content. + * - a manifest names it that is live by the clean-up's rule, or this process + * could record it nowhere; + * - what is at the path now is still an empty placeholder, or cannot be looked + * at: an empty regular file of any mode with one link, or an empty directory, + * and the user's. So a stale or forged manifest never makes a caller spare a + * file with content. * * Paths are as the wraps recorded them: absolute, links above the last * component resolved. It answers for one manifest directory, the one this * process uses or, before its first wrap, the first shared name that exists, so * a sandbox whose process keeps manifests elsewhere is not seen. Makes nothing - * on the host. Empty off Linux and where no manifest directory exists. + * on the host. Empty off Linux. */ -export function liveMountPoints(): ReadonlySet { +export function liveMountPoints(): ReadonlySet | undefined { const spared = new Set() if (!onLinux()) { return spared } - const dir = existingManifestDirectory() - const reading = dir === undefined ? undefined : readManifests(dir) const named = new Set(unrecorded.paths) - for (const manifest of reading?.manifests ?? []) { - if (reading?.inDoubt !== undefined || isLive(manifest)) { - for (const mountPoint of manifest.paths) named.add(mountPoint) + try { + const dir = existingManifestDirectory() + const reading = dir === undefined ? undefined : readManifests(dir) + if (reading?.inDoubt !== undefined) { + return undefined } + for (const manifest of reading?.manifests ?? []) { + if (isLive(manifest)) { + for (const mountPoint of manifest.paths) named.add(mountPoint) + } + } + } catch { + return undefined } for (const mountPoint of named) { if (isEmptyMountPoint(mountPoint)) { @@ -996,7 +1082,7 @@ export function collectMountPoints( } const removed: string[] = [] if (release === 'all') { - for (const mountPoint of unrecorded.paths) { + for (const mountPoint of childrenFirst(unrecorded.paths)) { if (removeMountPoint(mountPoint) === 'removed') removed.push(mountPoint) } for (const source of unrecorded.sources) { @@ -1008,6 +1094,10 @@ export function collectMountPoints( return removed } +/** A path sorts after the directories it is in: a directory goes empty. */ +const childrenFirst = (paths: Iterable): string[] => + [...paths].sort().reverse() + /** * One pass over a manifest directory. It takes no lock and waits for nothing; * any number may run at once, over the same claims. @@ -1029,10 +1119,14 @@ export function collectMountPoints( * LISTS_BEFORE_EACH_REMOVAL_UP_TO} candidates, whenever the listing is {@link * LISTING_GOOD_FOR_MS} old), and what has come to its name keeps what it names, * so the pass would have to be held up between a listing and the removal that - * follows it for as long as a sandbox takes to start. + * follows it for as long as a sandbox takes to start (for a directory a wrap + * only relies on: until that wrap, having published, has looked at it again, + * and its command is then refused its start). * * A manifest is all that names its mount points, so its claim is dropped last, - * and stays, for a later pass, when the pass turns back or a removal is refused. + * and stays, for a later pass, when the pass turns back, a removal is refused, + * or a directory holds what another manifest names. Only content that no + * manifest names makes a directory nobody's. */ function collect(dir: string, unrecordedToo: boolean): string[] { const before = readManifests(dir) @@ -1131,7 +1225,7 @@ function collect(dir: string, unrecordedToo: boolean): string[] { const removed: string[] = [] const notRemoved = new Set() - for (const [candidate, isSource] of candidates) { + for (const candidate of childrenFirst(candidates.keys())) { if ( performance.now() - listedAt >= goodFor && !newcomersKeepWhatTheyName() @@ -1141,12 +1235,16 @@ function collect(dir: string, unrecordedToo: boolean): string[] { if (kept.has(candidate)) { continue } - const outcome = isSource + const outcome = candidates.get(candidate) ? removeMountSource(candidate) : removeMountPoint(candidate) if (outcome === 'removed') { removed.push(candidate) - } else if (outcome === 'failed') { + } else if ( + outcome === 'failed' || + (outcome === 'occupied' && + [...kept, ...notRemoved].some(p => p.startsWith(`${candidate}/`))) + ) { notRemoved.add(candidate) } } @@ -1173,11 +1271,13 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } /** - * Forget which directory the manifests are kept in, so the next use works it - * out again from the environment. Test seam: a test gives itself a runtime - * directory of its own after this module may have settled on the real one. + * Forget which directory the manifests are kept in, and which PID namespace + * this is, so the next use works them out again. Test seam: a test gives itself + * a runtime directory of its own after this module may have settled on the real + * one. */ export function forgetMountPointManifestDirectory(): void { + pidNamespace = null manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 558526183..3b49e2550 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -11,13 +11,13 @@ import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, - isBwrapFileMountPoint, kindOfMountPoint, mountPointManifestDirectories, namedMountPoints, publishMountPointManifest, removePrivateManifestDirectory, type MountPointManifest, + type NamedMountPoints, type OwnManifestRelease, } from './bwrap-mount-manifests.js' import { buildJavaToolOptions } from './java-proxy-agent.js' @@ -731,11 +731,26 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** More mount points than one manifest holds. */ + | 'too_many_mount_points' + /** + * The mount point manifests cannot be read, so a path cannot be told from a + * mount point a running sandbox relies on. Passing where this process is out + * of descriptors; lasting while something that is no manifest sits among them. + */ + | 'mount_points_unreadable' + /** + * Other sandboxes' mount points kept being made or removed under what the + * profile relies on, each time the wrap planned it. Passing: wrap again. + */ + | 'mount_points_changed' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the * configuration expands to is past a limit, or, for `command_too_long` and - * `nul_in_path`, what the caller passed in is. The command was not run and no + * `nul_in_path`, what the caller passed in is, or, for the two + * `mount_points_` codes, other sandboxes' mount points cannot be told or do + * not hold still. The command was not run and no * profile file stays open; the wrap has already let go of the mount points it * had named and is not counted among the commands to clean up after. So do not * make the per-command cleanup call (`cleanupAfterCommand()`, @@ -770,6 +785,31 @@ export class LinuxSandboxProfileError extends Error { } } +let stepShell: string[] | undefined + +/** + * The shell the returned string runs its own steps in, before bubblewrap: found + * once, bash at one of its two usual places, else /bin/sh. + * + * bash, because where /bin/sh is dash the environment is rebuilt from the + * shell's variables, and every entry whose name is no identifier (`a.b`, + * `INPUT_GITHUB-TOKEN`, an exported function) is lost to the command. + * + * THREAT: this shell runs on the host, unsandboxed, in an environment a + * sandboxed command may have had a hand in. So `-p`: plain `bash -c` imports + * exported functions, and one named `shift`, `read`, `printf` or `exec` would + * run in place of the step's own word; it also reads the file `$BASH_ENV` + * names. And never a PATH look-up or the caller's `binShell`: a copy planted in + * a writable PATH directory would be run out here. + */ +function shellForSteps(): string[] { + if (stepShell === undefined) { + const bash = ['/bin/bash', '/usr/bin/bash'].find(isExecutable) + stepShell = bash === undefined ? ['/bin/sh', '-c'] : [bash, '-p', '-c'] + } + return stepShell +} + /** * The shell string that runs bwrap with `bwrapArgs`, which the caller runs * as one argument of `sh -c`. When that would not fit the kernel's @@ -781,10 +821,13 @@ export class LinuxSandboxProfileError extends Error { * so a prefix (`exec`, `timeout 30`) or a suffix (`&& next`) still composes. * * With `started`, the record of a wrap that named mount points, the string is - * a shell that appends its own /proc/$$/stat line to that record and then execs - * bwrap in its place: the pid on record is bwrap's, written before bwrap has - * made a mount. Appended, not written over, so that every run of the string is - * on record while it lasts. One shell does both where both are needed. + * a shell that appends its own /proc/self/stat line to that record and then + * execs bwrap in its place: the pid on record is bwrap's, written before bwrap + * has made a mount. Read by the shell itself, a redirection on a builtin: a + * child's line would be that of a process gone at once. Not /proc/$$: under an + * outer /proc `$$` is nobody there, or an unrelated process. Appended, not + * written over, so that every run of the string is on record while it lasts. + * One shell does both where both are needed. * Throws {@link LinuxSandboxProfileError} when the profile cannot run. */ function renderBwrapInvocation( @@ -800,7 +843,7 @@ function renderBwrapInvocation( ? [] : [ { - run: 'read -r s >"$1"', + run: 'read -r s >"$1"', file: started, }, ] @@ -809,8 +852,7 @@ function renderBwrapInvocation( steps.length === 0 ? quote(words) : quote([ - '/bin/sh', - '-c', + ...shellForSteps(), [...steps.map(step => `${step.run} && shift`), 'exec "$@"'].join( ' && ', ), @@ -858,7 +900,7 @@ function renderBwrapInvocation( error, ) } - // /bin/sh opens the profile on the fd and execs bwrap, which reads it to + // The shell opens the profile on the fd and execs bwrap, which reads it to // EOF and closes it before running the command. const viaArgsFile = afterSteps( [ @@ -896,8 +938,8 @@ let exitHandlerRegistered = false /** * Wraps handed to the caller and not yet cleaned up after. While above zero, * some command of this process may not have started, and its manifest and - * --args profile must still be there when it does; so nothing of this process's - * own is released unless the caller names the command. + * --args profile must still be there when it does; so no manifest of a command + * that has not started is released unless the caller names the command. */ let activeSandboxCount = 0 @@ -927,21 +969,22 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called on process exit as a safety net. * - * What it releases of what THIS process made: + * A command that has run is judged by its own record: every call, in any + * process, collects what it relied on once it has ended, whatever else is + * outstanding. What only this process can say is that a command which has NOT + * started is over, and what it releases of those: * - * - with no `commandId`: nothing until it has been called once for every wrap - * handed out, since nothing can tell which command the call is for. One - * long-running command therefore holds back the clean-up of those that - * finished beside it; - * - with the `commandId` its wrap was given: what that command relied on, at - * once; - * - with `force`: everything, because the process or session is ending. + * - with no `commandId`: none until it has been called once for every wrap + * handed out, since nothing can tell which command the call is for; + * - with the `commandId` its wrap was given: that command's, at once; + * - with `force`: all, because the process or session is ending. * - * A mount point goes only when no running sandbox relies on it, which each - * sandbox's own process answers (see bwrap-mount-manifests.ts). A call too many - * takes nothing from a running sandbox, but counts for another wrap of this - * process, whose command is then refused its start if it has not started by the - * time the count reaches zero. Does nothing except on Linux. + * The --args profiles go when none is outstanding. A mount point goes only when + * no running sandbox relies on it, which each sandbox's own process answers + * (see bwrap-mount-manifests.ts). A call too many takes nothing from a running + * sandbox, but counts for another wrap of this process, whose command is then + * refused its start if it has not started by the time the count reaches zero. + * Does nothing except on Linux. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean @@ -1646,7 +1689,15 @@ function pushReadDenyDirMounts( } /** - * Generate filesystem bind mount arguments for bwrap + * How often a wrap plans its mounts before it gives up because what a plan + * relied on had changed by the time its manifest was published. + */ +const MOUNT_PLAN_ATTEMPTS = 3 + +/** + * Generate filesystem bind mount arguments for bwrap. `undefined`, with nothing + * left published, when what the plan relied on on the host had changed by the + * time its manifest was: the caller plans again. */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -1658,20 +1709,37 @@ async function generateFilesystemArgs( allowGitConfig = false, abortSignal?: AbortSignal, commandKey?: string, -): Promise<{ - args: string[] - manifest: MountPointManifest | undefined - /** Where the manifest's started record is, as the binds spell its directory. */ - started: string | undefined -}> { +): Promise< + | { + args: string[] + /** How many of them, from the first, have to stay on the command line. */ + onTheLine: number + manifest: MountPointManifest | undefined + /** Where the manifest's started record is, as the binds spell its directory. */ + started: string | undefined + } + | undefined +> { const args: string[] = [] // fs already imported // The mount points on the host this wrap relies on: placeholders bwrap makes // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // What the manifests name, read once per wrap. - let namedMountPointsCache: ReturnType | undefined + // What the manifests name, as of the last time it was asked. + let named: NamedMountPoints | undefined + const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { + named = namedMountPoints(named, ownManifest) + if (named === undefined) { + // Not "treat what is there as named": naming what may be the caller's + // own makes it removable, from under a sandbox that bound it as that. + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + 'The mount point manifests cannot be read, so what is at a denied path cannot be told from a mount point a running sandbox relies on', + ) + } + return named.paths + } // Collect normalized allowed write paths. Populated in the writeConfig // block, read again in the denyRead loop to re-bind writes under tmpfs. @@ -2399,54 +2467,34 @@ async function generateFilesystemArgs( // name: bound onto itself it would not be named in this wrap's manifest // and would go, with this sandbox still bound over it, when its maker is // cleaned up after. Only a manifest says that a path is one; what no - // manifest names is the caller's own and is never removed. For a path a - // manifest names: - // - // - a FILE with the shape bubblewrap leaves (or, under a live manifest, - // any empty regular file) is covered with /dev/null and named; - // - a DIRECTORY is named and then bound onto itself read-only further - // down, since /dev/null cannot be bound over a directory; - // - anything else is the caller's by now. + // manifest names is the caller's own and is never removed. An empty FILE + // that a manifest names is covered with /dev/null and named here. A + // DIRECTORY is bound onto itself further down, since /dev/null cannot be + // bound over one, and is named with the other directories this profile + // relies on, where the manifest is published. // // The shape is asked before the existence, and once more when the two // disagree: another process can remove or make a mount point at any // moment. - let hasFileShape = isBwrapFileMountPoint(normalizedPath) + let isEmptyFile = kindOfMountPoint(normalizedPath) === 'file' // Asked once for the branches below. - let pathExists = hasFileShape || fs.existsSync(normalizedPath) - if (pathExists && !hasFileShape) { - hasFileShape = isBwrapFileMountPoint(normalizedPath) + let pathExists = isEmptyFile || fs.existsSync(normalizedPath) + if (pathExists && !isEmptyFile) { + isEmptyFile = kindOfMountPoint(normalizedPath) === 'file' } if ( - isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || - isWithinAnyAllowedWritePath(normalizedPath) + isEmptyFile && + (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || + isWithinAnyAllowedWritePath(normalizedPath)) ) { // A reading that names the path is believed whenever it was taken. One - // that does not name a file of that shape is believed only if taken - // after the file was seen: a sandbox publishes its manifest before - // bubblewrap makes the file, so an older reading would miss a sandbox - // that started since. - let named = pathExists ? namedMountPointsCache : undefined + // that does not is believed only if taken after the file was seen: a + // sandbox publishes its manifest before bubblewrap makes the file, so + // an older reading would miss a sandbox that started since. if ( - pathExists && - (named === undefined || - (hasFileShape && !named.named.has(normalizedPath))) + named?.paths.has(normalizedPath) || + whatIsNamedNow().has(normalizedPath) ) { - named = namedMountPointsCache = namedMountPoints() - if (hasFileShape && !named.named.has(normalizedPath)) { - pathExists = fs.existsSync(normalizedPath) - } - } - const kind = !named?.named.has(normalizedPath) - ? undefined - : hasFileShape - ? 'file' - : named.live.has(normalizedPath) - ? kindOfMountPoint(normalizedPath) - : kindOfMountPoint(normalizedPath) === 'directory' - ? 'directory' - : undefined - if (kind === 'file') { if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) @@ -2457,12 +2505,7 @@ async function generateFilesystemArgs( ) continue } - if (kind === 'directory') { - mountPoints.push(normalizedPath) - logForDebugging( - `[Sandbox Linux] Keeping a directory mount point another sandbox made for as long as this one runs: ${normalizedPath}`, - ) - } + pathExists = fs.existsSync(normalizedPath) } // Handle non-existent paths by mounting /dev/null to block creation. @@ -2962,9 +3005,42 @@ async function generateFilesystemArgs( } } + // What on the host the profile takes to be there: what it binds from or + // mounts on, and the directory each of its mount points is made in, which + // bubblewrap would make again, unpinned and named by nothing, if it were gone. + // Not for a wrap that restricts no write: it has no mount point of its own, + // and the manifests are not kept out of its command's reach either. + const madeIn = new Set(mountPoints.map(made => path.dirname(made))) + const boundFrom = new Set() + if (writeConfig !== undefined) { + args.forEach((word, i) => { + if (word === '--bind' || word === '--ro-bind' || word === '--tmpfs') { + boundFrom.add(args[i + 1]!) + } + }) + } + // Those of them, and the directories above them, that a manifest names: + // another sandbox's mount points, which go when no live manifest names them. + const mountPointsReliedOn = (ownManifest?: string): string[] => { + const found = new Set() + const namedNow = boundFrom.size > 0 ? whatIsNamedNow(ownManifest) : found + for (const relied of namedNow.size > 0 ? [...madeIn, ...boundFrom] : []) { + for (let dir = relied; dir !== '/'; dir = path.dirname(dir)) { + if (namedNow.has(dir)) found.add(dir) + } + } + return [...found].filter(dir => kindOfMountPoint(dir) === 'directory') + } + // Name the mount points this wrap relies on before the sandbox can start. // Those that could be recorded nowhere are this process's to remove. // + // INVARIANT: the manifest names every path the profile relies on that a + // clean-up could remove, its own mount points and other sandboxes' alike, on + // a reading taken after the last look at the host above. A directory it left + // out (the one a placeholder is nested in, a pin) goes with its maker, and the + // command is then refused its start or, once the pin is mounted, loses it. + // // INVARIANT: a manifest directory must never be writable from inside a // sandbox. A command that could delete a manifest could make a running // sandbox's mount points look unused, and one that could write a manifest @@ -2978,25 +3054,66 @@ async function generateFilesystemArgs( // Each is bound where it really is, not at the name the environment gave: // bubblewrap makes a bind's destination by name inside the new root, and a // link with an absolute target on the way leads nowhere from there. + const names = [...mountPoints, ...mountPointsReliedOn()] const manifest = publishMountPointManifest( - mountPoints, + names, emptySource === undefined ? [] : [emptySource], commandKey, ) + if (manifest === 'too-large') { + throw new LinuxSandboxProfileError( + 'too_many_mount_points', + `Sandbox profile relies on ${names.length} mount points, more than one manifest holds; reduce the write denies on paths that do not exist`, + ) + } + // From here a clean-up keeps what the manifest names. Until here any of it + // could go, or be made again by a sandbox that no reading above knew of. So + // the wrap looks again, this once, and plans afresh if anything is amiss. + // What cannot be looked at counts as there: bubblewrap does not start on what + // it cannot bind. + const isThere = (relied: string, asDirectory: boolean): boolean => { + try { + return fs.statSync(relied).isDirectory() || !asDirectory + } catch (err) { + return !isAbsenceErrno(err) + } + } + let holds = false + try { + holds = + [...boundFrom].every(relied => isThere(relied, false)) && + [...madeIn].every(relied => isThere(relied, true)) && + mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && + // Else settled on since the pins were made: nothing is pinned above it. + (manifest === undefined || manifestDirs.includes(manifest.dir)) + } finally { + if (!holds && manifest !== undefined) { + discardMountPointManifest(manifest.file) + } + } + if (!holds) { + return undefined + } let started: string | undefined + let onTheLine = 0 if (manifest !== undefined) { const dir = canonicalForm(manifest.dir) const file = path.join(dir, path.basename(manifest.file)) started = path.join(dir, path.basename(manifest.started)) - // INVARIANT: the manifest is the first thing bound after the root. A - // command started once a clean-up has claimed its manifest then fails - // ("Can't find source path") before bubblewrap has made a mount point. - // Bound any later, a claim that lands while bubblewrap is at its mounts - // would leave on the host mount points that nothing names or removes. + // INVARIANT: the manifest is the first thing bound after the root, and its + // bind is on the command line, not in an --args file. bubblewrap looks up + // every bind source before it makes anything, so a command started once a + // clean-up has claimed its manifest fails there ("Can't find source path") + // wherever the bind stands. First is for the claim that lands after that + // look: bubblewrap then fails at this bind ("Can't get type of source"), + // and bound any later it would by then have made mount points on the host + // that nothing names or removes. On the line, because the --args file is + // opened by a descriptor number that another wrap's file may have by then. args.splice(afterRootBind, 0, '--ro-bind', file, file) + onTheLine = afterRootBind + 3 args.push('--ro-bind', dir, dir) } - if (mountPoints.length > 0 || emptySource !== undefined) { + if (names.length > 0 || emptySource !== undefined) { registerExitCleanupHandler() } if (writeConfig !== undefined) { @@ -3038,7 +3155,7 @@ async function generateFilesystemArgs( args.push('--ro-bind', dir, dir) } - return { args, manifest, started } + return { args, onTheLine, manifest, started } } /** @@ -3325,25 +3442,36 @@ export async function wrapCommandWithSandboxLinux( } // ========== FILESYSTEM RESTRICTIONS ========== - const filesystem = await generateFilesystemArgs( - readConfig, - writeConfig, - maskedFileBinds, - maskedFileStoreDir, - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - // Only an id the caller chose: the attribution key falls back to the - // command text, which two wraps in flight may share. - commandId, - ) + let filesystem: Awaited> + for (let left = MOUNT_PLAN_ATTEMPTS; filesystem === undefined; left--) { + if (left === 0) { + throw new LinuxSandboxProfileError( + 'mount_points_changed', + `What the sandbox profile relies on on the host changed under each of ${MOUNT_PLAN_ATTEMPTS} plans: other sandboxes' mount points are being made and removed there`, + ) + } + filesystem = await generateFilesystemArgs( + readConfig, + writeConfig, + maskedFileBinds, + maskedFileStoreDir, + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + // Only an id the caller chose: the attribution key falls back to the + // command text, which two wraps in flight may share. + commandId, + ) + } // Held for the catch below: a wrap that produces no command has to let go // of the mount points it named. manifest = filesystem.manifest - const mountsStart = bwrapArgs.length + const mounts = { + start: bwrapArgs.length + filesystem.onTheLine, + end: bwrapArgs.length + filesystem.args.length, + } bwrapArgs.push(...filesystem.args) - const mounts = { start: mountsStart, end: bwrapArgs.length } // Always bind /dev bwrapArgs.push('--dev', '/dev') diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 438e3fe0c..2ae5347ab 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1623,10 +1623,12 @@ export type WrapWithSandboxOptions = { * * On Linux it is also the name under which the wrap records the mount * points its command relies on: pass the same value to - * `cleanupAfterCommand({ commandId })` to have them removed as soon as that - * command is over, whatever other commands are still in flight. Only a - * value passed here counts for that; the `command` default does not, since - * two wraps in flight may share their text. + * `cleanupAfterCommand({ commandId })` to let go of them at once when the + * command will never be started, whatever other commands are still in + * flight (a command that ran is let go of by any cleanup once it has + * ended). It has to be unique among the wraps in flight. Only a value passed + * here counts for that; the `command` default does not, since two wraps in + * flight may share their text. */ commandId?: string /** @@ -2077,12 +2079,13 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Call it once for each wrapped command, when that command is over. With no - * argument nothing this process made is removed until it has been called - * once for every wrap handed out, because a call cannot tell which command - * it is for. Pass the `commandId` the wrap was given - * (`WrapWithSandboxOptions.commandId`) to have that command's mount points - * removed at once, which matters when another command runs for a long time. + * Call it once for each wrapped command, when that command is over. Every + * call removes what commands that ran and have ended relied on, this + * process's and other processes'. A command that was wrapped and has not + * started is let go of only once it has been called for every wrap handed + * out, because a call cannot tell which command it is for; pass the + * `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to let + * go of that one at once. * * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. From 0ec2c8ed03b6f5c25241749a5963fe9f007d7b1c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 20:28:08 +0000 Subject: [PATCH 085/141] test: what a wrap relies on, old bubblewrap's mount points, the per-command release The per-command release could be disabled outright with every test passing: its tests ran the first command to completion, so its record alone ended it. They now use a command that never started, with the call that names no command as control. Every test file gets a runtime and a temp directory of its own from a preload, so no suite acts on the user's real manifests. --- bunfig.toml | 4 + test/helpers/bwrap-argv.ts | 14 + test/helpers/private-manifest-directory.ts | 67 +- test/preload.ts | 20 + test/sandbox/linux-bwrap-args-file.test.ts | 54 +- test/sandbox/mount-point-liveness.test.ts | 794 ++++++++++++++++----- test/sandbox/mount-point-manifests.test.ts | 649 ++++++++++++++--- test/sandbox/stale-mount-point.test.ts | 77 +- 8 files changed, 1357 insertions(+), 322 deletions(-) create mode 100644 bunfig.toml create mode 100644 test/preload.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..95adbec07 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +[test] +# Gives the run directories of its own for the mount point manifests, before any +# test file is loaded: see test/preload.ts. +preload = ["./test/preload.ts"] diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 4a1d1a9f1..3fbdd71cc 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { quote } from '../../src/utils/shell-quote.js' /** The $0 renderBwrapInvocation gives the shell that opens an over-long @@ -5,6 +6,19 @@ import { quote } from '../../src/utils/shell-quote.js' * file and not in the command. */ const ARGS_FILE_ARGV0 = 'srt-args' +/** + * How a wrapped command that takes steps of its own before bubblewrap begins, on + * this host: bash where it is at one of its two usual places, else /bin/sh. + */ +export const STEP_SHELL = ((): string => { + const bash = ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) + return bash === undefined ? '/bin/sh -c' : `${bash} -p -c` +})() + +/** The step that puts the command on its manifest's started record. */ +export const RECORD_STEP = + 'read -r s >"$1" && shift' + /** The mount flags this generator emits with a source and a destination. */ const MOUNT_FLAGS = ['--bind', '--ro-bind'] diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 10a57b67e..895061f92 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,10 +1,63 @@ -import { afterAll, beforeAll } from 'bun:test' -import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { afterAll, afterEach, beforeAll } from 'bun:test' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' +/** Field 22 of a /proc/PID/stat line: when that process started. */ +const startOf = (stat: string): string | undefined => + stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19] + +/** + * Kills every bubblewrap that is still on a started record in `dir`, and its + * sandbox with it: a test that failed half way must not leave one polling. Then + * empties `dir`. Only bubblewrap: a test may put any process on a record, itself + * included. + */ +function endSandboxesOnRecord(dir: string): void { + const attempt = (what: () => T): T | undefined => { + try { + return what() + } catch { + // No directory, no record, or the process is gone: nothing to end. + return undefined + } + } + for (const name of attempt(() => readdirSync(dir)) ?? []) { + const file = join(dir, name) + // A test may plant a FIFO there, and reading one waits for a writer. + if ( + !name.endsWith('.started') || + !attempt(() => lstatSync(file).isFile()) + ) { + continue + } + const record = attempt(() => readFileSync(file, 'utf8')) ?? '' + for (const line of record.split('\n')) { + const pid = /^\d+/.exec(line)?.[0] + const now = attempt(() => readFileSync(`/proc/${pid}/stat`, 'utf8')) + if (now?.includes(' (bwrap) ') && startOf(now) === startOf(line)) { + attempt(() => process.kill(Number(pid), 'SIGKILL')) + } + } + } + for (const name of attempt(() => readdirSync(dir)) ?? []) { + attempt(() => rmSync(join(dir, name), { recursive: true, force: true })) + } +} + /** * Gives the enclosing `describe` a runtime directory and a temp dir of its own, * for as long as its tests run. @@ -19,7 +72,9 @@ import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.j * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so: the library's count of wraps is shared by every test file of a - * run, and other suites wrap without cleaning up. + * run, and other suites wrap without cleaning up. After each test it ends + * whatever sandbox is still on record there and empties the directory, so no + * test finds what an earlier one left. * * Call it inside the `describe` callback. Returns where the manifests go. */ @@ -38,6 +93,12 @@ export function usePrivateManifestDirectory(): { manifestDir(): string } { cleanupBwrapMountPoints({ force: true }) }) + afterEach(() => { + if (base !== undefined) { + endSandboxesOnRecord(join(base, 'XDG_RUNTIME_DIR', 'srt-mount-points')) + } + }) + afterAll(() => { cleanupBwrapMountPoints({ force: true }) replaced.forEach((name, i) => { diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 000000000..05aef7bd8 --- /dev/null +++ b/test/preload.ts @@ -0,0 +1,20 @@ +import { afterAll } from 'bun:test' +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Every wrap that restricts writes makes, binds and collects in the per-user +// directories the mount point manifests are kept in, under $XDG_RUNTIME_DIR and +// the temp dir. The run is given both of its own before any test file is +// loaded, so that no suite acts on what else the user is running. A `describe` +// that lists or attacks the manifests takes its own on top of these (see +// helpers/private-manifest-directory.ts). +if (process.platform === 'linux') { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-run-'))) + for (const name of ['XDG_RUNTIME_DIR', 'TMPDIR']) { + process.env[name] = join(base, name) + mkdirSync(process.env[name], { mode: 0o700 }) + } + // Once, after the last test file. + afterAll(() => rmSync(base, { recursive: true, force: true })) +} diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index cc57c0150..a9db7fa31 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, readlinkSync, realpathSync, rmSync, @@ -19,6 +20,7 @@ import { import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/index.js' import { isLinux } from '../helpers/platform.js' +import { manifestOf, RECORD_STEP, STEP_SHELL } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -61,8 +63,12 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // The one rendered shape: the profile's path, then the options left before // and the words left after `--args 9`. A wrap that named mount points has // the same shell put itself on the manifest's record first. - const VIA_ARGS_FILE = - /^\/bin\/sh -c '(?:read -r s <\/proc\/\$\$\/stat && printf "%s\\n" "\$s" >>"\$1" && shift && )?exec 9<"\$1" && shift && exec "\$@"' srt-args (?:\S+\.started )?(\S+) bwrap (.*?) ?--args 9 (.*)$/s + const literally = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + const VIA_ARGS_FILE = new RegExp( + `^${literally(STEP_SHELL)} '(?:${literally(RECORD_STEP)} && )?exec 9<"\\$1" && shift && exec "\\$@"' srt-args (?:\\S+\\.started )?(\\S+) bwrap (.*?) ?--args 9 (.*)$`, + 's', + ) const MODULE = join( import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts', @@ -407,7 +413,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { console.log(JSON.stringify({ held, afterFirst, afterSecond: openFds() - baseline })) `) expect(seen).toEqual({ held: 2, afterFirst: 2, afterSecond: 0 }) - }) + }, 30000) it('refuses at wrap time, with the reason, when no directory takes an unnamed file', () => { // Both candidates read-only: tmpdir and /dev/shm. A profile that fits @@ -555,7 +561,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { expect(wrapped).toMatch( / srt-args \S+\/srt-mount-points\/\d+-[0-9a-f]{16}\.started \/proc\/\d+\/fd\/\d+ bwrap /, ) - expect(wrapped.match(/\/bin\/sh -c /g)).toHaveLength(1) + expect(wrapped.split(`${STEP_SHELL} `)).toHaveLength(2) const run = spawnSync(`timeout 60 ${wrapped} && echo AFTER`, { shell: true, encoding: 'utf8', @@ -573,4 +579,44 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { }, 60_000, ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'refuses a string run after its clean-up, though its descriptor number now holds the profile of a later wrap', + async () => { + // The manifest's bind is what refuses such a start. In the file, it would + // be the later wrap's manifest that is bound, and the command would run + // under that wrap's profile. + const files = overLongProfile() + const [A, B] = [join(BASE, 'a'), join(BASE, 'b')] + const inArea = (area: string): Promise => { + mkdirSync(area) + return wrap(files, { + allowOnly: [area], + denyWithinAllow: [join(area, 'absent.lock')], + command: `echo ran; touch ${join(B, 'written')}`, + }) + } + const first = await inArea(A) + const manifest = manifestOf(first)! + expect(first.match(VIA_ARGS_FILE)![2]).toEndWith( + ` --ro-bind / / --ro-bind ${manifest} ${manifest}`, + ) + cleanupBwrapMountPoints() + const second = await inArea(B) + expect(argsPathOf(second)).toBe(argsPathOf(first)) + + const run = spawnSync(first, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + expect(run.status).not.toBe(0) + expect(run.stderr).toContain(`Can't find source path ${manifest}`) + expect(run.stdout).toBe('') + // Nothing was made for it, in either place. + expect(readdirSync(A)).toEqual([]) + expect(readdirSync(B)).toEqual([]) + }, + 60_000, + ) }) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 2bb0fd4d6..b565d0536 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -20,6 +20,7 @@ import { basename, dirname, join } from 'node:path' import { liveMountPoints } from '../../src/index.js' import { cleanupBwrapMountPoints, + LinuxSandboxProfileError, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' @@ -27,6 +28,8 @@ import { countMounts, indexOfMount, manifestOf as recordedBy, + RECORD_STEP, + STEP_SHELL, } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' @@ -133,6 +136,12 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return existsSync(file) ? readFileSync(file, 'utf8') : '' } + /** The set a caller is given of the live mount points, as a list. */ + function live(): string[] | undefined { + const set = liveMountPoints() + return set && [...set] + } + /** The manifest the wrap recorded its mount points in. */ function manifestOf(command: string): string { const file = recordedBy(command) @@ -188,8 +197,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const holderExited = new Promise(resolve => holder.on('exit', resolve)) try { await waitFor(SIGNAL) - // The mount point bwrap made for the sandbox that is running. + // The mount point bwrap made for the sandbox that is running, on + // record where this test and the process below look. expect(lstatSync(LOCK).size).toBe(0) + expect(readdirSync(runtime.manifestDir())).toHaveLength(2) // A second process wraps the same deny path, runs a command and cleans // up. @@ -203,7 +214,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ]), ), ], - { encoding: 'utf8', timeout: 60000 }, + { env: { ...process.env }, encoding: 'utf8', timeout: 60000 }, ) expect(second.status).toBe(0) expect(existsSync(LOCK)).toBe(true) @@ -218,7 +229,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // Once nothing is running under it, it goes: on the holder's own cleanup // as it exits, or on this one. await holderExited - await sleep(600) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }, @@ -265,7 +275,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { async () => { // For a caller that removes paths itself after a command: what it is // about to remove may be what another process's sandbox is bound over. - expect([...liveMountPoints()]).toEqual([]) + expect(live()).toEqual([]) const holder = spawn( process.execPath, [writeScript('holder.ts', wrapperScript(heldCommand(), OUT))], @@ -274,16 +284,12 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const holderExited = new Promise(resolve => holder.on('exit', resolve)) try { await waitFor(SIGNAL) - // Past the grace, so that only the process on its record and its - // writer speak for the manifest. - await sleep(600) - expect([...liveMountPoints()]).toEqual([LOCK]) + expect(live()).toEqual([LOCK]) } finally { writeFileSync(GO, '') } await holderExited - await sleep(600) - expect([...liveMountPoints()]).toEqual([]) + expect(live()).toEqual([]) }, 90000, ) @@ -299,7 +305,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { writeFileSync(LOCK, '') chmodSync(LOCK, 0o444) - expect([...liveMountPoints()]).toEqual([LOCK]) + expect(live()).toEqual([LOCK]) }) it.if(BWRAP_CAN_NAMESPACE)( @@ -337,7 +343,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { writeFileSync(GO, '') await holderExited // and its own clean-up has run - await sleep(700) cleanupBwrapMountPoints() expect(lstatSync(LOCK).size).toBe(0) @@ -371,7 +376,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ]), ), ], - { encoding: 'utf8', timeout: 60000 }, + { env: { ...process.env }, encoding: 'utf8', timeout: 60000 }, ) expect(killed.signal).toBe('SIGKILL') // Its sandbox ran, and left what bubblewrap made for it: without this @@ -380,7 +385,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(lstatSync(LOCK).size).toBe(0) const directory = dirname(manifestOf(await wrap('true'))) - await sleep(600) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) @@ -578,7 +582,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // One shell, which bubblewrap takes the place of: the pid on record is // bubblewrap's own, and the sandbox dies with it. expect(command).toStartWith( - `/bin/sh -c 'read -r s >"$1" && shift && exec "$@"' srt ${record} bwrap --new-session --die-with-parent `, + `${STEP_SHELL} '${RECORD_STEP} && exec "$@"' srt ${record} bwrap --new-session --die-with-parent `, ) expect(command).toContain( ` --ro-bind / / --ro-bind ${manifest} ${manifest} --`, @@ -589,6 +593,186 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) }) + /** Field 22 of a /proc/PID/stat line: when that process started. */ + function startOf(stat: string): string | undefined { + return stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19] + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'puts bubblewrap itself on record, each time it is run', + async () => { + const command = await wrap(heldCommand()) + const child = spawn(command, { shell: true, stdio: 'ignore' }) + const exited = new Promise(resolve => child.on('exit', resolve)) + let pid: string + try { + await waitFor(SIGNAL) + const lines = onRecord(command) + expect(lines).toHaveLength(1) + pid = lines[0]!.split(' ')[0]! + // The shell that wrote the line has become bubblewrap. + const now = readFileSync(`/proc/${pid}/stat`, 'utf8') + expect(lines[0]).toMatch(/^\d+ \((ba)?sh\) /) + expect(now).toStartWith(`${pid} (bwrap) `) + expect(startOf(now)).toBe(startOf(lines[0]!)!) + } finally { + writeFileSync(GO, '') + } + await exited + expect(existsSync(`/proc/${pid}`)).toBe(false) + + expect(spawnSync(command, { shell: true, timeout: 60000 }).status).toBe(0) + expect(onRecord(command)).toHaveLength(2) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'puts itself on record by the number /proc has for it, where the shell has another for itself', + async () => { + // A PID namespace of its own under the outer /proc, as in a nested + // sandbox. Only the steps the command takes before bubblewrap are run. + const command = await wrap('true') + const steps = /^.*? srt \S+\.started /.exec(command)![0] + const nested = spawn( + 'bwrap', + [ + ...['--dev-bind', '/', '/', '--unshare-pid', '--die-with-parent'], + ...['--', '/bin/sh', '-c', `${steps} sleep 60`], + ], + { stdio: 'ignore' }, + ) + try { + const deadline = Date.now() + 20000 + while (!existsSync(manifestOf(command).replace(/json$/, 'started'))) { + if (Date.now() > deadline) throw new Error('nothing was recorded') + await sleep(25) + } + while (onRecord(command).length === 0 && Date.now() < deadline) { + await sleep(25) + } + const [line] = onRecord(command) + const pid = line!.split(' ')[0] + while ( + !contentAt(`/proc/${pid}/stat`).includes('(sleep)') && + Date.now() < deadline + ) { + await sleep(25) + } + const now = readFileSync(`/proc/${pid}/stat`, 'utf8') + expect(now).toStartWith(`${pid} (sleep) `) + expect(startOf(now)).toBe(startOf(line!)!) + } finally { + nested.kill('SIGKILL') + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'makes nothing on the host when its manifest is claimed after bubblewrap has looked its sources up', + async () => { + // bubblewrap looks up every bind source before it mounts anything, so a + // claim made before that refuses the start wherever the manifest is + // bound. Here bubblewrap is held up after that look, with only the root + // bound (`--file` copies from a FIFO nobody has closed), and the claim + // lands then: the manifest has to be the next thing it binds. + const command = await wrap('echo ran') + const manifest = manifestOf(command) + const fifo = join(BASE, 'fifo') + const gate = join(AREA, 'gate') + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + const hold = fs.openSync(fifo, fs.constants.O_RDWR) + const root = ' --ro-bind / / ' + expect(command.split(root)).toHaveLength(2) + const child = spawn( + `${command.replace(root, `${root}--bind ${AREA} ${AREA} --file 8 ${gate} `)} 8<${fifo}`, + { shell: true }, + ) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(gate) + fs.renameSync(manifest, manifest.replace(/json$/, 'claimed')) + } finally { + fs.closeSync(hold) + } + expect(await exited).not.toBe(0) + expect(said).toContain(`bwrap: Can't`) + expect(said).toContain(manifest) + expect(said).not.toContain('ran') + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + // ---- the shell the command takes its own steps in, on the host ---- + + const BASH = ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) + + it.if(BASH !== undefined)( + 'takes its steps in bash, where that is at one of its usual places, and has it import nothing', + async () => { + expect(await wrap('true')).toStartWith(`${BASH} -p -c '`) + }, + ) + + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( + 'hands the command every entry of the environment, whatever its name', + async () => { + // dash rebuilds the environment from its variables, and drops these. + const run = spawnSync(BASH!, ['-c', await wrap('env')], { + env: { ...process.env, 'a.b': 'dotted', 'FOO-BAR': 'dashed' }, + encoding: 'utf8', + timeout: 60000, + }) + expect(run.status).toBe(0) + expect(run.stdout.split('\n')).toEqual( + expect.arrayContaining(['a.b=dotted', 'FOO-BAR=dashed']), + ) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( + 'runs nothing on the host that the environment plants for a shell', + async () => { + // Each leaves a file where only the host can: the sandbox may not write + // outside AREA. + const functionRan = join(BASE, 'function-ran') + const fileRead = join(BASE, 'file-read') + const planted = join(BASE, 'bash-env.sh') + writeFileSync(planted, `touch ${fileRead} 2>/dev/null\n`) + const command = await wrap('echo started') + for (const [shell, entry, value] of [ + // Through bash, which hands an exported function on; dash drops it. + [ + BASH!, + 'BASH_FUNC_shift%%', + `() { touch ${functionRan} 2>/dev/null; builtin shift; }`, + ], + // Through sh, which does not read that file for itself. + ['/bin/sh', 'BASH_ENV', planted], + ] as const) { + const run = spawnSync(shell, ['-c', command], { + env: { ...process.env, [entry]: value }, + encoding: 'utf8', + timeout: 60000, + }) + expect(`${run.stdout}${run.stderr}`).toBe('started\n') + expect(run.status).toBe(0) + } + expect(existsSync(functionRan)).toBe(false) + expect(existsSync(fileRead)).toBe(false) + }, + 90000, + ) + // ---- two commands of one process in flight ---- // // A call to clean up cannot tell which command it is for, so while a wrap is @@ -599,8 +783,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 'a command wrapped and not yet started survives the clean-up after the one that finished first', async () => { const first = await wrap('echo first-ran') + // Removed first: the file's mode alone refuses a plain write. const second = await wrap( - `echo second-ran; echo pwned > ${LOCK}; echo rc=$?`, + `echo second-ran; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, ) const run = (command: string) => spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 }) @@ -626,25 +811,43 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) /** - * Runs `during` once in the next clean-up, when it has claimed the first - * manifest it takes for finished and before it goes on. + * Runs `before` just before, and `after` just after, the next clean-up claims + * `manifest`. */ - function duringTheNextCleanUp(during: () => void): { restore(): void } { + function atTheClaimOn( + manifest: string, + at: { before?: () => void; after?: () => void }, + ): { restore(): void } { const rename = fs.renameSync - let done = false const spy = spyOn(fs, 'renameSync').mockImplementation((( from: string, to: string, ) => { - if (to.endsWith('.claimed') && !done) { - done = true - during() - } + const meant = from === manifest && to.endsWith('.claimed') + if (meant) at.before?.() rename(from, to) + if (meant) at.after?.() }) as never) return { restore: () => spy.mockRestore() } } + /** The lines on the started record of `command`. */ + function onRecord(command: string): string[] { + return readFileSync(manifestOf(command).replace(/json$/, 'started'), 'utf8') + .split('\n') + .filter(line => line !== '') + } + + /** Waits until no process on the record of `command` is left. */ + async function ended(command: string): Promise { + const pids = onRecord(command).map(line => line.split(' ')[0]) + const deadline = Date.now() + 20000 + while (pids.some(pid => existsSync(`/proc/${pid}`))) { + if (Date.now() > deadline) throw new Error('the sandbox never ended') + await sleep(25) + } + } + /** * Waits to be told, then tries the denied write the hard way: a mount point * that was removed and made again is an empty file without a write bit, which @@ -664,17 +867,19 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const command = await wrap(heldCommandThatRemovesFirst()) const manifest = manifestOf(command) let inode: number | undefined - const pass = duringTheNextCleanUp(() => { - // `{ ...; true; }` keeps a shell as bubblewrap's parent. - spawnSync('sh', ['-c', `{ ${command}; true; } > ${OUT} 2>&1 &`], { - stdio: 'ignore', - }) - const deadline = Date.now() + 20000 - while (!existsSync(SIGNAL)) { - if (Date.now() > deadline) throw new Error('sandbox never came up') - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) - } - inode = lstatSync(LOCK).ino + const pass = atTheClaimOn(manifest, { + before: () => { + // `{ ...; true; }` keeps a shell as bubblewrap's parent. + spawnSync('sh', ['-c', `{ ${command}; true; } > ${OUT} 2>&1 &`], { + stdio: 'ignore', + }) + const deadline = Date.now() + 20000 + while (!existsSync(SIGNAL)) { + if (Date.now() > deadline) throw new Error('sandbox never came up') + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + inode = lstatSync(LOCK).ino + }, }) try { // The one clean-up this wrap is owed, made too early: the command is @@ -708,6 +913,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(contentAt(OUT)).toMatch(/rc=[1-9]/) expect(contentAt(LOCK)).toBe('') expect(lstatSync(LOCK).ino).toBe(inode!) + + // Not left running for the next test to find on record. + await ended(command) + cleanupBwrapMountPoints() + expect(readdirSync(dirname(manifest))).toEqual([]) }, 90000, ) @@ -717,25 +927,20 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { async () => { const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) let refused: { status: number | null; said: string } | undefined - const rename = fs.renameSync - const spy = spyOn(fs, 'renameSync').mockImplementation((( - from: string, - to: string, - ) => { - rename(from, to) - if (to.endsWith('.claimed') && refused === undefined) { + const pass = atTheClaimOn(manifestOf(command), { + after: () => { const run = spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000, }) refused = { status: run.status, said: `${run.stdout}${run.stderr}` } - } - }) as never) + }, + }) try { cleanupBwrapMountPoints() // too early, and the command starts under it } finally { - spy.mockRestore() + pass.restore() } expect(refused).toBeDefined() expect(refused?.status).not.toBe(0) @@ -747,69 +952,167 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) - it.if(BWRAP_CAN_NAMESPACE)( - 'with the id its wrap was given, a finished command lets go of its mount point while another is outstanding', - async () => { - const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') - const wrapAs = (commandId: string, command: string, deny: string) => - wrapCommandWithSandboxLinux({ - command, - commandId, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: [deny] }, - }) - const run = (command: string) => - spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 }) - - const first = await wrapAs('first-0123456789abcdef', 'true', lockOfFirst) - const second = await wrapAs( - 'second-0123456789abcdef', - `echo pwned > ${LOCK}; echo rc=$?`, - LOCK, - ) - expect(run(first).status).toBe(0) - expect(existsSync(lockOfFirst)).toBe(true) // bubblewrap leaves it + // ---- which command a clean-up is for ---- + // + // A command that ran is judged by its record alone: any clean-up collects it + // once it has ended. Of one that never started, only the caller can say that + // it is over, so these commands never start. What each holds on to is a mount + // point a killed process left, which the wrap covers and names. + + function leftByAKilledProcess(mountPoint: string): void { + writeFileSync(mountPoint, '') + chmodSync(mountPoint, 0o444) + manifestNaming([mountPoint]) + } - // This call says which command it is for, so that command's mount point - // goes although the second is still outstanding. - cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) - expect(existsSync(lockOfFirst)).toBe(false) - expect(existsSync(manifestOf(second))).toBe(true) + it('with the id its wrap was given, a command that never started lets go of its mount point at once, while others are outstanding', async () => { + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + leftByAKilledProcess(lockOfFirst) + const wrapAs = (commandId: string, deny: string) => + wrapCommandWithSandboxLinux({ + command: 'true', + commandId, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [deny] }, + }) + const first = await wrapAs('first-0123456789abcdef', lockOfFirst) + const others = [ + await wrapAs('second-0123456789abcdef', LOCK), + await wrapAs('third-0123456789abcdef', LOCK), + ] - const result = run(second) - expect(`${result.stdout}${result.stderr}`).not.toContain('bwrap:') - expect(result.stdout).toMatch(/rc=[1-9]/) - cleanupBwrapMountPoints({ commandId: 'second-0123456789abcdef' }) - expect(existsSync(LOCK)).toBe(false) - }, - 90000, - ) + // The control: a call that does not say which command it is for releases + // nothing this process made. + cleanupBwrapMountPoints() + expect(existsSync(lockOfFirst)).toBe(true) + expect(existsSync(manifestOf(first))).toBe(true) + + cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(first))).toBe(false) + for (const other of others) { + expect(existsSync(manifestOf(other))).toBe(true) + } + }) it.if(BWRAP_CAN_NAMESPACE)( - 'a wrap that produced no command is not waited for by the clean-up after the next', + 'lets go of it the same way through the public clean-up, and the others can still start', async () => { - // It gave its count back when it threw, so one clean-up after the command - // that did run is all that is owed. - const refused = wrapCommandWithSandboxLinux({ - command: 'true', - binShell: 'srt-no-such-shell', - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + leftByAKilledProcess(lockOfFirst) + const config = (deny: string) => ({ + filesystem: { denyRead: [], allowWrite: [AREA], denyWrite: [deny] }, }) - expect(refused).rejects.toThrow() - await refused.catch(() => undefined) + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + ...config(lockOfFirst), + }) + try { + const wrapAs = (commandId: string, deny: string) => + SandboxManager.wrapWithSandbox( + 'true', + undefined, + config(deny), + undefined, + { commandId }, + ) + const first = await wrapAs('first-0123456789abcdef', lockOfFirst) + const second = await wrapAs('second-0123456789abcdef', LOCK) + await wrapAs('third-0123456789abcdef', LOCK) - const command = await wrap('true') - expect(spawnSync(command, { shell: true, timeout: 60000 }).status).toBe(0) - expect(existsSync(LOCK)).toBe(true) // bubblewrap leaves it - cleanupBwrapMountPoints() - expect(existsSync(LOCK)).toBe(false) + SandboxManager.cleanupAfterCommand() + expect(existsSync(lockOfFirst)).toBe(true) + + SandboxManager.cleanupAfterCommand({ + commandId: 'first-0123456789abcdef', + }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(first))).toBe(false) + expect(spawnSync(second, { shell: true, timeout: 60000 }).status).toBe( + 0, + ) + } finally { + await SandboxManager.reset() + } }, 90000, ) + it('a wrap that produced no command is not waited for by the clean-up after the next', async () => { + // It gave its count back when it threw, so one clean-up is all that is owed. + const thrown: unknown = await wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'srt-no-such-shell', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + }).catch((error: unknown) => error) + expect(String(thrown)).toContain('srt-no-such-shell') + + leftByAKilledProcess(LOCK) + const command = await wrap('true') + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) + }) + + it('a clean-up too many, with nothing outstanding, is not held against the next wrap', async () => { + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + + leftByAKilledProcess(LOCK) + const command = await wrap('true') + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) + }) + + // ---- what a wrap cannot tell, it does not guess ---- + + /** What the wrap was refused with. */ + async function refusal(wrapping: Promise): Promise { + const thrown: unknown = await wrapping.then( + () => 'the wrap resolved', + (error: unknown) => error, + ) + expect(thrown).toBeInstanceOf(LinuxSandboxProfileError) + return (thrown as LinuxSandboxProfileError).code + } + + it("refuses to wrap when it cannot read another sandbox's manifest", async () => { + // Taken for the caller's own, the file would be bound onto itself and named + // nowhere, and would go from under this sandbox with the one that made it. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const theirs = manifestNaming([LOCK], process.pid) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === theirs) { + throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + } finally { + spy.mockRestore() + } + expect(readdirSync(dirname(theirs))).toEqual([basename(theirs)]) + expect(await wrap('true')).toContain(`--ro-bind /dev/null ${LOCK}`) + }) + + it('refuses to wrap what needs a manifest larger than can be read, and publishes none', async () => { + const many = Array.from({ length: 4000 }, (_, i) => + join(AREA, `${'x'.repeat(240)}-${i}`), + ) + expect(await refusal(wrap('true', many))).toBe('too_many_mount_points') + expect(readdirSync(runtime.manifestDir())).toEqual([]) + }, 30000) + it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { // Live: written by this process, which has not let go of it. A file with // content is nothing bubblewrap made, and /dev/null over it would hide it @@ -851,52 +1154,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) }) - it.if(BWRAP_CAN_NAMESPACE)( - 'goes at once through the public clean-up, given the id its wrap was given', - async () => { - const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') - const config = (deny: string) => ({ - filesystem: { denyRead: [], allowWrite: [AREA], denyWrite: [deny] }, - }) - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - ...config(lockOfFirst), - }) - try { - const first = await SandboxManager.wrapWithSandbox( - 'true', - undefined, - undefined, - undefined, - { commandId: 'first-0123456789abcdef' }, - ) - const second = await SandboxManager.wrapWithSandbox( - 'true', - undefined, - config(LOCK), - undefined, - { commandId: 'second-0123456789abcdef' }, - ) - expect(spawnSync(first, { shell: true, timeout: 60000 }).status).toBe(0) - expect(existsSync(lockOfFirst)).toBe(true) - - SandboxManager.cleanupAfterCommand({ - commandId: 'first-0123456789abcdef', - }) - expect(existsSync(lockOfFirst)).toBe(false) - expect(existsSync(manifestOf(first))).toBe(false) - // The other command has not started, and still can. - expect(existsSync(manifestOf(second))).toBe(true) - expect(spawnSync(second, { shell: true, timeout: 60000 }).status).toBe( - 0, - ) - } finally { - await SandboxManager.reset() - } - }, - 90000, - ) - it.if(BWRAP_CAN_NAMESPACE)( "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", async () => { @@ -1071,6 +1328,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // wrap that threw must not have held on to its manifest. await wrap('true') cleanupBwrapMountPoints() + expect(wrapper.exitCode).toBe(null) expect(existsSync(LOCK)).toBe(false) } finally { wrapper.kill() @@ -1095,9 +1353,6 @@ describe.if(isLinux)( const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), ) - // Comfortably past the grace a new manifest is given (500 ms). - const PAST_GRACE_MS = 1200 - let BASE: string let AREA: string // the allowed write area let LOCK: string // the denyWrite path, absent to begin with @@ -1272,6 +1527,7 @@ describe.if(isLinux)( const file = join(BASE, name) writeFileSync(file, source) return spawnSync(process.execPath, [file], { + env: { ...process.env }, encoding: 'utf8', timeout: 60000, }).status @@ -1378,10 +1634,6 @@ describe.if(isLinux)( } look('after its writer went') - // Past the grace a new manifest is given, so that neither it nor - // the writer is what keeps the manifest live below. - await sleep(PAST_GRACE_MS) - expect(runScript('collector.ts', collectorScript())).toBe(0) look('after a process that wrapped nothing collected') expect(runScript('second.ts', secondWrapperScript())).toBe(0) @@ -1448,7 +1700,6 @@ describe.if(isLinux)( `process.kill(process.pid, 'SIGKILL')`, 'SIGKILL', ) - await sleep(PAST_GRACE_MS) expect(loop.exitCode).toBe(null) // still collecting expect(lstatSync(LOCK).size).toBe(0) expect(await theDeniedWrite()).toEqual({ @@ -1651,7 +1902,6 @@ describe.if(isLinux)( writeFileSync(goHolder, '') await heldExited // and its own clean-up has run - await new Promise(resolve => setTimeout(resolve, 700)) cleanupBwrapMountPoints() expect(existsSync(DIRECTORY)).toBe(true) @@ -1734,6 +1984,200 @@ describe.if(isLinux)( expect(existsSync(DIRECTORY)).toBe(false) }) + // ---- relied on, not denied: what a second wrap nests under it ---- + // + // The second wrap finds the directory there, so its own mount point is made + // inside it and its pin is on it. It has to name the directory too, or that + // goes with the sandbox it was made for. + + it('is named by a wrap whose own mount point is made in it, and kept for that wrap', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + + const command = await wrap('true', [LEAF]) + expect(command).toContain(`--ro-bind /dev/null ${LEAF}`) + expect(countMounts(command, '--ro-bind', DIRECTORY, DIRECTORY)).toBe(1) + expect(namedBy(command).sort()).toEqual([DIRECTORY, LEAF].sort()) + + // What the killed process left is collected; the directory stays. + await wrap('true', []) + cleanupBwrapMountPoints() + expect(existsSync(left)).toBe(false) + expect(existsSync(DIRECTORY)).toBe(true) + + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is still there for a second command of this process, wrapped before and started after the clean-up for the first', + async () => { + const first = await wrap('echo first', [LEAF]) + expect(run(first).stdout).toBe('first\n') + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + const second = await wrap('echo second', [LEAF]) + expect(namedBy(second)).toContain(DIRECTORY) + + cleanupBwrapMountPoints() // for the first, which has ended + expect(existsSync(DIRECTORY)).toBe(true) + expect(run(second)).toMatchObject({ status: 0, said: 'second\n' }) + + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'keeps its pin in a sandbox that is starting when the sandbox it was made for is cleaned up after', + async () => { + // bubblewrap is held up after its pins and before it makes the mount + // point inside the directory: `--file` copies from a FIFO nobody has + // closed. Removed then, the directory would be made again, unpinned. + expect(run(await wrap('true', [LEAF])).status).toBe(0) + const inode = lstatSync(DIRECTORY).ino + const mountsAt = (p: string): string => + `$(grep -c ' ${p} ' /proc/self/mountinfo)` + const second = await wrap( + `echo "${mountsAt(DIRECTORY)} ${mountsAt(LEAF)}"`, + [LEAF], + ) + const placeholder = `--ro-bind /dev/null ${LEAF}` + expect(second.split(placeholder)).toHaveLength(2) + const fifo = join(BASE, 'fifo') + const gate = join(AREA, 'gate') + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + const hold = fs.openSync(fifo, fs.constants.O_RDWR) + const child = spawn( + `${second.replace(placeholder, `--file 8 ${gate} ${placeholder}`)} 8<${fifo}`, + { shell: true }, + ) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(gate) + cleanupBwrapMountPoints() // for the first, which has ended + expect(lstatSync(DIRECTORY).ino).toBe(inode) + } finally { + fs.closeSync(hold) + } + expect(await exited).toBe(0) + // One mount at the directory, its pin, and one at what is denied in it. + expect(said).toBe('1 1\n') + expect(lstatSync(DIRECTORY).ino).toBe(inode) + + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + }, + 90000, + ) + + /** Runs `then` each time the wrap is about to publish a manifest. */ + function atEachPublish(then: () => void): { restore(): void } { + const write = fs.writeFileSync + const spy = spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.json.tmp')) then() + return (write as (...args: unknown[]) => void)(file, ...rest) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('plans again when the directory has gone by the time its manifest is published', async () => { + // Until then nothing of this wrap's names it, and a clean-up in another + // process takes it away with the sandbox it was made for. + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + let published = 0 + const publishing = atEachPublish(() => { + if (published++ === 0) { + fs.rmdirSync(DIRECTORY) + rmSync(left) + } + }) + let command: string + try { + command = await wrap('true', [LEAF]) + } finally { + publishing.restore() + } + // The plan that was handed out found it absent, and covers it itself. + expect(published).toBe(2) + expect(command).not.toContain(`--ro-bind /dev/null ${LEAF}`) + expect(command).toMatch( + new RegExp(`--ro-bind \\S+/claude-empty-\\S+ ${DIRECTORY} `), + ) + expect(namedBy(command)).toEqual([DIRECTORY]) + // Nothing is left of the plan it gave up. + expect(readdirSync(runtime.manifestDir())).toEqual([ + basename(recordedBy(command)!), + ]) + }) + + it('gives up, with a code of its own and nothing left behind, when that happens to every plan', async () => { + const emptySources = (): string[] => + readdirSync(tmpdir()) + .filter(name => name.startsWith('claude-empty-')) + .map(name => join(tmpdir(), name)) + let published = 0 + const publishing = atEachPublish(() => { + published++ + // What the mount point is bound from, as a clean-up elsewhere takes it. + emptySources().forEach(source => fs.rmdirSync(source)) + }) + let refused: unknown + try { + refused = await wrap('true', [LEAF]).catch((error: unknown) => error) + } finally { + publishing.restore() + } + expect(refused).toBeInstanceOf(LinuxSandboxProfileError) + expect((refused as LinuxSandboxProfileError).code).toBe( + 'mount_points_changed', + ) + expect(published).toBe(3) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + + // And it is not counted: one clean-up is all the next wrap is owed. + const command = await wrap('true', [LEAF]) + cleanupBwrapMountPoints() + expect(existsSync(recordedBy(command)!)).toBe(false) + }) + + it('is named by a wrap that denies it though another sandbox made it after that wrap last read the manifests', async () => { + // An empty file at an earlier deny path has the manifests read. The + // directory is made, by a sandbox that published first, as the wrap comes + // to look at it. + const earlier = join(AREA, 'proj', 'earlier.lock') + writeFileSync(earlier, '') + const exists = fs.existsSync + let made = false + const spy = spyOn(fs, 'existsSync').mockImplementation((( + file: unknown, + ) => { + if (file === DIRECTORY && !made) { + made = true + manifestOfAKilledProcess([DIRECTORY]) + mkdirSync(DIRECTORY) + } + return exists(file as string) + }) as never) + let command: string + try { + command = await wrap('true', [earlier, DIRECTORY]) + } finally { + spy.mockRestore() + } + expect(made).toBe(true) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(namedBy(command)).toEqual([DIRECTORY]) + }) + it('is never removed once something has been put into it', async () => { mkdirSync(DIRECTORY) const left = manifestOfAKilledProcess([DIRECTORY]) @@ -1790,7 +2234,12 @@ describe.if(isLinux)( `process.kill(process.pid, 'SIGKILL')`, ]), ], - { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, ) expect(killed.signal).toBe('SIGKILL') // Its sandbox ran, and left what bubblewrap made for it. @@ -1801,9 +2250,6 @@ describe.if(isLinux)( name.endsWith('.json'), ), ).toHaveLength(1) - // Past its grace: nothing vouches for that manifest any more. - await new Promise(resolve => setTimeout(resolve, 600)) - const up = join(AREA, 'up-second') const go = join(AREA, 'go-second') let said = '' @@ -1827,7 +2273,12 @@ describe.if(isLinux)( `cleanupBwrapMountPoints()`, ]), ], - { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, ) expect(collector.status).toBe(0) expect(existsSync(DIRECTORY)).toBe(true) @@ -1959,7 +2410,7 @@ describe.if(isLinux)( // command's to rename. expect(countMounts(command, '--ro-bind', HOME, HOME)).toBe(0) expect(countMounts(command, '--ro-bind', BASE, BASE)).toBe(0) - }) + }, 30000) it('are pinned above the manifest directory under the runtime directory', () => { const RUN = join(HOME, 'run', 'user') @@ -1972,7 +2423,7 @@ describe.if(isLinux)( expect(dirname(recordedBy(command)!)).toBe(`${RUN}/srt-mount-points`) expectPinned(command, RUN) expectPinned(command, join(HOME, 'run')) - }) + }, 30000) it('are pinned above the directory of its own a process settles on as it records', () => { // Neither shared name will do, so the wrap makes a directory only this @@ -1986,7 +2437,7 @@ describe.if(isLinux)( new RegExp(`^${TMP}/srt-mount-points-[A-Za-z0-9]{6}$`), ) expectPinned(command, TMP) - }) + }, 30000) it('are pinned above the empty directory the placeholders bind from', () => { // The manifests are out of the way, under a runtime directory outside @@ -2002,7 +2453,7 @@ describe.if(isLinux)( new RegExp(`--ro-bind ${TMP}/claude-empty-\\S+ ${HOME}/absent `), ) expectPinned(command, TMP) - }) + }, 30000) it('are pinned above the store of fake files, under a wrap that restricts no write', () => { const store = join(TMP, 'srt-credmask-store') @@ -2026,7 +2477,7 @@ describe.if(isLinux)( indexOfMount(command, '--ro-bind', store, store), ) } - }) + }, 30000) it('are pinned where the temp dir really is, when its name leads there through a link', () => { // A pin is a mount, and a mount lands where its path resolves; on the @@ -2041,7 +2492,7 @@ describe.if(isLinux)( ) expectPinned(command, real) expect(countMounts(command, '--ro-bind', through, through)).toBe(0) - }) + }, 30000) it('are not pinned where the temp dir is itself the write root, or outside every one', () => { const inside = wrapped( @@ -2059,7 +2510,7 @@ describe.if(isLinux)( ) expect(countMounts(outside, '--ro-bind', TMP, TMP)).toBe(0) expect(countMounts(outside, '--ro-bind', HOME, HOME)).toBe(0) - }) + }, 30000) it.if(BWRAP_CAN_NAMESPACE)( 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', @@ -2253,7 +2704,7 @@ describe.if(isLinux)( const other = wrappedAndRun(through, 'true', []).wrapped expect(other).toContain(`--ro-bind-try ${kept} ${kept}`) expect(other).not.toContain(named) - }) + }, 30000) it(`binds the empty directory and the store of fake files where they really are, with the link ${where} the write root and its target ${target}`, () => { const { real, through } = linked() @@ -2281,7 +2732,7 @@ describe.if(isLinux)( expect(wrapped).not.toMatch( new RegExp(`--ro-bind \\S+ ${through}/(claude-empty-|fake-files)`), ) - }) + }, 30000) it.if(BWRAP_CAN_NAMESPACE)( `does not keep a sandbox from starting, with the link ${where} the write root and its target ${target}`, @@ -2450,9 +2901,6 @@ describe.if(isLinux)( manifest = recordedBy(readFileSync(WRAPPED, 'utf8'))! expect(existsSync(manifest)).toBe(true) expect(lstatSync(LOCK).size).toBe(0) - // Past the grace: only the writer and the process on record vouch - // for the manifest now, and neither can be seen from over there. - await new Promise(resolve => setTimeout(resolve, 800)) const collector = spawnSync( IN_NEW_PID_NAMESPACE[0]!, @@ -2470,7 +2918,12 @@ describe.if(isLinux)( `cleanupBwrapMountPoints()`, ]), ], - { encoding: 'utf8', timeout: 60000, cwd: import.meta.dir }, + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, ) expect(collector.status).toBe(0) @@ -2487,7 +2940,6 @@ describe.if(isLinux)( // And it still goes once nothing runs under it, from the namespace // that can tell: the holder's exit handler, or this cleanup. - await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) expect(existsSync(manifest)).toBe(false) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 8b493aba2..c24c07f5f 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -160,6 +160,21 @@ describe.if(isLinux)('The mount point manifests', () => { return manifest } + /** The manifest this process publishes for a wrap that names `paths`. */ + function publish(paths: string[], sources: string[] = []): string { + const manifest = publishMountPointManifest(paths, sources) + if (manifest === undefined || manifest === 'too-large') { + throw new Error(`not published: ${manifest}`) + } + return manifest.file + } + + /** What a wrap is told the manifests name, sorted; `undefined` in doubt. */ + function named(): string[] | undefined { + const reading = namedMountPoints() + return reading && [...reading.paths].sort() + } + /** The claims in the manifest directory, by name. */ function claims(): string[] { return readdirSync(DIR).filter(name => name.endsWith('.claimed')) @@ -314,7 +329,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(inside)).toBe(true) // Under its claim, which any later pass takes up. expect(claims()).toEqual([claimOf(manifest)]) - expect([...namedMountPoints().named]).toEqual([inside]) + expect(named()).toEqual([inside]) } finally { chmodSync(readOnly, 0o755) } @@ -336,7 +351,7 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(readOnly) const inside = join(readOnly, 'config.lock') leftover(inside) - const manifest = publishMountPointManifest([inside], [])!.file + const manifest = publish([inside]) chmodSync(readOnly, 0o555) try { expect(collectMountPoints('all')).toEqual([]) @@ -353,7 +368,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('keeps the manifest of a wrap of its own when the pass turns back half way, for the next', () => { leftover(X) - const manifest = publishMountPointManifest([X], [])!.file + const manifest = publish([X]) // Another version of this library publishes, in a layout this one cannot // read, after the pass has claimed: the pass stops short of removing // anything. @@ -386,9 +401,9 @@ describe.if(isLinux)('The mount point manifests', () => { let arrived: string | undefined const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { unlink(file) - if (file === X && arrived === undefined) { + if (file === Y && arrived === undefined) { // Published the moment the first mount point has gone. - arrived = manifestOfADeadProcess([Y], { + arrived = manifestOfADeadProcess([X], { pid: process.pid, created: Date.now(), }) @@ -401,8 +416,8 @@ describe.if(isLinux)('The mount point manifests', () => { spy.mockRestore() } expect(arrived).toBeDefined() - expect(removed).toEqual([X]) - expect(existsSync(Y)).toBe(true) + expect(removed).toEqual([Y]) + expect(existsSync(X)).toBe(true) }) it('lists by the clock, not before every removal, in a pass of many mount points', () => { @@ -429,14 +444,68 @@ describe.if(isLinux)('The mount point manifests', () => { expect(listings).toBeLessThan(many.length) }) - it('is live for half a second from when it was written, though its writer is gone', () => { + it('takes a manifest of a later version by the fields it knows', () => { + // Taken for unreadable, it would put everything in doubt, and every wrap of + // an older process would be refused while a newer one is at work beside it. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const later = { version: 2, added: { by: 'a later version' } } + manifestOfADeadProcess([X], { ...later, ...thisProcess() }) + const finished = manifestOfADeadProcess([Y], later) + + expect(liveMountPoints()).toEqual(new Set([X])) + expect(collectMountPoints('none')).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(finished)).toBe(false) + }) + + it('is live for half a second from when it was written, though its writer is gone, and no longer', () => { // A command whose wrapping process was killed a moment ago is not refused // its start for that. leftover(X) - const manifest = manifestOfADeadProcess([X], { created: Date.now() }) - expect(collectMountPoints()).toEqual([]) - expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) + const now = Date.now() + const manifest = manifestOfADeadProcess([X], { created: now - 499 }) + const clock = spyOn(Date, 'now').mockReturnValue(now) + try { + expect(collectMountPoints()).toEqual([]) + expect(existsSync(manifest)).toBe(true) + clock.mockReturnValue(now + 1) + expect(collectMountPoints()).toEqual([X]) + } finally { + clock.mockRestore() + } + }) + + it('says when it was written, and by which process as /proc numbers it', () => { + // Where the process has a PID namespace of its own under an outer /proc, + // its own number for itself is nobody's there, or somebody else's. + const stat = readFileSync('/proc/self/stat', 'utf8') + const outer = deadPid() + const read = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => + file === '/proc/self/stat' + ? stat.replace(/^\d+/, String(outer)) + : (read as (f: unknown, o: unknown) => unknown)( + file, + options, + )) as never) + const before = Date.now() + let manifest: string + try { + manifest = publish([X]) + } finally { + spy.mockRestore() + } + const written = JSON.parse(readFileSync(manifest, 'utf8')) as { + created: number + } + expect(written).toMatchObject({ pid: outer, start: startOf(stat) }) + expect(written.created).toBeGreaterThanOrEqual(before) + expect(written.created).toBeLessThanOrEqual(Date.now()) }) // ---- a sandbox vouches for itself ---- @@ -449,14 +518,14 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) leftover(Y) const theirs = started(manifestOfADeadProcess([X]), thisProcess()) - const own = started(publishMountPointManifest([Y], [])!.file, thisProcess()) + const own = started(publish([Y]), thisProcess()) expect(collectMountPoints('all')).toEqual([]) expect(collectMountPoints('all')).toEqual([]) for (const kept of [X, Y, theirs, own, recordOf(theirs), recordOf(own)]) { expect(existsSync(kept)).toBe(true) } - expect([...namedMountPoints().live].sort()).toEqual([X, Y].sort()) + expect(live()).toEqual([X, Y].sort()) }) it('is finished once no process on its record runs, though the process that wrote it does', () => { @@ -465,7 +534,7 @@ describe.if(isLinux)('The mount point manifests', () => { pid: deadPid(), start: '1', }) - expect([...namedMountPoints().live]).toEqual([]) + expect(live()).toEqual([]) expect(collectMountPoints('none')).toEqual([X]) expect(existsSync(manifest)).toBe(false) expect(existsSync(recordOf(manifest))).toBe(false) @@ -631,7 +700,7 @@ describe.if(isLinux)('The mount point manifests', () => { // Though its writer runs: whoever claimed it knew its command to be over. const claim = claimed(manifestOfADeadProcess([X], thisProcess())) manifestOfADeadProcess([X, Y]) - expect([...namedMountPoints().named].sort()).toEqual([X, Y].sort()) + expect(named()).toEqual([X, Y].sort()) expect(live()).toEqual([]) expect(collectMountPoints().sort()).toEqual([X, Y].sort()) expect(existsSync(claim)).toBe(false) @@ -655,17 +724,17 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(Y) const manifest = manifestOfADeadProcess([X, Y]) const claim = claimed(manifest) - const removal = atTheRemovalOf(X, () => { + const removal = atTheRemovalOf(Y, () => { fs.renameSync(claim, manifest) started(manifest, thisProcess()) }) try { - // Y stays: the manifest is at its own name by the time Y's turn comes. - expect(collectMountPoints()).toEqual([X]) + // X stays: the manifest is at its own name by the time X's turn comes. + expect(collectMountPoints()).toEqual([Y]) } finally { removal.restore() } - expect(existsSync(Y)).toBe(true) + expect(existsSync(X)).toBe(true) expect(readdirSync(DIR).sort()).toEqual( [basename(manifest), basename(recordOf(manifest))].sort(), ) @@ -690,7 +759,7 @@ describe.if(isLinux)('The mount point manifests', () => { return (open as (...args: unknown[]) => number)(file, ...rest) }) as never) try { - expect([...namedMountPoints().named]).toEqual([X]) + expect(named()).toEqual([X]) expect(claim).toBeDefined() expect(live()).toEqual([X]) expect(collectMountPoints()).toEqual([]) @@ -717,8 +786,9 @@ describe.if(isLinux)('The mount point manifests', () => { return (open as (...args: unknown[]) => number)(file, ...rest) }) as never) try { - // What it names cannot be listed, so what any other names is held. - expect(live()).toEqual([Y]) + // What it names cannot be listed. + expect(live()).toBeUndefined() + expect(named()).toBeUndefined() expect(collectMountPoints()).toEqual([]) } finally { spy.mockRestore() @@ -793,7 +863,7 @@ describe.if(isLinux)('The mount point manifests', () => { /** What a pass removes, and the set a caller is given, with that in place. */ function withThat(injected: { restore(): void }): { removed: string[] - held: string[] + held: string[] | undefined } { try { return { held: live(), removed: collectMountPoints() } @@ -836,10 +906,10 @@ describe.if(isLinux)('The mount point manifests', () => { // file to drop for being unreadable. const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) utimesSync(manifest, hoursAgo, hoursAgo) - // What it names cannot be listed, so what any other names is held. + // What it names cannot be listed, so nobody can say what is held. expect(withThat(failing(manifest, code, at))).toEqual({ removed: [], - held: [Y], + held: undefined, }) expect(existsSync(X)).toBe(true) expect(existsSync(manifest)).toBe(true) @@ -879,8 +949,6 @@ describe.if(isLinux)('The mount point manifests', () => { const gone = (): string => statLine({ pid: deadPid(), start: '1' }) const notARecord: [string, (record: string) => void][] = [ - ['empty, as between the shell making it and writing to it', r => write(r)], - ['cut short of its end of line', r => write(r, gone().slice(0, -1))], ['cut short of the start time', r => write(r, `${gone().slice(0, 30)}\n`)], ['not the line of a process', r => write(r, 'ended\n')], [ @@ -924,6 +992,137 @@ describe.if(isLinux)('The mount point manifests', () => { }, 15000) } + // ---- a record with no whole line on it is no record ---- + // + // The command line execs bubblewrap only once its whole line is written. What + // a shell that was killed before that leaves must not vouch for ever, and + // nothing may become removable by its age alone. + + const MONTH_MS = 30 * 24 * 3600 * 1000 + const unwritten: [string, () => string][] = [ + ['empty, as its shell makes it', () => ''], + ['cut short of its end of line', () => gone().slice(0, -1)], + ] + for (const [what, text] of unwritten) { + it(`collects a manifest whose writer is gone and whose record is ${what}`, () => { + leftover(X) + write(recordOf(manifestOfADeadProcess([X])), text()) + expect(live()).toEqual([]) + expect(collectMountPoints('none')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + for (const [what, text] of [ + ['not there yet', undefined], + ...unwritten, + ] as const) { + it(`keeps, however old, the manifest of a running process that has not released it, its record ${what}`, () => { + // A wrap held up between publishing and its command putting itself on + // record, for longer than every threshold there is. + leftover(X) + const own = publish([X]) + const theirs = manifestOfADeadProcess([X], thisProcess()) + const longAgo = new Date(Date.now() - MONTH_MS) + for (const manifest of [own, theirs]) { + const body = JSON.parse(readFileSync(manifest, 'utf8')) as object + write(manifest, JSON.stringify({ ...body, created: longAgo.getTime() })) + if (text !== undefined) write(recordOf(manifest), text()) + for (const file of [manifest, recordOf(manifest)]) { + if (existsSync(file)) utimesSync(file, longAgo, longAgo) + } + } + expect(collectMountPoints('none')).toEqual([]) + expect(live()).toEqual([X]) + expect(claims()).toEqual([]) + + // Its own goes when the caller says the command is over; the other + // process's is not this one's to release. + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(own)).toBe(false) + expect(existsSync(theirs)).toBe(true) + expect(existsSync(X)).toBe(true) + }) + } + + it('goes by the whole lines of a record whose last line is cut short', () => { + // The same command line run again, its shell not yet done writing. + leftover(X) + const manifest = manifestOfADeadProcess([X], thisProcess()) + write(recordOf(manifest), statLine(thisProcess()) + gone().slice(0, -1)) + expect(collectMountPoints('none')).toEqual([]) + write(recordOf(manifest), gone() + gone().slice(0, -1)) + expect(collectMountPoints('none')).toEqual([X]) + }) + + it('is not held up by a record with no whole line and no manifest, and drops it past the grace', () => { + // Nothing names a path, so it protects none. + leftover(X) + started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + const [young, old, older] = ['4242', '4243', '4244'].map(pid => + join(DIR, `${pid}-0123456789abcdef.started`), + ) as [string, string, string] + write(young) + write(old) + write(older, gone().slice(0, -1)) + // Its shell may be about to write to it: not by the clock of a slow run. + const soon = new Date(Date.now() + 60_000) + utimesSync(young, soon, soon) + const aSecondAgo = new Date(Date.now() - 1000) + utimesSync(old, aSecondAgo, aSecondAgo) + utimesSync(older, new Date(Date.now() - MONTH_MS), aSecondAgo) + + expect(live()).toEqual([]) + expect(named()).toEqual([X]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([basename(young)]) + }) + + /** Hides `name` from the next listing of the manifest directory. */ + function missedByTheNextListing(name: string): { restore(): void } { + const readdir = fs.readdirSync + let hidden = false + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + const names = (readdir as (d: unknown, o: unknown) => string[])( + dir, + options, + ) + if (dir !== DIR || hidden) return names + hidden = true + return names.filter(listed => listed !== name) + }) as never) + return { restore: () => spy.mockRestore() } + } + + for (const [what, text] of [ + ...unwritten, + ['that of a process that runs', () => statLine(thisProcess())], + ] as const) { + it(`finds by name a manifest the listing missed, its record ${what}, and leaves that record`, () => { + // Published, or renamed, while the directory was being listed: the record + // shows and the manifest does not. Taken for a record without a manifest + // it would be dropped, from under the shell about to write to it. + leftover(X) + const manifest = manifestOfADeadProcess([X], thisProcess()) + write(recordOf(manifest), text()) + const longAgo = new Date(Date.now() - MONTH_MS) + utimesSync(recordOf(manifest), longAgo, longAgo) + for (const look of [named, live, () => collectMountPoints('none')]) { + const listing = missedByTheNextListing(basename(manifest)) + try { + expect(look()).toEqual(look === named || look === live ? [X] : []) + } finally { + listing.restore() + } + } + expect(existsSync(recordOf(manifest))).toBe(true) + expect(existsSync(X)).toBe(true) + }) + } + it("keeps what a manifest names while its record is somebody else's", () => { leftover(X) const manifest = started(manifestOfADeadProcess([X]), { @@ -950,36 +1149,200 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(record)).toBe(true) }) - it('removes nothing when the manifests cannot be listed', () => { - leftover(X) - manifestOfADeadProcess([X]) - const readdir = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - dir: unknown, - options: unknown, + /** Has this process fail to write its next manifest, as on a full disk. */ + function unrecorded(paths: string[]): void { + const write = fs.writeFileSync + const spy = spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] ) => { - if (dir === DIR) { - throw Object.assign(new Error(`EMFILE: ${DIR}`), { code: 'EMFILE' }) + if (String(file).endsWith('.json.tmp')) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) } - return (readdir as (d: unknown, o: unknown) => unknown)(dir, options) + return (write as (...args: unknown[]) => void)(file, ...rest) }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(publishMountPointManifest(paths, [])).toBeUndefined() } finally { spy.mockRestore() } + } + + it('removes nothing when the manifests cannot be listed', () => { + // Not even what this process could not record and would remove on its own + // word: a manifest that cannot be listed may name it too. + leftover(X) + unrecorded([X]) + const listing = failingCall('readdirSync', DIR, 'EMFILE') + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + listing.restore() + } expect(existsSync(X)).toBe(true) + expect(collectMountPoints('all')).toEqual([X]) }) + it('takes every manifest for live when it cannot tell which PID namespace it is in', () => { + // With no /proc every process asked after reads as gone. + leftover(X) + started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + forgetMountPointManifestDirectory() + const spy = spyOn(fs, 'readlinkSync').mockImplementation((() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) as never) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + forgetMountPointManifestDirectory() + } + expect(collectMountPoints()).toEqual([X]) + }) + + for (const [what, then] of [ + [ + 'the directory cannot be listed again', + () => failingCall('readdirSync', DIR, 'EMFILE'), + ], + [ + 'a manifest that has arrived cannot be read', + () => planted('1-garbage.json', '{ not json'), + ], + ] as const) { + it(`stops removing, and keeps its claim, once ${what}`, () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const manifest = manifestOfADeadProcess([X, Y]) + let injected: { restore(): void } | undefined + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation((( + file: string, + ) => { + unlink(file) + // The moment the first mount point has gone. + injected ??= then() + }) as never) + try { + expect(collectMountPoints()).toEqual([Y]) + } finally { + spy.mockRestore() + injected?.restore() + } + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([claimOf(manifest)]) + expect(collectMountPoints()).toEqual([X]) + }) + } + // ---- a manifest is only a claim about a path --------------------------- + // bubblewrap makes a file mount point 0444 from 0.5.0, and 0666 less the + // umask before. + for (const mode of [0o444, 0o644, 0o666, 0o600]) { + it(`takes an empty file of mode 0${mode.toString(8)} that a manifest names for a mount point`, () => { + writeFileSync(X, '') + chmodSync(X, mode) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + + started(manifest, { pid: deadPid(), start: '1' }) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + expect(existsSync(X)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + // ---- a directory mount point, and what lies in it ---- + + /** `.claude`, as one sandbox made it, and what others nested in it. */ + function nested(): { Q: string; inside: string[] } { + const Q = join(BASE, '.claude') + const inside = [join(Q, 'agents'), join(Q, 'commands')] + mkdirSync(Q) + inside.forEach(leftover) + return { Q, inside } + } + + for (const order of ['as listed', 'the other way round']) { + it(`removes what lies in a directory before the directory, the manifests read ${order}`, () => { + const { Q, inside } = nested() + manifestOfADeadProcess([Q, ...inside]) + manifestOfADeadProcess([Q]) + manifestOfADeadProcess(inside) + const readdir = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + const names = (readdir as (d: unknown, o: unknown) => string[])( + dir, + options, + ) + return order === 'as listed' ? names : names.reverse() + }) as never) + try { + expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + } finally { + spy.mockRestore() + } + expect(existsSync(Q)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + it('keeps its claim on a directory for as long as what lies in it is named by a manifest that is kept', () => { + // Dropped, nothing would name the directory once it is empty. + const { Q, inside } = nested() + const outer = manifestOfADeadProcess([Q]) + const inner = started(manifestOfADeadProcess(inside), thisProcess()) + expect(collectMountPoints()).toEqual([]) + expect(claims()).toEqual([claimOf(outer)]) + + started(inner, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + expect(readdirSync(DIR)).toEqual([]) + }) + + it.if(NOT_ROOT)( + 'keeps its claim on a directory when what lies in it could not be removed', + () => { + const { Q, inside } = nested() + const outer = manifestOfADeadProcess([Q]) + manifestOfADeadProcess(inside) + chmodSync(Q, 0o555) + try { + expect(collectMountPoints()).toEqual([]) + expect(claims()).toContain(claimOf(outer)) + } finally { + chmodSync(Q, 0o755) + } + expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + expect(readdirSync(DIR)).toEqual([]) + }, + ) + + it('keeps the directory a live manifest binds its mount points from', () => { + const source = join(BASE, 'source') + mkdirSync(source, { mode: 0o700 }) + manifestOfADeadProcess([], { sources: [source] }) + const live = started( + manifestOfADeadProcess([], { sources: [source] }), + thisProcess(), + ) + expect(collectMountPoints()).toEqual([]) + started(live, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([source]) + }) + it('leaves what a finished manifest names when it no longer looks like a mount point', () => { const written = join(BASE, 'written-to') writeFileSync(written, 'mine') chmodSync(written, 0o444) - const writable = join(BASE, 'writable') - writeFileSync(writable, '') - chmodSync(writable, 0o644) const linked = join(BASE, 'linked') leftover(linked) linkSync(linked, join(BASE, 'linked-again')) @@ -989,15 +1352,9 @@ describe.if(isLinux)('The mount point manifests', () => { const inUse = join(BASE, 'directory-in-use') mkdirSync(inUse) writeFileSync(join(inUse, 'file'), '') - const manifest = manifestOfADeadProcess([ - written, - writable, - linked, - pointer, - inUse, - ]) + const manifest = manifestOfADeadProcess([written, linked, pointer, inUse]) expect(collectMountPoints()).toEqual([]) - for (const kept of [written, writable, linked, pointer, inUse]) { + for (const kept of [written, linked, pointer, inUse]) { expect(existsSync(kept)).toBe(true) } expect(existsSync(join(BASE, 'pointed-at'))).toBe(true) @@ -1198,7 +1555,7 @@ describe.if(isLinux)('The mount point manifests', () => { /** Where this process, having settled on nothing yet, records X. */ function recordedIn(): string { forgetMountPointManifestDirectory() - return dirname(publishMountPointManifest([X], [])!.file) + return dirname(publish([X])) } it("are not kept in a directory that is somebody else's", () => { @@ -1241,7 +1598,7 @@ describe.if(isLinux)('The mount point manifests', () => { // Opened up since: made the user's alone again before it is used. mkdirSync(DIR, { mode: 0o777 }) chmodSync(DIR, 0o777) - expect(dirname(publishMountPointManifest([X], [])!.file)).toBe(DIR) + expect(dirname(publish([X]))).toBe(DIR) expect(statSync(DIR).mode & 0o777).toBe(0o700) rmSync(DIR, { recursive: true }) @@ -1250,7 +1607,7 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(target, { mode: 0o700 }) symlinkSync(target, DIR) try { - expect(dirname(publishMountPointManifest([X], [])!.file)).not.toBe(DIR) + expect(dirname(publish([X]))).not.toBe(DIR) expect(readdirSync(target)).toEqual([]) } finally { rmSync(DIR) @@ -1280,11 +1637,11 @@ describe.if(isLinux)('The mount point manifests', () => { `const { existsSync } = await import('node:fs')`, `const there = () => [at.X, at.directory, at.source].map(p => existsSync(p))`, `const published = m.publishMountPointManifest([at.X, at.directory], [at.source]) ?? null`, - `const named = m.namedMountPoints()`, + `const named = [...m.namedMountPoints().paths].sort()`, `const held = [...m.liveMountPoints()].sort()`, `const some = { removed: m.collectMountPoints('none'), there: there() }`, `const all = { removed: m.collectMountPoints('all').sort(), there: there() }`, - `console.log(JSON.stringify({ published, named: [...named.named].sort(), live: [...named.live].sort(), held, some, all, again: m.collectMountPoints('all') }))`, + `console.log(JSON.stringify({ published, named, held, some, all, again: m.collectMountPoints('all') }))`, ].join('\n'), // No runtime directory, and nothing can be made under the temp dir. { env: { XDG_RUNTIME_DIR: undefined, TMPDIR: readOnly } }, @@ -1293,7 +1650,6 @@ describe.if(isLinux)('The mount point manifests', () => { expect(JSON.parse(nowhere.stdout)).toEqual({ published: null, named: [X, directory].sort(), - live: [X, directory].sort(), held: [X, directory].sort(), some: { removed: [], there: [true, true, true] }, all: { @@ -1313,27 +1669,32 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) leftover(Y) started(manifestOfADeadProcess([X]), thisProcess()) - const write = fs.writeFileSync - const spy = spyOn(fs, 'writeFileSync').mockImplementation((( - file: unknown, - ...rest: unknown[] - ) => { - if (String(file).endsWith('.json.tmp')) { - throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) - } - return (write as (...args: unknown[]) => void)(file, ...rest) - }) as never) - try { - expect(publishMountPointManifest([X, Y], [])).toBeUndefined() - } finally { - spy.mockRestore() - } - expect([...namedMountPoints().named].sort()).toEqual([X, Y].sort()) + unrecorded([X, Y]) + expect(named()).toEqual([X, Y].sort()) expect(collectMountPoints('none')).toEqual([]) expect(collectMountPoints('all')).toEqual([Y]) expect(existsSync(X)).toBe(true) }) + it('publishes no manifest that it would not read', () => { + const paths = (count: number): string[] => + Array.from({ length: count }, (_, i) => + join(BASE, 'x'.repeat(80), String(i).padStart(6, '0')), + ) + // Each with its quotes and a comma, in a mebibyte less the other fields. + const fit = Math.floor((1024 * 1024 - 400) / (paths(1)[0]!.length + 3)) + expect(publishMountPointManifest(paths(fit + 10), [])).toBe('too-large') + // Nothing is written, or kept in memory: no command comes of that wrap. + expect(readdirSync(DIR)).toEqual([]) + expect(named()).toEqual([]) + // What it does publish, it reads. + expect(statSync(publish(paths(fit))).size).toBeGreaterThan(1_000_000) + expect(named()?.length).toBe(fit) + leftover(X) + manifestOfADeadProcess([X]) + expect(collectMountPoints('none')).toEqual([X]) + }) + // ---- what is read as a manifest ---- // // Only a regular file of the user's, of a size a manifest can have, is read: @@ -1380,11 +1741,13 @@ describe.if(isLinux)('The mount point manifests', () => { writeFileSync(name, finishedManifestText(paths).slice(0, -1)), ], [ - 'a manifest of another version', + 'a manifest of a later version without a field this one goes by', (name, paths) => writeFileSync( name, - finishedManifestText(paths).replace('"version":1', '"version":2'), + finishedManifestText(paths) + .replace('"version":1', '"version":2') + .replace('"sources":[]', '"origins":[]'), ), ], ['a directory', name => mkdirSync(name)], @@ -1405,19 +1768,23 @@ describe.if(isLinux)('The mount point manifests', () => { // the one thread there is. const look = (): unknown => { const looked = inAChild( - `console.log(JSON.stringify({ named: [...m.namedMountPoints().named], held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + [ + `const list = set => (set === undefined ? 'cannot tell' : [...set])`, + `console.log(JSON.stringify({ named: list(m.namedMountPoints()?.paths), held: list(m.liveMountPoints()), removed: m.collectMountPoints() }))`, + ].join('\n'), ) expect(looked.status).toBe(0) expect(looked.ms).toBeLessThan(AT_ONCE_MS) return JSON.parse(looked.stdout) } // It names nothing that can be read, and may name anything. - expect(look()).toEqual({ named: [Y], held: [Y], removed: [] }) + const inDoubt = { named: 'cannot tell', held: 'cannot tell', removed: [] } + expect(look()).toEqual(inDoubt) // Nor while a process is on its record, however old it is. aged(junk) writeFileSync(recordOf(junk), statLine(thisProcess())) - expect(look()).toEqual({ named: [Y], held: [Y], removed: [] }) + expect(look()).toEqual(inDoubt) rmSync(recordOf(junk)) expect(look()).toEqual({ named: [Y], held: [], removed: [Y] }) @@ -1435,7 +1802,7 @@ describe.if(isLinux)('The mount point manifests', () => { manifestOfADeadProcess([Y]) const theirs = asSomebodyElses(junk) try { - expect([...namedMountPoints().named]).toEqual([Y]) + expect(named()).toBeUndefined() expect(collectMountPoints()).toEqual([]) aged(junk) expect(collectMountPoints()).toEqual([Y]) @@ -1451,9 +1818,10 @@ describe.if(isLinux)('The mount point manifests', () => { // leave out. What it is told to leave out is never more than an empty // placeholder. - /** The set, as a sorted list. */ - function live(): string[] { - return [...liveMountPoints()].sort() + /** The set, as a sorted list; `undefined` where it cannot tell. */ + function live(): string[] | undefined { + const set = liveMountPoints() + return set && [...set].sort() } it('holds a mount point while a live manifest names it, file or directory, and no other path', () => { @@ -1463,10 +1831,7 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(directory) leftover(other) // Of this process, and not released: live. - const manifest = publishMountPointManifest( - [X, directory, join(BASE, 'never-there')], - [], - )!.file + const manifest = publish([X, directory, join(BASE, 'never-there')]) expect(live()).toEqual([X, directory].sort()) // Asking takes nothing away. @@ -1489,9 +1854,6 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('holds nothing a live manifest names that does not look like a mount point any more', () => { - const writable = join(BASE, 'writable') - writeFileSync(writable, '') - chmodSync(writable, 0o644) const linked = join(BASE, 'linked') leftover(linked) linkSync(linked, join(BASE, 'linked-again')) @@ -1506,10 +1868,7 @@ describe.if(isLinux)('The mount point manifests', () => { const theirDirectory = join(BASE, 'somebody-elses-directory') mkdirSync(theirDirectory) leftover(X) - publishMountPointManifest( - [writable, linked, pointer, inUse, theirs, theirDirectory, X], - [], - ) + publish([linked, pointer, inUse, theirs, theirDirectory, X]) // As another user's would look: nothing here can make one. const lstat = fs.lstatSync const spy = spyOn(fs, 'lstatSync').mockImplementation((( @@ -1545,21 +1904,99 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([X]) }) - it('holds what any manifest names while something in the directory cannot be read, and of that only what looks like a mount point', () => { - // What another version of this library wrote, say. What it names cannot be - // listed, so while the clean-up takes it to be live every named path - // counts. - const written = join(BASE, 'written-to') - writeFileSync(written, 'mine') - chmodSync(written, 0o444) + /** Has `call` on `target` fail with `code`. */ + function failingCall( + call: 'readdirSync' | 'lstatSync', + target: string, + code: string, + ): { restore(): void } { + const real = fs[call] as (f: unknown, o: unknown) => unknown + const spy = spyOn(fs, call).mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === target) { + throw Object.assign(new Error(`${code}: ${target}`), { code }) + } + return real(file, options) + }) as never) + return { restore: () => spy.mockRestore() } + } + + const planted = (name: string, text: string): { restore(): void } => { + writeFileSync(join(DIR, name), text) + return { restore: () => rmSync(join(DIR, name)) } + } + const cannotTell: [string, (manifest: string) => { restore(): void }][] = [ + [ + 'the directory cannot be looked at', + () => failingCall('lstatSync', DIR, 'EIO'), + ], + [ + 'the directory cannot be listed', + () => failingCall('readdirSync', DIR, 'EMFILE'), + ], + ['a manifest cannot be opened', manifest => failing(manifest, 'EMFILE')], + ['a manifest cannot be read', manifest => failing(manifest, 'EIO', 'read')], + ['something is no manifest', () => planted('1-garbage.json', '{ not json')], + [ + 'a process runs on a record without a manifest', + () => planted('1-0123456789abcdef.started', statLine(thisProcess())), + ], + ] + for (const [what, inject] of cannotTell) { + it(`says that it cannot tell, to a caller and to a wrap, when ${what}`, () => { + // An empty set would read as "nothing is live", and the caller would + // remove a mount point from under a sandbox that is running. + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + expect(live()).toEqual([X]) + const earlier = namedMountPoints() + const injected = inject(manifest) + try { + expect(liveMountPoints()).toBeUndefined() + expect(namedMountPoints()).toBeUndefined() + // A manifest already read is not read again, so that is no doubt. + if (!what.startsWith('a manifest')) { + expect(namedMountPoints(earlier)).toBeUndefined() + } + } finally { + injected.restore() + } + expect(live()).toEqual([X]) + }) + } + + it('holds a path a live manifest names that cannot be looked at', () => { leftover(X) - manifestOfADeadProcess([X, written]) - const stranger = join(DIR, '1-garbage.json') - writeFileSync(stranger, '{ not json') - expect(live()).toEqual([X]) + started(manifestOfADeadProcess([X]), thisProcess()) + const look = failingCall('lstatSync', X, 'EACCES') + try { + expect(live()).toEqual([X]) + } finally { + look.restore() + } + }) - aged(stranger) - expect(live()).toEqual([]) + it('answers a wrap with an earlier reading while the directory lists nothing new, and reads again when it does', () => { + const [Y, Z] = [join(BASE, 'second.lock'), join(BASE, 'third.lock')] + manifestOfADeadProcess([X]) + const first = namedMountPoints()! + const open = spyOn(fs, 'openSync') + try { + expect(namedMountPoints(first)).toBe(first) + expect(open).not.toHaveBeenCalled() + } finally { + open.mockRestore() + } + // The caller knows what it has published itself. + const own = publish([Y]) + expect(namedMountPoints(first, own)).toBe(first) + expect([...namedMountPoints(first)!.paths].sort()).toEqual([X, Y].sort()) + manifestOfADeadProcess([Z]) + expect([...namedMountPoints(first, own)!.paths].sort()).toEqual( + [X, Y, Z].sort(), + ) }) it('is one listing of the directory, and changes nothing in it', () => { @@ -1578,7 +2015,7 @@ describe.if(isLinux)('The mount point manifests', () => { const readdir = spyOn(fs, 'readdirSync') const rename = spyOn(fs, 'renameSync') - let listed: string[] + let listed: string[] | undefined let listings: number try { listed = live() diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index bdbc8b00c..9a3e73844 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -164,19 +164,24 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths } - it('is covered with /dev/null like an absent path, named, and removed after the command', async () => { - const left = plantLeftover(LOCK) + // 0444 is what bubblewrap makes from 0.5.0, 0666 less the umask before. + it.each([0o444, 0o644, 0o666, 0o600])( + 'is covered with /dev/null like an absent path, named, and removed after the command, whatever its mode: %o', + async mode => { + const left = plantLeftover(LOCK) + chmodSync(LOCK, mode) - const command = await wrap([LOCK]) + const command = await wrap([LOCK]) - expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) - expect(countMounts(command, '--ro-bind', LOCK, LOCK)).toBe(0) - expect(namedBy(command)).toEqual([LOCK]) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(countMounts(command, '--ro-bind', LOCK, LOCK)).toBe(0) + expect(namedBy(command)).toEqual([LOCK]) - cleanupBwrapMountPoints() - expect(existsSync(LOCK)).toBe(false) - expect(existsSync(left)).toBe(false) - }) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(left)).toBe(false) + }, + ) it('stays while a wrap of this process is outstanding, and goes with the last of them', async () => { plantLeftover(LOCK) @@ -198,13 +203,6 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { // somebody's file: bound onto itself as an existing deny path, and still // there afterwards. const somebodysFiles: [string, (p: string) => void][] = [ - [ - 'an empty file with write bits (a lockfile, a file made empty on purpose)', - (p: string) => { - writeFileSync(p, '') - chmodSync(p, 0o644) - }, - ], [ 'a read-only file with content', (p: string) => { @@ -249,29 +247,33 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { }, ) - // ---- an empty read-only file that no manifest names ---- + // ---- an empty file that no manifest names ---- // // It is the caller's own, whatever it looks like. It is denied like any // existing path, by a read-only bind onto itself, and nothing ever removes // it. - it("is the caller's own where no manifest names it: bound onto itself, named by the wrap nowhere, and never removed", async () => { - plantEmptyReadOnlyFile(LOCK) - // A manifest that names something else changes nothing about it. - manifestNaming([join(GIT_DIR, 'other.lock')]) + it.each([0o444, 0o644])( + "is the caller's own where no manifest names it: bound onto itself, named by the wrap nowhere, and never removed (mode %o)", + async mode => { + plantEmptyReadOnlyFile(LOCK) + chmodSync(LOCK, mode) + // A manifest that names something else changes nothing about it. + manifestNaming([join(GIT_DIR, 'other.lock')]) - const command = await wrap([LOCK]) + const command = await wrap([LOCK]) - expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) - expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) - expect(namedBy(command)).toEqual([]) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + expect(namedBy(command)).toEqual([]) - cleanupBwrapMountPoints() - cleanupBwrapMountPoints({ force: true }) - const kept = lstatSync(LOCK) - expect(kept.size).toBe(0) - expect(kept.mode & 0o777).toBe(0o444) - }) + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + const kept = lstatSync(LOCK) + expect(kept.size).toBe(0) + expect(kept.mode & 0o777).toBe(mode) + }, + ) it("is the caller's own at a mandatory deny path as well, beside the mount points the wrap makes for the others", async () => { const project = join(AREA, 'repo') @@ -445,7 +447,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { async () => { plantLeftover(LOCK) - const write = `echo ${BOOTED}; echo x > ${LOCK}; echo rc=$?` + // Removed first: a file without a write bit refuses a plain write by its + // mode alone, and only a bind refuses the removal. + const write = `echo ${BOOTED}; rm -f ${LOCK}; echo x > ${LOCK}; echo rc=$?` const overLeftover = run(await wrap([LOCK], write)) expect(overLeftover.stdout).toContain(BOOTED) expect(overLeftover.stdout).toMatch(/rc=[1-9]/) @@ -491,6 +495,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { ].join('\n'), ) const killed = spawnSync(process.execPath, [script], { + env: { ...process.env }, encoding: 'utf8', timeout: 30000, }) @@ -504,16 +509,12 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const command = await wrap( [LOCK], - `echo ${BOOTED}; echo x > ${LOCK}; echo rc=$?`, + `echo ${BOOTED}; rm -f ${LOCK}; echo x > ${LOCK}; echo rc=$?`, ) expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) const after = run(command) expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) - // A manifest whose writer is gone and that no sandbox has locked counts - // as live for half a second, so the cleanup that takes its mount point - // away is the first one after that. - await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) }, From ab17aadabf6f3cc3b7f7a5bd56d7adb93dbaba3d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 22:37:15 +0000 Subject: [PATCH 086/141] linux: a plan agrees with itself, a plan given up is released, and no refusal lasts for ever Each deny path took its own look at the host. Another sandbox's bubblewrap could make `.claude` between two of them, and one plan then put an empty read-only directory at `.claude` and a mount point of its own inside it, which bubblewrap cannot make: the command was refused its start. Two commands a few milliseconds apart in one process were enough. What lies at or under a placeholder of this plan is now covered by it, whatever is on the host by then. A plan given up, or a wrap that threw after publishing, had its manifest unlinked. Where that manifest had become the only one naming a leftover, the file was named by nothing from then on and stayed for good. A discard is now a release: a pass collects it like a finished command. `mount_points_unreadable` refuses every wrap of the user that restricts writes, and four kinds of stray file made it last for ever. No lasting state of the directory does so now. A record with no manifest beside it names nothing, and causes doubt only while it can be read and a process on it is running. What cannot be opened at a manifest's name because of what it is counts like junk: for an hour. What this process is short of (EMFILE and the like) is never ended by a file's age. A directory that kept changing under the reading is a failed plan, so the retryable `mount_points_changed`. The refusal names the file and the reason. Also: `-p` where /bin/sh is bash; a file mount point that is bound from is named like a directory; the empty source directory of a wrap that throws is removed; liveMountPoints() logs why it cannot tell. --- README.md | 18 ++-- src/sandbox/bwrap-mount-manifests.ts | 144 ++++++++++++++++++--------- src/sandbox/linux-sandbox-utils.ts | 125 +++++++++++++++-------- 3 files changed, 190 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index cbe5a13ce..6768a1729 100644 --- a/README.md +++ b/README.md @@ -742,21 +742,21 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every directory mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent. Where neither is there it is `/bin/sh`, and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and does not hold a manifest's fields counts so for an hour, and is dropped then; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. - **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full temp dir), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (the manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken from one listing of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). Off Linux the set is always empty. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (the manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). Off Linux the set is always empty. - **Known limits.** - - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. + - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a reboot or the last logout (`$XDG_RUNTIME_DIR` is a tmpfs that logind removes), a container restart with the project on a volume, a cleaner of the temp dir. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. - - _An older release beside this one._ A process of 0.0.77 or earlier takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running. The protection holds between processes of this release and later. + - _An older release beside this one._ A process of 0.0.77 takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running; one of an earlier release removes at its cleanup what it made itself, whoever relies on it by then. The protection holds between processes of this release and later. - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them. - - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads ` -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, and the binds of the root and of the manifest kept on the line ahead of `--args`, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -765,7 +765,7 @@ Filesystem restrictions are enforced at the OS level: - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest, and a second run is then refused. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read (this process is out of descriptors, or something that does not hold a manifest's fields, and is less than an hour old, sits among them), so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 9758cd461..46c5f30c1 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -9,8 +9,8 @@ * counting for itself. * * The sandbox vouches for itself. Each wrap writes a manifest, `.json`, into - * a per-user directory, naming its mount points and every directory mount point - * of another sandbox that its own mounts lie in or bind from. The command line + * a per-user directory, naming its mount points and every mount point of + * another sandbox that its own mounts lie in or bind from. The command line * it hands out is a shell that appends its own /proc/self/stat line to * `.started` and then execs bubblewrap: the process on record is bubblewrap, * recorded before it has made a mount, and the wrap always passes @@ -26,6 +26,14 @@ * claim. Whatever cannot be read or asked (a manifest, a record, /proc) counts * as live: only "no such process" and a different start time end a sandbox. * + * A reading in doubt refuses every wrap of this user that restricts writes, so + * NO LASTING STATE OF THE DIRECTORY REFUSES WRAPS FOR EVER. Every source of doubt + * is either a condition of this process that clears by itself (EMFILE, ENFILE, + * ENOMEM, EIO on a read), which the age of a file never ends, or a property of + * what is at a name, which has an end: an hour, or the process on its record. + * No sandboxed command can write the directory (see below), so what is there + * and should not be comes from an accident on the host. + * * The manifest directories are bound read-only into every sandbox that * restricts writes, and the directories above them inside a write root are * pinned, so a sandboxed command can neither rewrite a manifest nor swap the @@ -47,7 +55,10 @@ import { z } from 'zod/v3' import { logForDebugging } from '../utils/debug.js' import { isAbsenceErrno } from './sandbox-utils.js' -/** The only manifest layout this version writes and reads. */ +/** + * The manifest layout this version writes. It reads this one and any later one, + * by the fields it knows (see {@link ManifestSchema}). + */ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' const STARTED_SUFFIX = '.started' @@ -78,7 +89,7 @@ const LISTING_GOOD_FOR_MS = 0.25 * How often a reading starts over because a listed manifest was gone when it * was opened (claimed, given back or collected meanwhile) before it gives up. */ -const LISTING_ATTEMPTS = 4 +const LISTING_ATTEMPTS = 8 /** * What is at a manifest's name and is not one this version reads counts as @@ -159,6 +170,11 @@ const ownManifests = new Map< */ const unrecorded = { paths: new Set(), sources: new Set() } +/** Has `source` removed at this process's own clean-up if no manifest keeps it. */ +export function keepTrackOfMountSource(source: string): void { + unrecorded.sources.add(source) +} + /** * Which of this process's own manifests a collect may release. Only the caller * knows that a command is over: @@ -467,6 +483,23 @@ export function removePrivateManifestDirectory(): void { /** What is at a name and is not what this library keeps there. */ class NotOurs extends Error {} +/** + * Whether `file` is what an open does not refuse for what it is: a regular file + * of the user's that its owner may read. What cannot be looked at may be. + */ +function isOwnReadableFile(file: string): boolean { + try { + const stat = fs.lstatSync(file) + return ( + stat.isFile() && + stat.uid === process.getuid?.() && + (stat.mode & 0o400) !== 0 + ) + } catch { + return true + } +} + /** * What `file` holds, or `undefined` where nothing is there. Only a regular file * of the user's, no larger than `max`, is read, through a descriptor opened @@ -481,10 +514,11 @@ function readOwnFile(file: string, max: number): string | undefined { fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, ) } catch (e) { - const code = (e as NodeJS.ErrnoException).code - if (code === 'ENOENT') return undefined - // What O_NOFOLLOW says of a link. - throw code === 'ELOOP' ? new NotOurs(`${file} is a link`) : e + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined + // A link, a socket, a file of mode 000: that lasts. Anything else passes. + throw isOwnReadableFile(file) + ? e + : new NotOurs(`${file} is not a readable regular file of this user's`) } try { const stat = fs.fstatSync(fd) @@ -536,13 +570,17 @@ function readManifest(file: string): Manifest | undefined { * regular file, or holds a line that is no /proc/PID/stat line vouches: only a * process that is gone says its sandbox has ended. * + * Not so an `orphan`, which has no manifest that can be read: it names no path, + * so it protects none. Only a process that can be asked after vouches there, and + * an error that says nothing of the file. + * * What follows the last newline counts as not written, an empty record * included. The command line execs bubblewrap only once its whole line is * written, so no sandbox is behind it yet, and one that follows is refused its * start by a claim like any that has not put itself on record. Taking it for a * running sandbox would keep what a shell killed there named for ever. */ -function recordVouches(record: string): boolean | undefined { +function recordVouches(record: string, orphan = false): boolean | undefined { let lines: string[] try { const text = readOwnFile(record, RECORD_MAX_BYTES) @@ -550,8 +588,8 @@ function recordVouches(record: string): boolean | undefined { return undefined } lines = text.split('\n').slice(0, -1) - } catch { - return true + } catch (e) { + return !(orphan && e instanceof NotOurs) } if (lines.length === 0) { return undefined @@ -559,9 +597,9 @@ function recordVouches(record: string): boolean | undefined { return lines.some(line => { const pid = /^(\d+) \(/.exec(line)?.[1] const start = startTimeFromProcStat(line) - return ( - pid === undefined || start === undefined || isRunning(Number(pid), start) - ) + return pid === undefined || start === undefined + ? !orphan + : isRunning(Number(pid), start) }) } @@ -778,12 +816,15 @@ export function publishMountPointManifest( } /** - * Drop the manifest of a wrap that never produced a command. No sandbox can - * start under it, so the mount points it named are no one's. + * Let go of the manifest of a wrap that produced no command: released, and + * collected by a pass like any other. Not unlinked: a pass elsewhere may have + * spared a path on its word and dropped the manifest that named the path till + * then, and what nothing names any more stays on the host for good. */ export function discardMountPointManifest(file: string): void { - ownManifests.delete(file) - drop(file) + const own = ownManifests.get(file) + if (own !== undefined) own.over = true + collectMountPoints('none') } function drop(file: string): boolean { @@ -807,6 +848,8 @@ type Reading = { * may: nothing may then be removed on it. */ inDoubt?: string + /** All that doubt is, is that manifests kept moving while they were read. */ + keptChanging?: boolean /** What is there and is of no use to anybody any more. */ spent: string[] } @@ -821,16 +864,16 @@ type Reading = { * when opened, and for a record with no manifest listed beside it whose manifest * is there when asked for by name. * - * A record with no manifest at either name names no path and protects none: it - * puts the reading in doubt only while a process on it runs, and one with no - * whole line is dropped past {@link MANIFEST_GRACE_MS} (its shell may be about - * to write to it). + * A record with no manifest at either name puts the reading in doubt only while + * a process on it runs (see {@link recordVouches}) and is spent otherwise, one + * with no whole line past {@link MANIFEST_GRACE_MS} (its shell may be about to + * write to it). * * Whatever is at a manifest's name and cannot be read puts the reading in doubt - * too: always where that is an error (a sandboxed command can use up what this - * user may open), and where it is not a manifest of this version, while a - * process on its record runs or it is younger than {@link - * UNREADABLE_MANIFEST_MAX_AGE_MS}. + * too: always where that is an error of the moment (a sandboxed command can use + * up what this user may open), and where it is what is there that cannot be + * read as a manifest, while a process on its record runs or it is younger than + * {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. */ function readManifests(dir: string): Reading { const olderThan = (file: string, ms: number): boolean => { @@ -872,12 +915,13 @@ function readManifests(dir: string): Reading { } catch (e) { if ( e instanceof NotOurs && - recordVouches(`${idOf(file)}${STARTED_SUFFIX}`) !== true && + recordVouches(`${idOf(file)}${STARTED_SUFFIX}`, true) !== true && olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS) ) { reading.spent.push(file) } else { - reading.inDoubt ??= `${file}: ${String(e)}` + const why = e instanceof Error ? e.message : String(e) + reading.inDoubt ??= why.includes(file) ? why : `${file}: ${why}` } } } @@ -900,9 +944,9 @@ function readManifests(dir: string): Reading { moved = true continue } - const vouched = recordVouches(file) + const vouched = recordVouches(file, true) if (vouched === true) { - reading.inDoubt ??= `${file} has no manifest` + reading.inDoubt ??= `${file} has no manifest, and a process on it may be running` } else if (vouched === false || olderThan(file, MANIFEST_GRACE_MS)) { reading.spent.push(file) } @@ -912,6 +956,7 @@ function readManifests(dir: string): Reading { continue } if (moved) { + reading.keptChanging = reading.inDoubt === undefined reading.inDoubt ??= `${dir} kept changing` } return reading @@ -929,9 +974,11 @@ export type NamedMountPoints = { * (what only those name is a leftover the next pass removes), and what this * process could record nowhere. Makes nothing on the host. * - * `undefined` where the reading is in doubt: a path it does not name may then be - * a running sandbox's mount point all the same, and a wrap that took it for the - * caller's own would not name it and would lose it under its own sandbox. + * `inDoubt`, which says what could not be read and why, where the reading is in + * doubt: a path it does not name may then be a running sandbox's mount point + * all the same, and a wrap that took it for the caller's own would not name it + * and would lose it under its own sandbox. With `keptChanging`, only because + * other processes are busy in the directory: the next reading may do. * * `earlier` is a reading this call may answer with. A manifest is never * rewritten, so it still holds when the directory lists nothing it did not, @@ -940,7 +987,7 @@ export type NamedMountPoints = { export function namedMountPoints( earlier?: NamedMountPoints, own?: string, -): NamedMountPoints | undefined { +): NamedMountPoints | { inDoubt: string; keptChanging?: boolean } { try { const dir = onLinux() ? existingManifestDirectory() : undefined if (dir === undefined) { @@ -955,19 +1002,20 @@ export function namedMountPoints( return earlier } const reading = readManifests(dir) - if (reading.inDoubt === undefined) { - return { - paths: new Set([ - ...unrecorded.paths, - ...reading.manifests.flatMap(manifest => manifest.paths), - ]), - listed: new Set(reading.names), - } + if (reading.inDoubt !== undefined) { + return { inDoubt: reading.inDoubt, keptChanging: reading.keptChanging } } - } catch { + return { + paths: new Set([ + ...unrecorded.paths, + ...reading.manifests.flatMap(manifest => manifest.paths), + ]), + listed: new Set(reading.names), + } + } catch (e) { // The directory cannot be looked at, or listed. + return { inDoubt: String(e) } } - return undefined } /** @@ -1036,14 +1084,18 @@ export function liveMountPoints(): ReadonlySet | undefined { const dir = existingManifestDirectory() const reading = dir === undefined ? undefined : readManifests(dir) if (reading?.inDoubt !== undefined) { - return undefined + throw new Error(reading.inDoubt) } for (const manifest of reading?.manifests ?? []) { if (isLive(manifest)) { for (const mountPoint of manifest.paths) named.add(mountPoint) } } - } catch { + } catch (e) { + logForDebugging( + `[Sandbox Linux] Which mount points are live cannot be told (${String(e)})`, + { level: 'warn' }, + ) return undefined } for (const mountPoint of named) { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 01fda199c..7497388bf 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -11,6 +11,7 @@ import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, + keepTrackOfMountSource, kindOfMountPoint, mountPointManifestDirectories, namedMountPoints, @@ -460,6 +461,8 @@ function ensureEmptyMountSourceDir(): string { // directory lands on 0500. Set the mode here rather than let the check // above reject the directory this call has just made. fs.chmodSync(emptyMountSourceDir, 0o700) + // The wrap may throw before a manifest names it. + keepTrackOfMountSource(emptyMountSourceDir) return emptyMountSourceDir } @@ -735,13 +738,16 @@ export type LinuxSandboxProfileErrorCode = | 'too_many_mount_points' /** * The mount point manifests cannot be read, so a path cannot be told from a - * mount point a running sandbox relies on. Passing where this process is out - * of descriptors; lasting while something that is no manifest sits among them. + * mount point a running sandbox relies on. It ends by itself: when this + * process has descriptors again, when what sits among them and is no manifest + * is an hour old, when the process on a record with no manifest has ended. + * `.cause` is the file and the reason, which the message holds too. */ | 'mount_points_unreadable' /** * Other sandboxes' mount points kept being made or removed under what the - * profile relies on, each time the wrap planned it. Passing: wrap again. + * profile relies on, or their manifests kept moving while they were read, + * each time the wrap planned it. Passing: wrap again. */ | 'mount_points_changed' @@ -789,7 +795,8 @@ let stepShell: string[] | undefined /** * The shell the returned string runs its own steps in, before bubblewrap: found - * once, bash at one of its two usual places, else /bin/sh. + * once, bash at one of its two usual places, else /bin/sh, which is bash too on + * some systems (NixOS, Guix). * * bash, because where /bin/sh is dash the environment is rebuilt from the * shell's variables, and every entry whose name is no identifier (`a.b`, @@ -804,7 +811,16 @@ let stepShell: string[] | undefined */ function shellForSteps(): string[] { if (stepShell === undefined) { - const bash = ['/bin/bash', '/usr/bin/bash'].find(isExecutable) + const isBash = (shell: string): boolean => { + try { + return path.basename(fs.realpathSync(shell)) === 'bash' + } catch { + return false + } + } + const bash = + ['/bin/bash', '/usr/bin/bash'].find(isExecutable) ?? + ['/bin/sh'].find(isBash) stepShell = bash === undefined ? ['/bin/sh', '-c'] : [bash, '-p', '-c'] } return stepShell @@ -1699,10 +1715,14 @@ function pushReadDenyDirMounts( */ const MOUNT_PLAN_ATTEMPTS = 3 +/** Thrown in a plan that cannot be finished and may be the next time. */ +class MountPlanFailed extends Error {} + /** - * Generate filesystem bind mount arguments for bwrap. `undefined`, with nothing - * left published, when what the plan relied on on the host had changed by the - * time its manifest was: the caller plans again. + * Generate filesystem bind mount arguments for bwrap. `undefined`, with its + * manifest let go of, when what the plan relied on on the host had changed by + * the time that was published: the caller plans again, as it does when this + * throws {@link MountPlanFailed}. */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -1734,15 +1754,21 @@ async function generateFilesystemArgs( // What the manifests name, as of the last time it was asked. let named: NamedMountPoints | undefined const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { - named = namedMountPoints(named, ownManifest) - if (named === undefined) { + const now = namedMountPoints(named, ownManifest) + if ('inDoubt' in now) { + if (now.keptChanging) { + throw new MountPlanFailed(now.inDoubt) + } // Not "treat what is there as named": naming what may be the caller's // own makes it removable, from under a sandbox that bound it as that. + // The reason names the file: this refuses every wrap while it lasts. throw new LinuxSandboxProfileError( 'mount_points_unreadable', - 'The mount point manifests cannot be read, so what is at a denied path cannot be told from a mount point a running sandbox relies on', + `The mount point manifests cannot be read (${now.inDoubt}), so what is at a denied path cannot be told from a mount point a running sandbox relies on`, + now.inDoubt, ) } + named = now return named.paths } @@ -2225,8 +2251,8 @@ async function generateFilesystemArgs( // bwrap's ensure_file() falls through to creat() on a read-only mount. const seenDenyWrite = new Set() // Placeholder destination -> the index of its source in denyWriteArgs, so - // a later deny reaching the same destination can upgrade a /dev/null - // placeholder to the directory form in place (see the emission below). + // a later deny at or under that destination can upgrade a /dev/null + // placeholder to the directory form in place. const placeholderSourceArgIndex = new Map() // PRE-PASS (order-independent): record the directories the loop below // re-binds read-only, BEFORE any stub decision, so the read-only @@ -2468,6 +2494,37 @@ async function generateFilesystemArgs( continue } + // INVARIANT: a plan agrees with itself. Each deny path takes its own look + // at the host, and another sandbox's bubblewrap can make `.claude` between + // two of them: the later deny would then get a mount point of its own + // inside the read-only directory this plan has put at `.claude`, which + // bubblewrap cannot make. So what lies at or under a placeholder of this + // plan is covered by it, whatever is on the host by now. + // + // One mount point per destination, too: two binds on one make bwrap refuse + // to start when they disagree about its kind ("Can't mkdir : Not a + // directory"). The directory form wins: an empty read-only directory + // blocks creating the destination and everything below it exactly as + // /dev/null does, and stays traversable for the deeper deny. + let placeholder = normalizedPath + while ( + placeholder !== '/' && + !placeholderSourceArgIndex.has(placeholder) + ) { + placeholder = path.dirname(placeholder) + } + const placeholderAt = placeholderSourceArgIndex.get(placeholder) + if (placeholderAt !== undefined) { + if (placeholder !== normalizedPath) { + denyWriteArgs[placeholderAt] = emptySource ??= + ensureEmptyMountSourceDir() + } + logForDebugging( + `[Sandbox Linux] Reusing the mount point at ${placeholder} to block creation of ${normalizedPath}`, + ) + continue + } + // A mount point another sandbox made is an absent deny path in all but // name: bound onto itself it would not be named in this wrap's manifest // and would go, with this sandbox still bound over it, when its maker is @@ -2578,35 +2635,15 @@ async function generateFilesystemArgs( ? (emptySource ??= ensureEmptyMountSourceDir()) : '/dev/null' - // One mount point per destination. Deny paths are deduplicated on - // the deny path, but a placeholder lands on the first MISSING - // component, so two denies sharing one arrive here with a single - // destination — denyWrite '/.claude' together with the - // mandatory '/.claude/commands', in a project with no - // `.claude/`. Two binds there make bwrap refuse to start when they - // disagree about the destination's kind ("Can't mkdir : Not a - // directory"). The directory form wins the disagreement: an empty - // read-only directory blocks creating the destination and everything - // below it exactly as /dev/null does, and stays traversable for the - // deeper deny that asked for a directory. - const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent) - if (placeholderAt !== undefined) { - if (isIntermediate) denyWriteArgs[placeholderAt] = source - logForDebugging( - `[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`, - ) - continue - } denyWriteArgs.push('--ro-bind', source, firstNonExistent) placeholderSourceArgIndex.set( firstNonExistent, denyWriteArgs.length - 2, ) - // First writer wins for a destination several denies share (the - // reuse branch above returns before reaching this), and the record - // is purely additive: it only gives the tmpfs and mask comparisons - // below a second spelling to test, and `dest` itself is always - // tested. + // First writer wins for a destination several denies share (the later + // ones do not get this far), and the record is purely additive: it + // only gives the tmpfs and mask comparisons below a second spelling to + // test, and `dest` itself is always tested. denyWriteRawDests.set(firstNonExistent, rawPath) mountPoints.push(firstNonExistent) logForDebugging( @@ -3026,6 +3063,8 @@ async function generateFilesystemArgs( } // Those of them, and the directories above them, that a manifest names: // another sandbox's mount points, which go when no live manifest names them. + // A file among them was made again after the deny loop took it for the + // caller's own. const mountPointsReliedOn = (ownManifest?: string): string[] => { const found = new Set() const namedNow = boundFrom.size > 0 ? whatIsNamedNow(ownManifest) : found @@ -3034,7 +3073,7 @@ async function generateFilesystemArgs( if (namedNow.has(dir)) found.add(dir) } } - return [...found].filter(dir => kindOfMountPoint(dir) === 'directory') + return [...found].filter(relied => kindOfMountPoint(relied) !== undefined) } // Name the mount points this wrap relies on before the sandbox can start. @@ -3452,7 +3491,7 @@ export async function wrapCommandWithSandboxLinux( if (left === 0) { throw new LinuxSandboxProfileError( 'mount_points_changed', - `What the sandbox profile relies on on the host changed under each of ${MOUNT_PLAN_ATTEMPTS} plans: other sandboxes' mount points are being made and removed there`, + `What the sandbox profile relies on on the host changed under each of ${MOUNT_PLAN_ATTEMPTS} plans: other sandboxes' mount points, or their manifests, are being made and removed there`, ) } filesystem = await generateFilesystemArgs( @@ -3467,7 +3506,10 @@ export async function wrapCommandWithSandboxLinux( // Only an id the caller chose: the attribution key falls back to the // command text, which two wraps in flight may share. commandId, - ) + ).catch((error: unknown) => { + if (!(error instanceof MountPlanFailed)) throw error + return undefined + }) } // Held for the catch below: a wrap that produces no command has to let go // of the mount points it named. @@ -3576,8 +3618,7 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // No command came of this wrap, so no sandbox starts under its manifest - // and the mount points it named are no one's. + // No command came of this wrap, so no sandbox starts under its manifest. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } From 14d0b4caaa39d2bb4402f78843a4b6b95899ff40 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 22:37:15 +0000 Subject: [PATCH 087/141] test: pin the hour, a manifest's fields, and the record past the grace Four gaps: "an hour" held under a mutation to a minute; only a missing `sources` was tested of the fields a manifest must hold; two real-bubblewrap tests of a sandbox that outlives its wrapper finished inside the grace, so the grace kept the manifest live and not the record; and one asserted that a real mount point has no write bit, which fails on bubblewrap before 0.5.0. Also: a clean-up that names one command leaves other wraps' --args profiles open; short directory names in the preload, for socket paths. --- test/helpers/bwrap-argv.ts | 7 +- test/preload.ts | 11 +- test/sandbox/linux-bwrap-args-file.test.ts | 21 +- test/sandbox/mount-point-liveness.test.ts | 421 +++++++++++++++++++-- test/sandbox/mount-point-manifests.test.ts | 147 ++++++- test/sandbox/stale-mount-point.test.ts | 3 +- 6 files changed, 556 insertions(+), 54 deletions(-) diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 3fbdd71cc..bcca28271 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -1,4 +1,5 @@ -import { existsSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' +import { basename } from 'node:path' import { quote } from '../../src/utils/shell-quote.js' /** The $0 renderBwrapInvocation gives the shell that opens an over-long @@ -11,7 +12,9 @@ const ARGS_FILE_ARGV0 = 'srt-args' * this host: bash where it is at one of its two usual places, else /bin/sh. */ export const STEP_SHELL = ((): string => { - const bash = ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) + const bash = + ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) ?? + ['/bin/sh'].find(shell => basename(realpathSync(shell)) === 'bash') return bash === undefined ? '/bin/sh -c' : `${bash} -p -c` })() diff --git a/test/preload.ts b/test/preload.ts index 05aef7bd8..de92d48b8 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -10,9 +10,14 @@ import { join } from 'node:path' // that lists or attacks the manifests takes its own on top of these (see // helpers/private-manifest-directory.ts). if (process.platform === 'linux') { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-run-'))) - for (const name of ['XDG_RUNTIME_DIR', 'TMPDIR']) { - process.env[name] = join(base, name) + // Short names: suites keep Unix sockets beneath these, and a socket's path is + // limited to 108 bytes. + const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-'))) + for (const [name, dir] of [ + ['XDG_RUNTIME_DIR', 'x'], + ['TMPDIR', 't'], + ] as const) { + process.env[name] = join(base, dir) mkdirSync(process.env[name], { mode: 0o700 }) } // Once, after the last test file. diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index a9db7fa31..b4a326b99 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -395,10 +395,15 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { }) }) - it('keeps a pending profile through the clean-up after another command, and gives it back with the last', () => { - // A profile belongs to one wrap and nothing maps it back to a command, so - // none is closed while any wrap of this process is outstanding. - const seen = isolated(` + it.each([ + ['that names no command', ''], + ['that names one', `{ commandId: 'another' }`], + ])( + 'keeps a pending profile through a clean-up %s, and gives it back with the last', + (_how, first) => { + // A profile belongs to one wrap and nothing maps it back to a command, so + // none is closed while any wrap of this process is outstanding. + const seen = isolated(` const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. await wrap(small) @@ -407,13 +412,15 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { await wrap(overLong) await wrap(overLong) const held = openFds() - baseline - cleanupBwrapMountPoints() + cleanupBwrapMountPoints(${first}) const afterFirst = openFds() - baseline cleanupBwrapMountPoints() console.log(JSON.stringify({ held, afterFirst, afterSecond: openFds() - baseline })) `) - expect(seen).toEqual({ held: 2, afterFirst: 2, afterSecond: 0 }) - }, 30000) + expect(seen).toEqual({ held: 2, afterFirst: 2, afterSecond: 0 }) + }, + 30000, + ) it('refuses at wrap time, with the reason, when no directory takes an unnamed file', () => { // Both candidates read-only: tmpdir and /dev/shm. A profile that fits diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index b565d0536..b3cbe5b4c 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -13,6 +13,7 @@ import { realpathSync, rmSync, symlinkSync, + utimesSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -722,6 +723,56 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }, ) + for (const [sh, begins] of [ + ['/nix/store/bash-5.2/bin/bash', `/bin/sh -p -c '`], + ['/usr/bin/dash', `/bin/sh -c '`], + ] as const) { + it(`takes them in /bin/sh where bash is at neither, which is ${sh}: ${begins}`, async () => { + const access = fs.accessSync + const realpath = fs.realpathSync + const spies = [ + spyOn(fs, 'accessSync').mockImplementation((( + file: unknown, + mode: unknown, + ) => { + if (file === '/bin/bash' || file === '/usr/bin/bash') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return (access as (...args: unknown[]) => void)(file, mode) + }) as never), + spyOn(fs, 'realpathSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => + file === '/bin/sh' + ? sh + : (realpath as (...args: unknown[]) => string)( + file, + ...rest, + )) as never), + ] + try { + // The shell is found once, so in a copy of the library of its own. + const fresh = (await import( + `${JSON.parse(LIBRARY)}?${basename(sh)}` + )) as { + wrapCommandWithSandboxLinux: typeof wrapCommandWithSandboxLinux + } + expect( + await fresh.wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'sh', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + }), + ).toStartWith(begins) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + }) + } + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( 'hands the command every entry of the environment, whatever its name', async () => { @@ -1086,6 +1137,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { writeFileSync(LOCK, '') chmodSync(LOCK, 0o444) const theirs = manifestNaming([LOCK], process.pid) + // However old it is: what this process is short of says nothing of the file. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(theirs, hoursAgo, hoursAgo) const open = fs.openSync const spy = spyOn(fs, 'openSync').mockImplementation((( file: unknown, @@ -1097,7 +1151,16 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return (open as (...args: unknown[]) => number)(file, ...rest) }) as never) try { - expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + const wrapping = wrap('true') + expect(await refusal(wrapping)).toBe('mount_points_unreadable') + // It refuses every wrap while it lasts, so it says which file and why. + const said = (await wrapping.catch((e: unknown) => e)) as Error + for (const where of [said.message, said.cause]) { + expect(where).toContain(theirs) + expect(where).toContain('EMFILE') + } + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(LOCK)).toBe(true) } finally { spy.mockRestore() } @@ -1105,6 +1168,52 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(await wrap('true')).toContain(`--ro-bind /dev/null ${LOCK}`) }) + it('plans again, and does not call the manifests unreadable, when they keep moving while it reads them', async () => { + // Other processes are busy, in whatever project: listed, and gone when + // opened, is what a manifest claimed, given back or collected looks like. + writeFileSync(LOCK, '') + const theirs = manifestNaming([LOCK], process.pid) + const open = fs.openSync + let [opens, elusiveFor] = [0, Infinity] + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === theirs && opens++ < elusiveFor) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(await refusal(wrap('true'))).toBe('mount_points_changed') + expect(readdirSync(dirname(theirs))).toEqual([basename(theirs)]) + const inOnePlan = opens / 3 + // Which planning again does not mend where something else is amiss too. + const junk = join(dirname(theirs), '4242-00.json') + writeFileSync(junk, 'no manifest') + expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + rmSync(junk) + ;[opens, elusiveFor] = [0, inOnePlan] + expect(await wrap('true')).toContain(`--ro-bind /dev/null ${LOCK}`) + } finally { + spy.mockRestore() + } + }) + + it('is not asked of a wrap that restricts no write, which has no mount point', async () => { + mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + writeFileSync(join(runtime.manifestDir(), '4242-00.json'), 'no manifest') + expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + expect( + await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [AREA] }, + writeConfig: undefined, + }), + ).toContain(`--tmpfs ${AREA}`) + }) + it('refuses to wrap what needs a manifest larger than can be read, and publishes none', async () => { const many = Array.from({ length: 4000 }, (_, i) => join(AREA, `${'x'.repeat(240)}-${i}`), @@ -1154,6 +1263,27 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) }) + it("is named by a wrap that took it for the caller's own file, another sandbox having made it again meanwhile", async () => { + // No manifest named it when the wrap read them: the one that had was + // collected. Not named, it would go with the sandbox that made it again. + writeFileSync(LOCK, '') + const exists = fs.existsSync + let theirs: string | undefined + const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { + if (file === LOCK) theirs ??= manifestNaming([LOCK], process.pid) + return exists(file as string) + }) as never) + let command: string + try { + command = await wrap('true') + } finally { + spy.mockRestore() + } + expect(theirs).toBeDefined() + expect(command).toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + }) + it.if(BWRAP_CAN_NAMESPACE)( "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", async () => { @@ -1546,7 +1676,8 @@ describe.if(isLinux)( /** * Has a process wrap the held command, start it and go, and checks the - * premise: that process is gone and its sandbox is running. + * premise: that process is gone, its sandbox is running, and its manifest + * is past the grace. */ async function sandboxWhoseWriterHasGone( leave: string, @@ -1568,6 +1699,17 @@ describe.if(isLinux)( expect(existsSync(`/proc/${info.writer}`)).toBe(false) expect(existsSync(SIGNAL)).toBe(true) expect(runningTree(info.launcher).length).toBeGreaterThan(1) + // Past its grace, or the grace is what keeps the manifest live and not + // the process on its record. + const manifest = recordedBy(info.wrapped) + expect(manifest).toBeDefined() + const { created } = JSON.parse(readFileSync(manifest!, 'utf8')) as { + created: number + } + await waitFor( + 'the manifest to be past its grace', + () => Date.now() - created > 750, + ) return info } @@ -2075,6 +2217,48 @@ describe.if(isLinux)( 90000, ) + it.if(BWRAP_CAN_NAMESPACE)( + 'covers all that is denied in it, though another sandbox makes it while the wrap goes from one deny to the next', + async () => { + // Absent for the first deny and there for the second, it would be + // planned as an empty read-only directory and, inside that, a mount + // point bubblewrap cannot make. + const OTHER = join(DIRECTORY, 'agents') + const first = await wrap('true', [LEAF, OTHER]) + const lstat = fs.lstatSync + let ran = false + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + // The first deny is judged: the wrap looks at what it will bind from. + if (String(file).includes('claude-empty-') && !ran) { + ran = true + expect(run(first).status).toBe(0) + expect(existsSync(DIRECTORY)).toBe(true) + } + return (lstat as (...args: unknown[]) => unknown)(file, ...rest) + }) as never) + let second: string + try { + second = await wrap(`touch ${LEAF} ${OTHER}; echo rc=$?`, [ + LEAF, + OTHER, + ]) + } finally { + spy.mockRestore() + } + expect(ran).toBe(true) + expect(namedBy(second)).toEqual([DIRECTORY]) + expect(run(second)).toMatchObject({ status: 0, stdout: 'rc=1\n' }) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + }, + 90000, + ) + /** Runs `then` each time the wrap is about to publish a manifest. */ function atEachPublish(then: () => void): { restore(): void } { const write = fs.writeFileSync @@ -2088,42 +2272,223 @@ describe.if(isLinux)( return { restore: () => spy.mockRestore() } } - it('plans again when the directory has gone by the time its manifest is published', async () => { - // Until then nothing of this wrap's names it, and a clean-up in another - // process takes it away with the sandbox it was made for. - mkdirSync(DIRECTORY) - const left = manifestOfAKilledProcess([DIRECTORY]) - let published = 0 - const publishing = atEachPublish(() => { - if (published++ === 0) { - fs.rmdirSync(DIRECTORY) - rmSync(left) + /** + * Runs `then` when the wrap has decided where its mount points go and has + * yet to pin what lies above them: as it makes sure of the manifest + * directories. + */ + function beforeThePins(then: () => void): { restore(): void } { + const mkdir = fs.mkdirSync + const spy = spyOn(fs, 'mkdirSync').mockImplementation((( + dir: unknown, + ...rest: unknown[] + ) => { + if (dir === runtime.manifestDir()) then() + return (mkdir as (...args: unknown[]) => unknown)(dir, ...rest) + }) as never) + return { restore: () => spy.mockRestore() } + } + + for (const [when, early] of [ + ['by the time its manifest is published', false], + ['between its decision to nest in it and the pin', true], + ] as const) { + it(`plans again when the directory has gone ${when}`, async () => { + // Until it has published nothing of this wrap's names it, and a clean-up + // in another process takes it away with the sandbox it was made for. + // That pass spares the file, which the new manifest names by then, and + // drops the manifest it was collecting. + const spared = join(AREA, 'proj', '.bashrc') + writeFileSync(spared, '') + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([spared, DIRECTORY]) + const goes = (now: boolean): void => { + if (now && existsSync(left)) { + fs.rmdirSync(DIRECTORY) + rmSync(left) + } + } + let published = 0 + const publishing = atEachPublish(() => + goes(published++ === 0 && !early), + ) + const pinning = beforeThePins(() => goes(early)) + let command: string + try { + command = await wrap('true', [spared, LEAF]) + } finally { + pinning.restore() + publishing.restore() } + // The plan that was handed out found it absent, and covers it itself. + expect(published).toBe(2) + expect(command).not.toContain(`--ro-bind /dev/null ${LEAF}`) + expect(command).toMatch( + new RegExp(`--ro-bind \\S+/claude-empty-\\S+ ${DIRECTORY} `), + ) + // Nothing is left of the plan it gave up, nor of what only that named: + // found there, the file would be the caller's own from now on. + expect(command).toContain(`--ro-bind /dev/null ${spared}`) + expect(namedBy(command).sort()).toEqual([spared, DIRECTORY].sort()) + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + expect(readdirSync(runtime.manifestDir())).toEqual([ + basename(recordedBy(command)!), + ]) }) - let command: string + } + + for (const [what, meanwhile, names] of [ + [ + 'a directory it mounts a tmpfs on has gone', + () => fs.rmdirSync(join(AREA, 'hidden')), + () => [LEAF], + ], + [ + // Of a sandbox that has ended: the directory goes with the plan. + 'a manifest has come that names the directory it nests in', + () => manifestOfAKilledProcess([DIRECTORY]), + () => [DIRECTORY], + ], + ] as const) { + it(`plans again when ${what} by the time its manifest is published`, async () => { + mkdirSync(DIRECTORY) + mkdirSync(join(AREA, 'hidden')) + let published = 0 + const publishing = atEachPublish(() => { + if (published++ === 0) meanwhile() + }) + let command: string + try { + command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(AREA, 'hidden')] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LEAF] }, + }) + } finally { + publishing.restore() + } + expect(published).toBe(2) + expect(namedBy(command)).toEqual(names()) + }) + } + + it('takes what it cannot look at again for there', async () => { + // bubblewrap does not start on what it cannot bind. + let published = 0 + const publishing = atEachPublish(() => published++) + const stat = fs.statSync + const spy = spyOn(fs, 'statSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === AREA && published > 0) { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + } + return (stat as (...args: unknown[]) => unknown)(file, ...rest) + }) as never) try { - command = await wrap('true', [LEAF]) + await wrap('true', [LEAF]) } finally { + spy.mockRestore() publishing.restore() } - // The plan that was handed out found it absent, and covers it itself. - expect(published).toBe(2) - expect(command).not.toContain(`--ro-bind /dev/null ${LEAF}`) - expect(command).toMatch( - new RegExp(`--ro-bind \\S+/claude-empty-\\S+ ${DIRECTORY} `), + expect(published).toBe(1) + }) + + it('plans again when its manifest went to a directory settled on since the pins', async () => { + // Nothing is pinned above that one. Both shared names are taken as the + // wrap reads the manifests, so it publishes in a directory of its own. + const shared = [ + runtime.manifestDir(), + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ] + const readdir = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + ...rest: unknown[] + ) => { + const names = (readdir as (...args: unknown[]) => unknown)(dir, ...rest) + if (dir === shared[0]) { + for (const name of shared) { + fs.rmdirSync(name) + writeFileSync(name, '') + } + } + return names + }) as never) + let published = 0 + const publishing = atEachPublish(() => published++) + try { + const command = await wrap('true', [LEAF]) + expect(published).toBe(2) + const own = dirname(recordedBy(command)!) + expect(shared).not.toContain(own) + expect(countMounts(command, '--ro-bind', own, own)).toBe(1) + expect(readdirSync(own)).toEqual([basename(recordedBy(command)!)]) + } finally { + publishing.restore() + spy.mockRestore() + shared.forEach(name => rmSync(name)) + } + }) + + it('is let go of when a process exits whose wrap made no mount point of its own', () => { + mkdirSync(DIRECTORY) + manifestOfAKilledProcess([DIRECTORY]) + const file = join(BASE, 'exits.ts') + writeFileSync( + file, + [ + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(DIRECTORY)}] },`, + `})`, + ].join('\n'), ) - expect(namedBy(command)).toEqual([DIRECTORY]) - // Nothing is left of the plan it gave up. - expect(readdirSync(runtime.manifestDir())).toEqual([ - basename(recordedBy(command)!), - ]) + const exited = spawnSync(process.execPath, [file], { + env: { ...process.env }, + timeout: 60000, + cwd: import.meta.dir, + }) + expect(exited.status).toBe(0) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + expect(existsSync(DIRECTORY)).toBe(false) + }, 90000) + + const emptySources = (): string[] => + readdirSync(tmpdir()) + .filter(name => name.startsWith('claude-empty-')) + .map(name => join(tmpdir(), name)) + + it('leaves nothing behind when it throws, having published or not', async () => { + const thrown: unknown = await wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'srt-no-such-shell', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LEAF] }, + }).catch((error: unknown) => error) + expect(String(thrown)).toContain('srt-no-such-shell') + expect(readdirSync(runtime.manifestDir())).toEqual([]) + expect(emptySources()).toEqual([]) + + // Before it publishes: what it made goes once a clean-up can tell. + const junk = join(runtime.manifestDir(), '4242-00.json') + writeFileSync(junk, 'no manifest') + expect(await wrap('true', [LEAF]).catch((e: Error) => e.name)).toBe( + 'LinuxSandboxProfileError', + ) + expect(emptySources()).toHaveLength(1) + rmSync(junk) + cleanupBwrapMountPoints() + expect(emptySources()).toEqual([]) }) it('gives up, with a code of its own and nothing left behind, when that happens to every plan', async () => { - const emptySources = (): string[] => - readdirSync(tmpdir()) - .filter(name => name.startsWith('claude-empty-')) - .map(name => join(tmpdir(), name)) let published = 0 const publishing = atEachPublish(() => { published++ diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index c24c07f5f..549ca4ed7 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -23,9 +23,11 @@ import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { collectMountPoints, + discardMountPointManifest, forgetMountPointManifestDirectory, liveMountPoints, namedMountPoints, + type NamedMountPoints, publishMountPointManifest, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' @@ -169,10 +171,18 @@ describe.if(isLinux)('The mount point manifests', () => { return manifest.file } + /** A reading that is not in doubt. */ + function reads( + reading: ReturnType, + ): NamedMountPoints { + if ('inDoubt' in reading) throw new Error(reading.inDoubt) + return reading + } + /** What a wrap is told the manifests name, sorted; `undefined` in doubt. */ function named(): string[] | undefined { const reading = namedMountPoints() - return reading && [...reading.paths].sort() + return 'inDoubt' in reading ? undefined : [...reading.paths].sort() } /** The claims in the manifest directory, by name. */ @@ -312,6 +322,32 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(X)).toBe(true) }, 15000) + it('removes what a pass spared on the word of a manifest whose wrap then comes to nothing', () => { + // The pass drops the finished manifest, so the newcomer alone names the + // path from then on: merely unlinked, it would leave the path for good. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X, Y]) + let newcomer: string | undefined + const pass = duringTheNextPass(() => (newcomer = publish([X, Y]))) + try { + expect(collectMountPoints('none')).toEqual([]) + } finally { + pass.restore() + } + expect(readdirSync(DIR)).toEqual([basename(newcomer!)]) + + const live = started(manifestOfADeadProcess([Y]), thisProcess()) + discardMountPointManifest(newcomer!) + expect(existsSync(X)).toBe(false) + // What another manifest names as well is that one's to keep. + expect(existsSync(Y)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual( + [live, recordOf(live)].map(file => basename(file)).sort(), + ) + }) + it.if(NOT_ROOT)( 'keeps the manifest of a mount point it could not remove, for a pass that can', () => { @@ -975,6 +1011,12 @@ describe.if(isLinux)('The mount point manifests', () => { function write(file: string, text = ''): void { writeFileSync(file, text, { mode: 0o600 }) } + if (NOT_ROOT) { + notARecord.push([ + 'one its owner may not read', + r => writeFileSync(r, gone(), { mode: 0 }), + ]) + } for (const [what, plant] of notARecord) { it(`keeps what a manifest names while its record is ${what}`, () => { leftover(X) @@ -1078,6 +1120,46 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(DIR)).toEqual([basename(young)]) }) + for (const [what, plant] of notARecord) { + it(`is not held up by a record with no manifest that is ${what}`, () => { + // It refuses every wrap of the user for as long as it counts. + leftover(X) + manifestOfADeadProcess([X]) + const orphan = join(DIR, '4242-0123456789abcdef.started') + plant(orphan) + const looked = inAChild( + `console.log(JSON.stringify({ named: [...m.namedMountPoints().paths], held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + ) + expect(looked.status).toBe(0) + expect(looked.ms).toBeLessThan(AT_ONCE_MS) + expect(JSON.parse(looked.stdout)).toEqual({ + named: [X], + held: [], + removed: [X], + }) + // Dropped, where it can be. + expect(readdirSync(DIR)).toEqual( + what === 'a directory' ? [basename(orphan)] : [], + ) + }, 15000) + } + + it('removes nothing, however old it is, while a record with no manifest cannot be read for want of a descriptor', () => { + // That says nothing of the record: its manifest may be passing from one + // name to the other with a sandbox on it. + leftover(X) + manifestOfADeadProcess([X]) + const orphan = join(DIR, '4242-0123456789abcdef.started') + write(orphan, gone()) + const longAgo = new Date(Date.now() - MONTH_MS) + utimesSync(orphan, longAgo, longAgo) + expect(withThat(failing(orphan, 'EMFILE'))).toEqual({ + removed: [], + held: undefined, + }) + expect(existsSync(orphan)).toBe(true) + }) + /** Hides `name` from the next listing of the manifest directory. */ function missedByTheNextListing(name: string): { restore(): void } { const readdir = fs.readdirSync @@ -1702,10 +1784,13 @@ describe.if(isLinux)('The mount point manifests', () => { // names paths nobody can list, so while it may have a sandbox under it // nothing at all is removed. - /** Two hours old: past the hour for which what is no manifest counts as live. */ - function aged(file: string): string { - const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) - lutimesSync(file, hoursAgo, hoursAgo) + /** + * `minutes` old, two hours unless said: past the hour for which what is no + * manifest counts as live. + */ + function aged(file: string, minutes = 120): string { + const then = new Date(Date.now() - minutes * 60 * 1000) + lutimesSync(file, then, then) return file } @@ -1750,12 +1835,20 @@ describe.if(isLinux)('The mount point manifests', () => { .replace('"sources":[]', '"origins":[]'), ), ], + ['an empty file', name => writeFileSync(name, '')], ['a directory', name => mkdirSync(name)], [ 'a FIFO nobody writes to', name => expect(spawnSync('mkfifo', [name]).status).toBe(0), ], ] + if (NOT_ROOT) { + notAManifest.push([ + 'a manifest its owner may not read', + (name, paths) => + writeFileSync(name, finishedManifestText(paths), { mode: 0 }), + ]) + } for (const [what, plant] of notAManifest) { it(`removes nothing beside ${what} until it is an hour old, and passes over it then`, () => { const Y = join(BASE, 'second.lock') @@ -1786,7 +1879,8 @@ describe.if(isLinux)('The mount point manifests', () => { writeFileSync(recordOf(junk), statLine(thisProcess())) expect(look()).toEqual(inDoubt) - rmSync(recordOf(junk)) + // A record that names no process to ask after puts nothing off. + writeFileSync(recordOf(junk), 'ended\n') expect(look()).toEqual({ named: [Y], held: [], removed: [Y] }) expect(existsSync(X)).toBe(true) // Dropped, where it is a file that can be. @@ -1794,6 +1888,26 @@ describe.if(isLinux)('The mount point manifests', () => { }, 30000) } + it.each(['version', 'pid', 'start', 'created', 'paths', 'sources'])( + 'does not take for a manifest what holds no `%s`', + field => { + // A later version is read by the fields this one knows, so each of them + // has to be there: read without one, it would name nothing or be live + // for nobody. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X], { [field]: undefined }) + manifestOfADeadProcess([Y]) + + expect(named()).toBeUndefined() + expect(liveMountPoints()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(Y)).toBe(true) + }, + ) + it("removes nothing beside a manifest that is somebody else's until it is an hour old, and passes over it then", () => { const Y = join(BASE, 'second.lock') leftover(X) @@ -1804,7 +1918,11 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(named()).toBeUndefined() expect(collectMountPoints()).toEqual([]) - aged(junk) + // An hour, not less and not much more. + aged(junk, 59) + expect(named()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + aged(junk, 61) expect(collectMountPoints()).toEqual([Y]) } finally { theirs.restore() @@ -1951,14 +2069,15 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = started(manifestOfADeadProcess([X]), thisProcess()) expect(live()).toEqual([X]) - const earlier = namedMountPoints() + const earlier = reads(namedMountPoints()) const injected = inject(manifest) + const doubt = { inDoubt: expect.stringContaining(DIR) } try { expect(liveMountPoints()).toBeUndefined() - expect(namedMountPoints()).toBeUndefined() + expect(namedMountPoints()).toEqual(doubt) // A manifest already read is not read again, so that is no doubt. if (!what.startsWith('a manifest')) { - expect(namedMountPoints(earlier)).toBeUndefined() + expect(namedMountPoints(earlier)).toEqual(doubt) } } finally { injected.restore() @@ -1981,7 +2100,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('answers a wrap with an earlier reading while the directory lists nothing new, and reads again when it does', () => { const [Y, Z] = [join(BASE, 'second.lock'), join(BASE, 'third.lock')] manifestOfADeadProcess([X]) - const first = namedMountPoints()! + const first = reads(namedMountPoints()) const open = spyOn(fs, 'openSync') try { expect(namedMountPoints(first)).toBe(first) @@ -1992,9 +2111,11 @@ describe.if(isLinux)('The mount point manifests', () => { // The caller knows what it has published itself. const own = publish([Y]) expect(namedMountPoints(first, own)).toBe(first) - expect([...namedMountPoints(first)!.paths].sort()).toEqual([X, Y].sort()) + expect([...reads(namedMountPoints(first)).paths].sort()).toEqual( + [X, Y].sort(), + ) manifestOfADeadProcess([Z]) - expect([...namedMountPoints(first, own)!.paths].sort()).toEqual( + expect([...reads(namedMountPoints(first, own)).paths].sort()).toEqual( [X, Y, Z].sort(), ) }) diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 9a3e73844..2e139ef99 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -505,7 +505,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const left = lstatSync(LOCK) expect(left.isFile()).toBe(true) expect(left.size).toBe(0) - expect(left.mode & 0o222).toBe(0) + // Its mode is bubblewrap's own: 0444 from 0.5.0 on, with write bits before. + expect(left.nlink).toBe(1) const command = await wrap( [LOCK], From 2551cecd1cd8a6c672ea6a53fda7172e87f6bfbd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Tue, 29 Sep 2026 09:57:47 +0000 Subject: [PATCH 088/141] linux: the manifests live under /var/tmp, by the user id, and have an end The manifest directory was taken from XDG_RUNTIME_DIR or TMPDIR, so two processes of one user with different environments did not see each other, and a logout, which empties /run/user/, left a killed process's mount points in the project for good. A process now writes in the first usable of /var/tmp/srt-mount-points-, /run/user//srt-mount-points and /tmp/srt-mount-points-, and reads all three; what the environment names counts only when none of them can be written. Every place is made, bound read-only and pinned by every wrap, so that the sandboxed command cannot bring one into being. Not the home directory: a network home caches listings, which the claim by rename relies on. Ends. A manifest carries the boot id: of another boot, it is finished. Of another PID namespace, it is live for seven days. What can be a manifest of no version (zero bytes, not JSON) is spent after the grace. The boot id counts only on kinds of file system that one kernel owns, by an allowlist; elsewhere a manifest is this kernel's only when both boot ids can be read and are equal, since two hosts' PID namespaces can be named alike, and any other is live for the seven days. The permission bits of /run/user/ count for writing under it, not for reading: where it is a write root the command can chmod it, and must not make other processes read less by that. --- README.md | 27 +- eslint.config.js | 1 + src/sandbox/bwrap-mount-manifests.ts | 447 +++++++++++---- src/sandbox/linux-sandbox-utils.ts | 2 +- test/cli.test.ts | 5 +- test/control-fd.test.ts | 4 +- test/helpers/isolated/cli.ts | 2 + test/helpers/isolated/dist-cli.mjs | 5 + test/helpers/isolated/library.ts | 2 + test/helpers/isolated/manager.ts | 2 + test/helpers/isolated/manifests.ts | 2 + test/helpers/isolated/places.ts | 9 + test/helpers/private-manifest-directory.ts | 61 +- test/preload.ts | 12 +- test/sandbox/default-write-paths.test.ts | 2 +- test/sandbox/linux-bwrap-args-file.test.ts | 5 +- test/sandbox/mount-point-liveness.test.ts | 225 +++++++- test/sandbox/mount-point-manifests.test.ts | 622 ++++++++++++++++++++- test/sandbox/path-canonicalization.test.ts | 9 +- test/sandbox/stale-mount-point.test.ts | 4 +- 20 files changed, 1264 insertions(+), 184 deletions(-) create mode 100644 test/helpers/isolated/cli.ts create mode 100644 test/helpers/isolated/dist-cli.mjs create mode 100644 test/helpers/isolated/library.ts create mode 100644 test/helpers/isolated/manager.ts create mode 100644 test/helpers/isolated/manifests.ts create mode 100644 test/helpers/isolated/places.ts diff --git a/README.md b/README.md index 6768a1729..8f95f261c 100644 --- a/README.md +++ b/README.md @@ -742,21 +742,22 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full temp dir), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (the manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). Off Linux the set is always empty. +- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** - - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. - - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. - - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a reboot or the last logout (`$XDG_RUNTIME_DIR` is a tmpfs that logind removes), a container restart with the project on a volume, a cleaner of the temp dir. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. + - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/var/tmp` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none, for seven days from the last change to the manifest or to its record, whichever is later, and for finished then. Until then it removes nothing that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. The seven days are for a namespace that has gone: after `docker restart` the container has its files, the host's boot id and a new PID namespace, and nothing else would ever end what the earlier one left. A sandbox that runs longer than that in another PID namespace, sharing both this directory and the project, is no longer protected from processes outside its namespace. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/var/tmp` or `/tmp` can delete and forge manifests there. A process that can write in none of the first three places keeps its manifests where its environment says, which the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. + - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where one of the names, or a directory above it, is a symbolic link of the user's that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where the parent of a name does not exist, and the user may write the directory it is missing from, and that lies inside a write root (a process that runs as root and has no `/run/user/0`, with `/` or `/run` writable; an `XDG_RUNTIME_DIR` or `TMPDIR` that is not there), the wrap cannot make the manifest directory and a sandboxed command can, parent and all: every process that looks there afterwards believes it. Where `/run/user/` itself is a write root the command can open it to others; every process goes on reading what is under it, but another local user can then move that manifest directory aside. What a cleanup removes on such a manifest's word is bounded as above. + - _A `/var/tmp/srt-mount-points-` that another user made._ Another local user can make that name, or the one in `/tmp`, before the user's first wrap. It fails the owner check and is passed over, never repaired, and the user's processes write in the next place. + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in every directory it believes, and it lists them again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. + - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a container made anew with the project on a volume, a cleaner of `/var/tmp`, and, for a process that had to write in a later place, the last logout (`/run/user/` is a tmpfs that logind removes) or a reboot. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. - _An older release beside this one._ A process of 0.0.77 takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running; one of an earlier release removes at its cleanup what it made itself, whoever relies on it by then. The protection holds between processes of this release and later. - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. - - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when one writes where the other reads. Every process reads the first three places, so what is left is: a process that can write in none of them, which records where its environment says, where only a process in the same position looks, in a private directory (it runs inside another sandbox, where all are read-only), or nowhere; two processes that share a project but none of the three (a container beside the host, a service with a private `/tmp` and `/var/tmp`); and a release that wrote no manifest. Nothing the other process reads then says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that reads the manifest directory they are recorded in; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads ` -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, and the binds of the root and of the manifest kept on the line ahead of `--args`, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -765,7 +766,7 @@ Filesystem restrictions are enforced at the OS level: - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest, and a second run is then refused. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) @@ -797,7 +798,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. -With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. +With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of `/var/tmp`, of `/run/user/`, of `/tmp`, of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. diff --git a/eslint.config.js b/eslint.config.js index b7783d1c9..bebf03e6f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default [ allowDefaultProject: [ 'eslint.config.js', 'test/utils/which-node-test.mjs', + 'test/helpers/isolated/dist-cli.mjs', 'vendor/build-common.ts', 'vendor/seccomp/build.ts', 'vendor/srt-win/build.ts', diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 46c5f30c1..73120345e 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -34,17 +34,25 @@ * No sandboxed command can write the directory (see below), so what is there * and should not be comes from an accident on the host. * - * The manifest directories are bound read-only into every sandbox that - * restricts writes, and the directories above them inside a write root are - * pinned, so a sandboxed command can neither rewrite a manifest nor swap the - * directory (see {@link mountPointManifestDirectories}). Not covered: a sandbox - * this library did not start, a process that looks for its runtime or temp - * directory elsewhere, and a runtime or temp directory that is a link inside a - * write root or not there yet. + * Two processes protect each other only when each reads what the other writes, + * and a mount point whose manifest is lost stays for good. So the directories + * follow from the user id, not the environment, and the first survives a logout + * and a reboot (see {@link sharedManifestDirectoryNames}). + * + * INVARIANT: all of this rests on the sandboxed command, which has the same + * uid, being unable to write OR CREATE a directory a pass believes: one it made + * itself would pass every check of owner and mode. So every wrap that restricts + * writes makes every one of them, binds it read-only and pins what lies above + * it inside a write root, before it hands the command out (see {@link + * mountPointManifestDirectories}). Not covered: a sandbox this library did not + * start; a name, or one above it, that is a link inside a write root; a parent + * that is not there, where the command may make it. * * /proc/PID is relative to a PID namespace, so a manifest records the namespace * that wrote it and only a process in that namespace judges it; to any other it - * is live. + * is live, for {@link OTHER_NAMESPACE_MAX_AGE_MS}. It records the boot too: no + * process of an earlier one runs, where only this kernel reaches the directory. + * NO LASTING STATE KEEPS WHAT IS LEFT FOR EVER. */ import { randomBytes } from 'node:crypto' @@ -72,7 +80,7 @@ const CLAIMED_SUFFIX = '.claimed' const MANIFEST_GRACE_MS = 500 /** - * A pass with no more candidates than this lists the manifest directory again + * A pass with no more candidates than this lists the manifest directories again * before every removal, so each is judged on a listing a few microseconds old. * An ordinary pass is a handful of paths. */ @@ -98,6 +106,13 @@ const LISTING_ATTEMPTS = 8 */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 +/** + * A manifest of another PID namespace, or that records none, counts as live + * until it and its record are this old. Nothing else ends one whose namespace + * is gone: a restarted container keeps its files and the host's boot id. + */ +const OTHER_NAMESPACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + /** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 @@ -128,6 +143,12 @@ const ManifestSchema = z.object({ * be asked after from there. Absent where it could not be read. */ ns: z.string().optional(), + /** + * The boot it was written in. Absent where that could not be read. As read, + * both are absent where nothing follows from them (see {@link + * ONE_KERNEL_FILE_SYSTEMS}). + */ + boot: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ created: z.number(), /** The mount points bwrap makes on the host for that wrap's deny paths. */ @@ -268,24 +289,89 @@ function ownPidNamespace(): string | undefined { return pidNamespace } +let bootId: string | undefined + +/** This boot as the kernel names it, or `undefined`. */ +function ownBootId(): string | undefined { + try { + return (bootId ??= + fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || + undefined) + } catch { + return undefined + } +} + +/** + * The kinds of file system that only processes of this kernel reach, by + * `statfs`'s type. On any other (NFS, 9p, virtiofs, FUSE) a manifest may be + * another running kernel's, and only the boot id tells: two hosts each in their + * initial PID namespace name that alike. So there a manifest is this kernel's + * only when its boot id and ours can both be read and are equal. Of any other, + * neither its boot nor its namespace says anything: it records none, and is + * live for {@link OTHER_NAMESPACE_MAX_AGE_MS}. + * + * INVARIANT: a list of what is let in, not of what is kept out. On a local kind + * that is missing, what an earlier boot left waits those seven days instead of + * going at once. A kind wrongly let in, as the next network file system would be + * by a list of the known ones, has a live sandbox judged finished and its mount + * points removed. + */ +const ONE_KERNEL_FILE_SYSTEMS = new Set([ + 0x01021994, // tmpfs + 0xef53, // ext2, ext3, ext4 + 0x58465342, // xfs + 0x9123683e, // btrfs + 0x794c7630, // overlayfs + 0x2fc12fc1, // zfs + 0xf2f52010, // f2fs +]) + +/** Whether `dir` is on one of those. Not where that cannot be asked. */ +function isReachedByOneKernel(dir: string): boolean { + try { + return ONE_KERNEL_FILE_SYSTEMS.has(Number(fs.statfsSync(dir).type)) + } catch { + return false + } +} + +/** + * Whether `file` was last written over `ms` ago. One that cannot be looked at + * was not; one that is not there counts as `absent` says. + */ +function olderThan(file: string, ms: number, absent = false): boolean { + try { + return Date.now() - fs.lstatSync(file).mtimeMs > ms + } catch (e) { + return absent && isAbsenceErrno(e) + } +} + /** - * Whether `dir` is a directory of ours that nobody else can write, and we can. - * With `orThrow`, an error that says neither "not there" nor "not to be written - * from here" is thrown, for a caller that must not take it for a no. + * Whether `dir` is a directory of ours, with `alone`, that nobody else can + * write, and, with `toWrite`, that we can. With `orThrow`, an error that says + * neither "not there" nor "not to be written from here" is thrown, for a caller + * that must not take it for a no. */ -function isOurPrivateDirectory(dir: string, orThrow = false): boolean { +function isOurPrivateDirectory( + dir: string, + orThrow = false, + toWrite = true, + alone = true, +): boolean { try { const stat = fs.lstatSync(dir) if ( !stat.isDirectory() || stat.uid !== process.getuid?.() || - (stat.mode & 0o077) !== 0 + (alone && (stat.mode & 0o077) !== 0) ) { return false } - // Ours, and no use if it cannot be written: a directory an outer sandbox - // binds read-only reads as ours in every other respect. - fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) + // A directory an outer sandbox binds read-only reads as ours in every + // other respect. + if (toWrite) fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) return true } catch (e) { const code = (e as NodeJS.ErrnoException).code @@ -348,20 +434,82 @@ function isBuriedBySandboxMounts(dir: string): boolean { ) } +// Test seam: what stands in for `/` in the names that follow from the user id. +let root = '/' + +/** + * The names every process of this user works out alike, in the order tried: + * under /var/tmp, which is local to the host and outlives a logout and a + * reboot; under /run/user/UID, where that is a directory of the user's; under + * /tmp. INVARIANT for every later version: it goes on reading these. + * + * To be written under (`toWrite`), /run/user/UID has to be closed to everyone + * else as well. Not to be read under: where it is a write root the command can + * chmod it, and must not make other processes read less by that. Reading more + * does no harm, a manifest being only a claim about a path. + * + * Not the home directory. A network home caches listings, and the claim by + * rename, the reading after it and the listing before a removal all need the + * next readdir in another process to show a rename; a home is often inside a + * write root; nothing ages what is left there; and in a shared one another boot + * id may be another live host's. + */ +function fixedManifestDirectoryNames( + orThrow = false, + toWrite = false, +): string[] { + const uid = process.getuid?.() ?? 0 + const runtimeDir = path.join(root, 'run/user', String(uid)) + return [ + path.join(root, 'var/tmp', `srt-mount-points-${uid}`), + ...(isOurPrivateDirectory(runtimeDir, orThrow, false, toWrite) + ? [path.join(runtimeDir, 'srt-mount-points')] + : []), + path.join(root, 'tmp', `srt-mount-points-${uid}`), + ] +} + /** * Where processes of this user keep manifests that others can find, in the - * order tried: under $XDG_RUNTIME_DIR, then under the system temp dir. + * order tried: {@link fixedManifestDirectoryNames}, then, for a process that + * can use none of those (a minimal or read-only image, another sandbox), under + * $XDG_RUNTIME_DIR and under the system temp dir. */ -function sharedManifestDirectoryNames(): string[] { +function sharedManifestDirectoryNames( + orThrow = false, + toWrite = false, +): string[] { + const shared = fixedManifestDirectoryNames(orThrow, toWrite) const runtimeDir = process.env['XDG_RUNTIME_DIR'] - const shared = - runtimeDir !== undefined && path.isAbsolute(runtimeDir) - ? [path.join(runtimeDir, 'srt-mount-points')] - : [] + if (runtimeDir !== undefined && path.isAbsolute(runtimeDir)) { + shared.push(path.join(runtimeDir, 'srt-mount-points')) + } shared.push( path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), ) - return shared.filter(candidate => !isBuriedBySandboxMounts(candidate)) + return [...new Set(shared)].filter( + candidate => !isBuriedBySandboxMounts(candidate), + ) +} + +/** + * The directories whose manifests this process believes: it reads them all, and + * claims and collects in those it can write. The names that follow from the + * user id; the ones the environment gives only where none of those can be + * written, because only processes with that environment keep them out of a + * sandbox's reach; and the one it has settled on. Each only while it is a + * directory of ours alone. Makes nothing. Throws where it cannot tell. + */ +function believedManifestDirectories(): string[] { + const names = fixedManifestDirectoryNames(true, true).some(dir => + isOurPrivateDirectory(dir, true), + ) + ? fixedManifestDirectoryNames(true) + : sharedManifestDirectoryNames(true) + if (manifestDirectory !== undefined) names.push(manifestDirectory) + return [...new Set(names)].filter(dir => + isOurPrivateDirectory(dir, true, false), + ) } /** @@ -378,7 +526,7 @@ function ensureManifestDirectory(): string | undefined { return manifestDirectory } manifestDirectoryIsPrivate = false - for (const candidate of sharedManifestDirectoryNames()) { + for (const candidate of sharedManifestDirectoryNames(false, true)) { if (makeOurPrivateDirectory(candidate)) { manifestDirectory = candidate return candidate @@ -432,15 +580,21 @@ function isOurDirectory(dir: string): boolean { /** * Every directory a wrap must bind read-only because some process of this user - * keeps manifests in it and a collect believes what it finds there: both shared - * names, plus the one this process settled on. + * keeps manifests in it and a collect believes what it finds there: every + * shared name, whichever this process writes to, plus the one it settled on. + * + * Each is made if it can be, so a sandboxed command cannot make and fill it + * first. One that can be neither made nor bound is not believed, and the + * command does not bring it into being either: * - * Both names, because a process started without $XDG_RUNTIME_DIR (cron, plain - * ssh, a service) uses the second, and a sandbox of the first kind with the - * temp dir writable could otherwise forge its manifests. Each is made if it can - * be, so a sandboxed command cannot make and fill it first. With `recording`, - * the directory this wrap's manifest will go to is settled first, so the wrap - * can pin what lies above it before it publishes. + * - its parent is not there: it is missing from /var, /run/user or /, which + * are root's to write (what the environment names may be missing anywhere); + * - its parent is read-only: so is every mount made from it; + * - another user has the name, in a sticky directory: only that user or root + * can remove or rename it. + * + * With `recording`, the directory this wrap's manifest will go to is settled + * first, so the wrap can pin what lies above it before it publishes. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -483,6 +637,12 @@ export function removePrivateManifestDirectory(): void { /** What is at a name and is not what this library keeps there. */ class NotOurs extends Error {} +/** + * What can be a manifest of no version. A manifest is not synced before it is + * moved into place, so a crash can leave one with nothing in it. + */ +class NotJson extends NotOurs {} + /** * Whether `file` is what an open does not refuse for what it is: a regular file * of the user's that its owner may read. What cannot be looked at may be. @@ -548,7 +708,7 @@ function readManifest(file: string): Manifest | undefined { try { json = JSON.parse(text) } catch { - throw new NotOurs(`${file} is not JSON`) + throw new NotJson(`${file} is not JSON`) } const parsed = ManifestSchema.safeParse(json) if (!parsed.success) { @@ -605,16 +765,29 @@ function recordVouches(record: string, orphan = false): boolean | undefined { /** * Whether a sandbox that named this manifest may be running, or may yet start. - * Only a process in the PID namespace that wrote it can tell; to any other it - * is live, and is collected by a process in its own. With a started record, the - * record alone says. With none, no sandbox has got past a claim, and one the - * caller has not released is live while it is young or its writer runs. + * None of another boot is, whatever else the manifest says. Only a process in + * the PID namespace that wrote it can tell; to any other it is live until {@link + * OTHER_NAMESPACE_MAX_AGE_MS}. With a started record, the record alone says. + * With none, no sandbox has got past a claim, and one the caller has not + * released is live while it is young or its writer runs. */ function isLive(manifest: Manifest): boolean { + if ( + manifest.boot !== undefined && + manifest.boot !== (ownBootId() ?? manifest.boot) + ) { + return false + } const here = ownPidNamespace() - if (here === undefined || manifest.ns !== here) { + if (here === undefined) { return true } + if (manifest.ns !== here) { + return !( + olderThan(manifest.file, OTHER_NAMESPACE_MAX_AGE_MS) && + olderThan(manifest.record, OTHER_NAMESPACE_MAX_AGE_MS, true) + ) + } const vouched = recordVouches(manifest.record) if (vouched !== undefined) { return vouched @@ -783,6 +956,7 @@ export function publishMountPointManifest( version: MANIFEST_VERSION, ...ownProcess(), ns: ownPidNamespace(), + boot: ownBootId(), created: Date.now(), paths: [...new Set(mountPoints)], sources: [...new Set(sources)], @@ -837,10 +1011,14 @@ function drop(file: string): boolean { } } -/** One reading of a manifest directory. */ +/** Every file in `dirs`. Throws where one of them cannot be listed. */ +const filesIn = (dirs: readonly string[]): string[] => + dirs.flatMap(dir => fs.readdirSync(dir).map(name => path.join(dir, name))) + +/** One reading of the manifest directories. */ type Reading = { - /** Every name in it. */ - names: string[] + /** Every file in them. */ + files: string[] /** The manifests that could be read, claimed ones among them. */ manifests: Manifest[] /** @@ -855,7 +1033,8 @@ type Reading = { } /** - * Reads the manifests in `dir`, every one that the listing showed. + * Reads the manifests in `dirs`, every one that the listings showed. Doubt in + * one directory is doubt. * * A pass renames a manifest to claim it and to give it back, which can hide it * from a listing under way and from the open that follows one, and one published @@ -873,16 +1052,11 @@ type Reading = { * too: always where that is an error of the moment (a sandboxed command can use * up what this user may open), and where it is what is there that cannot be * read as a manifest, while a process on its record runs or it is younger than - * {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. + * {@link UNREADABLE_MANIFEST_MAX_AGE_MS} ({@link MANIFEST_GRACE_MS} for a {@link + * NotJson}). */ -function readManifests(dir: string): Reading { - const olderThan = (file: string, ms: number): boolean => { - try { - return Date.now() - fs.lstatSync(file).mtimeMs > ms - } catch { - return false - } - } +function readManifests(dirs: readonly string[]): Reading { + const oneKernel = dirs.filter(isReachedByOneKernel) // What cannot be asked after counts as there. const isThere = (file: string): boolean => { try { @@ -893,30 +1067,44 @@ function readManifests(dir: string): Reading { } } for (let attempt = 1; ; attempt++) { - let names: string[] + let files: string[] try { - names = fs.readdirSync(dir) + files = filesIn(dirs) } catch (e) { - return { names: [], manifests: [], spent: [], inDoubt: String(e) } + return { files: [], manifests: [], spent: [], inDoubt: String(e) } } - const reading: Reading = { names, manifests: [], spent: [] } + const reading: Reading = { files, manifests: [], spent: [] } const listed = new Set() let moved = false - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX) && !name.endsWith(CLAIMED_SUFFIX)) { + for (const file of files) { + if (!file.endsWith(MANIFEST_SUFFIX) && !file.endsWith(CLAIMED_SUFFIX)) { continue } - const file = path.join(dir, name) listed.add(idOf(file)) try { const manifest = readManifest(file) - if (manifest !== undefined) reading.manifests.push(manifest) - else moved = true + if (manifest === undefined) { + moved = true + continue + } + if ( + !oneKernel.includes(path.dirname(file)) && + (manifest.boot === undefined || manifest.boot !== ownBootId()) + ) { + delete manifest.boot + delete manifest.ns + } + reading.manifests.push(manifest) } catch (e) { if ( e instanceof NotOurs && recordVouches(`${idOf(file)}${STARTED_SUFFIX}`, true) !== true && - olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS) + olderThan( + file, + e instanceof NotJson + ? MANIFEST_GRACE_MS + : UNREADABLE_MANIFEST_MAX_AGE_MS, + ) ) { reading.spent.push(file) } else { @@ -925,14 +1113,13 @@ function readManifests(dir: string): Reading { } } } - for (const name of names) { - const file = path.join(dir, name) - if (name.endsWith(TEMPORARY_SUFFIX)) { + for (const file of files) { + if (file.endsWith(TEMPORARY_SUFFIX)) { // Left by a process killed between writing and moving into place. if (olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS)) { reading.spent.push(file) } - } else if (name.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { + } else if (file.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { // Asked for at its own name first, and the record read last. Only a // claim given back can then pass between the two names unseen, and a // pass gives back only once it has found a process on the record, @@ -957,7 +1144,7 @@ function readManifests(dir: string): Reading { } if (moved) { reading.keptChanging = reading.inDoubt === undefined - reading.inDoubt ??= `${dir} kept changing` + reading.inDoubt ??= `${dirs.join(', ')} kept changing` } return reading } @@ -978,10 +1165,10 @@ export type NamedMountPoints = { * doubt: a path it does not name may then be a running sandbox's mount point * all the same, and a wrap that took it for the caller's own would not name it * and would lose it under its own sandbox. With `keptChanging`, only because - * other processes are busy in the directory: the next reading may do. + * other processes are busy in a directory: the next reading may do. * * `earlier` is a reading this call may answer with. A manifest is never - * rewritten, so it still holds when the directory lists nothing it did not, + * rewritten, so it still holds when the directories list nothing it did not, * `own` (a manifest the caller has published since) apart. */ export function namedMountPoints( @@ -989,19 +1176,14 @@ export function namedMountPoints( own?: string, ): NamedMountPoints | { inDoubt: string; keptChanging?: boolean } { try { - const dir = onLinux() ? existingManifestDirectory() : undefined - if (dir === undefined) { - return { paths: new Set(unrecorded.paths), listed: new Set() } - } + const dirs = onLinux() ? believedManifestDirectories() : [] if ( earlier !== undefined && - fs - .readdirSync(dir) - .every(n => earlier.listed.has(n) || path.join(dir, n) === own) + filesIn(dirs).every(file => earlier.listed.has(file) || file === own) ) { return earlier } - const reading = readManifests(dir) + const reading = readManifests(dirs) if (reading.inDoubt !== undefined) { return { inDoubt: reading.inDoubt, keptChanging: reading.keptChanging } } @@ -1010,25 +1192,14 @@ export function namedMountPoints( ...unrecorded.paths, ...reading.manifests.flatMap(manifest => manifest.paths), ]), - listed: new Set(reading.names), + listed: new Set(reading.files), } } catch (e) { - // The directory cannot be looked at, or listed. + // A directory cannot be looked at, or listed. return { inDoubt: String(e) } } } -/** - * The directory this process keeps its manifests in, if it exists already. - * Makes and settles nothing: for a look that must leave the host as found. - * Throws where it cannot tell. - */ -function existingManifestDirectory(): string | undefined { - return [manifestDirectory, ...sharedManifestDirectoryNames()].find( - dir => dir !== undefined && isOurPrivateDirectory(dir, true), - ) -} - /** * Whether `p` is still something a clean-up would remove on a manifest's word: * an empty file or an empty directory. One that cannot be looked at may be. @@ -1050,7 +1221,7 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command: removing a mount point from under a running * sandbox lifts the deny there. * - * `undefined` when it cannot tell: the manifest directory, its listing, a + * `undefined` when it cannot tell: a manifest directory, its listing, a * manifest or a record without a manifest cannot be read or accounted for, so a * sandbox may be running on paths that cannot be listed. The caller must then * skip every removal of an empty file or an empty directory. It does not throw. @@ -1069,10 +1240,9 @@ function isEmptyMountPoint(p: string): boolean { * file with content. * * Paths are as the wraps recorded them: absolute, links above the last - * component resolved. It answers for one manifest directory, the one this - * process uses or, before its first wrap, the first shared name that exists, so - * a sandbox whose process keeps manifests elsewhere is not seen. Makes nothing - * on the host. Empty off Linux. + * component resolved. It answers for the directories this process believes + * (see {@link believedManifestDirectories}), so a sandbox whose process keeps + * manifests elsewhere is not seen. Makes nothing on the host. Empty off Linux. */ export function liveMountPoints(): ReadonlySet | undefined { const spared = new Set() @@ -1081,12 +1251,11 @@ export function liveMountPoints(): ReadonlySet | undefined { } const named = new Set(unrecorded.paths) try { - const dir = existingManifestDirectory() - const reading = dir === undefined ? undefined : readManifests(dir) - if (reading?.inDoubt !== undefined) { + const reading = readManifests(believedManifestDirectories()) + if (reading.inDoubt !== undefined) { throw new Error(reading.inDoubt) } - for (const manifest of reading?.manifests ?? []) { + for (const manifest of reading.manifests) { if (isLive(manifest)) { for (const mountPoint of manifest.paths) named.add(mountPoint) } @@ -1128,9 +1297,21 @@ export function collectMountPoints( own.over = true } } - const dir = ensureManifestDirectory() - if (dir !== undefined) { - return collect(dir, release === 'all') + // Made first: with one of the first places to write in, what the environment + // names is not believed. + ensureManifestDirectory() + let dirs: string[] + try { + dirs = believedManifestDirectories() + } catch (e) { + logForDebugging( + `[Sandbox Linux] Where the mount point manifests are cannot be told - leaving every mount point where it is (${String(e)})`, + { level: 'warn' }, + ) + return [] + } + if (dirs.length > 0) { + return collect(dirs, release === 'all') } const removed: string[] = [] if (release === 'all') { @@ -1151,11 +1332,13 @@ const childrenFirst = (paths: Iterable): string[] => [...paths].sort().reverse() /** - * One pass over a manifest directory. It takes no lock and waits for nothing; - * any number may run at once, over the same claims. + * One pass over the manifest directories. It takes no lock and waits for + * nothing; any number may run at once, over the same claims. * - * INVARIANT: a path is removed only when every manifest that names it is - * claimed and has no process on its record, by a reading made after the claims. + * INVARIANT: a path is removed only when every manifest that names it, in any + * directory, is claimed and has no process on its record, by a reading of every + * directory made after the claims in every directory. A directory that cannot + * be written from here cannot be claimed in, so all it names is kept. * bubblewrap binds the manifest before it makes a mount point, so a start under * a claimed manifest is refused, and a sandbox that got past the claim wrote * its record first: every reading after the claim finds it. A manifest at its @@ -1166,8 +1349,8 @@ const childrenFirst = (paths: Iterable): string[] => * A pass gives back the claims it made itself, and only when that reading finds * a process on the record. So what remains is a manifest that comes to its own * name after the reading, published or given back, whose sandbox reaches its - * bind on a path a claimed manifest names too. The directory is listed again - * before every removal (in a pass of over {@link + * bind on a path a claimed manifest names too. The directories, worked out + * afresh, are listed again before every removal (in a pass of over {@link * LISTS_BEFORE_EACH_REMOVAL_UP_TO} candidates, whenever the listing is {@link * LISTING_GOOD_FOR_MS} old), and what has come to its name keeps what it names, * so the pass would have to be held up between a listing and the removal that @@ -1180,16 +1363,16 @@ const childrenFirst = (paths: Iterable): string[] => * or a directory holds what another manifest names. Only content that no * manifest names makes a directory nobody's. */ -function collect(dir: string, unrecordedToo: boolean): string[] { - const before = readManifests(dir) +function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { + const before = readManifests(dirs) before.spent.forEach(drop) const own = new Set() let claims = false if (before.inDoubt === undefined) { // Forget own manifests that something else has removed. - const there = new Set(before.names.map(name => idOf(path.join(dir, name)))) + const there = new Set(before.files.map(idOf)) for (const file of ownManifests.keys()) { - if (path.dirname(file) === dir && !there.has(idOf(file))) { + if (dirs.includes(path.dirname(file)) && !there.has(idOf(file))) { ownManifests.delete(file) } } @@ -1200,13 +1383,13 @@ function collect(dir: string, unrecordedToo: boolean): string[] { fs.renameSync(manifest.file, claim) own.add(claim) } catch { - // Another pass has it. + // Another pass has it, or the directory cannot be written. } } claims ||= manifest.claimed || own.size > 0 } } - const reading = claims ? readManifests(dir) : before + const reading = claims ? readManifests(dirs) : before if (reading.inDoubt !== undefined) { logForDebugging( `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${reading.inDoubt})`, @@ -1222,8 +1405,13 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } } const finished: Manifest[] = [] + const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) for (const manifest of reading.manifests) { - if (manifest.claimed && !isLive(manifest)) { + if ( + manifest.claimed && + !isLive(manifest) && + writable.includes(path.dirname(manifest.file)) + ) { finished.push(manifest) continue } @@ -1249,20 +1437,22 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } } } - const known = new Set(reading.names) + const known = new Set(reading.files) const goodFor = candidates.size <= LISTS_BEFORE_EACH_REMOVAL_UP_TO ? 0 : LISTING_GOOD_FOR_MS let listedAt = -Infinity const newcomersKeepWhatTheyName = (): boolean => { try { - for (const name of fs.readdirSync(dir)) { - if (known.has(name) || !name.endsWith(MANIFEST_SUFFIX)) continue + // The first place last: most is published there, so that listing is the + // one to be a few microseconds old. + for (const file of filesIn(believedManifestDirectories().reverse())) { + if (known.has(file) || !file.endsWith(MANIFEST_SUFFIX)) continue // Published, or given back, since the reading. One that is gone again // is looked for the next time: it may be given back once more. - const arrived = readManifest(path.join(dir, name)) + const arrived = readManifest(file) if (arrived === undefined) continue keep(arrived) - known.add(name) + known.add(file) } } catch (e) { logForDebugging( @@ -1322,14 +1512,25 @@ function collect(dir: string, unrecordedToo: boolean): string[] { return removed } +/** + * Look for the places that follow from the user id under `under` instead of + * `/`. Test seam, and nothing else may call it: no environment keeps a test out + * of the ones the user's own processes keep. + */ +export function setMountPointManifestPlacesForTesting(under: string): void { + root = under + forgetMountPointManifestDirectory() +} + /** * Forget which directory the manifests are kept in, and which PID namespace - * this is, so the next use works them out again. Test seam: a test gives itself - * a runtime directory of its own after this module may have settled on the real - * one. + * and boot this is, so the next use works them out again. Test seam: a test + * gives itself a runtime directory of its own after this module may have + * settled on the real one. */ export function forgetMountPointManifestDirectory(): void { pidNamespace = null + bootId = undefined manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7497388bf..605dec40c 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3095,7 +3095,7 @@ async function generateFilesystemArgs( // it pinned. Emitted after every allow and deny bind, like the two stores // below. // - // Each is bound where it really is, not at the name the environment gave: + // Each is bound where it really is, not at the name it was worked out as: // bubblewrap makes a bind's destination by name inside the new root, and a // link with an absolute target on the way leads nowhere from there. const names = [...mountPoints, ...mountPointsReliedOn()] diff --git a/test/cli.test.ts b/test/cli.test.ts index 654490796..0d86ce840 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,10 +11,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' /** - * Get the path to the CLI entry point + * Get the path to the CLI entry point, kept out of the user's own mount point + * manifests */ function getCliPath(): string { - return join(process.cwd(), 'src', 'cli.ts') + return join(process.cwd(), 'test', 'helpers', 'isolated', 'cli.ts') } /** diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 770d6c2e0..e14b19421 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -11,8 +11,8 @@ import * as path from 'path' import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' -// Get the path to the built CLI -const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js') +// Get the path to the built CLI, kept out of the user's own mount point manifests +const CLI_PATH = path.join(process.cwd(), 'test/helpers/isolated/dist-cli.mjs') // srt is expected to exit on its own shortly after the wrapped command // (which runs for well under a second) finishes; a hang is a failure, not diff --git a/test/helpers/isolated/cli.ts b/test/helpers/isolated/cli.ts new file mode 100644 index 000000000..0acc64dd6 --- /dev/null +++ b/test/helpers/isolated/cli.ts @@ -0,0 +1,2 @@ +import './places.js' +import '../../../src/cli.js' diff --git a/test/helpers/isolated/dist-cli.mjs b/test/helpers/isolated/dist-cli.mjs new file mode 100644 index 000000000..1eaf8644b --- /dev/null +++ b/test/helpers/isolated/dist-cli.mjs @@ -0,0 +1,5 @@ +// places.ts, for the built command line tool. +import { setMountPointManifestPlacesForTesting } from '../../../dist/sandbox/bwrap-mount-manifests.js' + +setMountPointManifestPlacesForTesting('/nonexistent') +await import('../../../dist/cli.js') diff --git a/test/helpers/isolated/library.ts b/test/helpers/isolated/library.ts new file mode 100644 index 000000000..f55b59c6f --- /dev/null +++ b/test/helpers/isolated/library.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/linux-sandbox-utils.js' diff --git a/test/helpers/isolated/manager.ts b/test/helpers/isolated/manager.ts new file mode 100644 index 000000000..a0752db06 --- /dev/null +++ b/test/helpers/isolated/manager.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/sandbox-manager.js' diff --git a/test/helpers/isolated/manifests.ts b/test/helpers/isolated/manifests.ts new file mode 100644 index 000000000..d5e6b3b02 --- /dev/null +++ b/test/helpers/isolated/manifests.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/bwrap-mount-manifests.js' diff --git a/test/helpers/isolated/places.ts b/test/helpers/isolated/places.ts new file mode 100644 index 000000000..8f3738784 --- /dev/null +++ b/test/helpers/isolated/places.ts @@ -0,0 +1,9 @@ +import { setMountPointManifestPlacesForTesting } from '../../../src/sandbox/bwrap-mount-manifests.js' + +// The first places the mount point manifests are kept in follow from the user +// id, so no environment keeps a test out of the ones the user's own processes +// keep. Whatever loads this looks for them under a root that is not there, finds +// none, and goes by $XDG_RUNTIME_DIR and the temp dir, which a test can give it. +// The preload loads it for the test process. A child process loads the library +// through one of the modules beside this one. +setMountPointManifestPlacesForTesting('/nonexistent') diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 895061f92..c0c1dccca 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,5 +1,7 @@ import { afterAll, afterEach, beforeAll } from 'bun:test' import { + chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -9,7 +11,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' @@ -58,6 +60,54 @@ function endSandboxesOnRecord(dir: string): void { } } +/** + * Makes `/var/tmp`, `/run/user/UID` and `/tmp` under `root`, as a system has + * them. Returns the places the manifests are kept in there, in the order they + * are tried, none of them made: for `setMountPointManifestPlacesForTesting`, or + * {@link inPrivateNamespace}. + */ +export function makePlaces(root: string): [string, string, string] { + const uid = process.getuid?.() ?? 0 + const places = [ + join(root, 'var/tmp', `srt-mount-points-${uid}`), + join(root, 'run/user', String(uid), 'srt-mount-points'), + join(root, 'tmp', `srt-mount-points-${uid}`), + ] as const + places.forEach((place, i) => { + mkdirSync(dirname(place), { recursive: true }) + chmodSync(dirname(place), i === 1 ? 0o700 : 0o1777) + }) + return [...places] +} + +/** + * The start of a command line that runs what follows it in a mount namespace of + * its own, where what {@link makePlaces} made under `root` stands at its real + * name: for a child process that is to work the places out for itself, with + * nothing put in its way, and not reach the user's own. `keep`, this repository + * and the runtime are bound back where they are: they may lie under one of the + * three. No PID namespace. Needs `bwrapCanNamespace`. + */ +export function inPrivateNamespace(root: string, keep: string[]): string[] { + return [ + 'bwrap', + '--die-with-parent', + '--dev-bind', + '/', + '/', + // Where one of them is not there, there is nothing under it to reach. + ...['/var/tmp', '/run/user', '/tmp'] + .filter(dir => existsSync(dir)) + .flatMap(dir => ['--bind', join(root, dir), dir]), + ...[ + join(import.meta.dir, '../..'), + dirname(process.execPath), + ...keep, + ].flatMap(dir => ['--bind', dir, dir]), + '--', + ] +} + /** * Gives the enclosing `describe` a runtime directory and a temp dir of its own, * for as long as its tests run. @@ -65,10 +115,11 @@ function endSandboxesOnRecord(dir: string): void { * The manifests of every srt process of a user live in one directory, and a * collect reads, judges and removes all of them, so a test that lists or * attacks that directory would otherwise act on whatever else the user is - * running. The library looks under `$XDG_RUNTIME_DIR` first, and every wrap - * that restricts writes makes the directory under the temp dir as well, so both - * are replaced, before anything is cleaned up: nothing is made or removed in - * the directories the user's own processes keep. + * running. With the places that follow from the user id out of reach (see + * isolated/places.ts) the library looks under `$XDG_RUNTIME_DIR` first, and + * every wrap that restricts writes makes the directory under the temp dir as + * well, so both are replaced, before anything is cleaned up: nothing is made or + * removed in the directories the user's own processes keep. * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so: the library's count of wraps is shared by every test file of a diff --git a/test/preload.ts b/test/preload.ts index de92d48b8..69c0bd2c3 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -2,13 +2,15 @@ import { afterAll } from 'bun:test' import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import './helpers/isolated/places.js' // Every wrap that restricts writes makes, binds and collects in the per-user -// directories the mount point manifests are kept in, under $XDG_RUNTIME_DIR and -// the temp dir. The run is given both of its own before any test file is -// loaded, so that no suite acts on what else the user is running. A `describe` -// that lists or attacks the manifests takes its own on top of these (see -// helpers/private-manifest-directory.ts). +// directories the mount point manifests are kept in. The ones that follow from +// the user id are put out of reach (see helpers/isolated/places.ts), which +// leaves those under $XDG_RUNTIME_DIR and the temp dir. The run is given both of +// its own before any test file is loaded, so that no suite acts on what else +// the user is running. A `describe` that lists or attacks the manifests takes +// its own on top of these (see helpers/private-manifest-directory.ts). if (process.platform === 'linux') { // Short names: suites keep Unix sockets beneath these, and a socket's path is // limited to 108 bytes. diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index c6fd91384..134be0f5f 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -193,7 +193,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const { allowOnly, wrapped, reopened } = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') const filesystem = { denyRead: ['~/.npm'], allowWrite: [], denyWrite: [] } await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index b4a326b99..dcf4c50d1 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -69,10 +69,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { `^${literally(STEP_SHELL)} '(?:${literally(RECORD_STEP)} && )?exec 9<"\\$1" && shift && exec "\\$@"' srt-args (?:\\S+\\.started )?(\\S+) bwrap (.*?) ?--args 9 (.*)$`, 's', ) - const MODULE = join( - import.meta.dir, - '../../src/sandbox/linux-sandbox-utils.ts', - ) + const MODULE = join(import.meta.dir, '../helpers/isolated/library.ts') let BASE: string const savedCwd = process.cwd() diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index b3cbe5b4c..f095b2f99 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -34,7 +34,11 @@ import { } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + inPrivateNamespace, + makePlaces, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * A denyWrite path that does not exist is blocked by binding over a mount point @@ -51,7 +55,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string // the allowed write area @@ -528,7 +532,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // What a process without a runtime directory has recorded there. const manifest = join(theirs, '4242-0123456789abcdef.json') - writeFileSync(manifest, '{"version":1,"paths":[]}', { mode: 0o600 }) + writeFileSync( + manifest, + '{"version":1,"pid":4242,"start":"1","created":0,"paths":[],"sources":[]}', + { mode: 0o600 }, + ) const late = withARuntimeDirectory( attacker( 'late.ts', @@ -754,7 +762,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { try { // The shell is found once, so in a copy of the library of its own. const fresh = (await import( - `${JSON.parse(LIBRARY)}?${basename(sh)}` + `../../src/sandbox/linux-sandbox-utils.ts?${basename(sh)}` )) as { wrapCommandWithSandboxLinux: typeof wrapCommandWithSandboxLinux } @@ -1481,7 +1489,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string // the allowed write area @@ -1879,7 +1887,7 @@ describe.if(isLinux)( const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string @@ -2409,7 +2417,7 @@ describe.if(isLinux)( ...rest: unknown[] ) => { const names = (readdir as (...args: unknown[]) => unknown)(dir, ...rest) - if (dir === shared[0]) { + if (dir === shared[1]) { for (const name of shared) { fs.rmdirSync(name) writeFileSync(name, '') @@ -2676,7 +2684,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -2975,7 +2983,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -3192,7 +3200,7 @@ describe.if(isLinux)( 'true', ]).status === 0 const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string @@ -3313,3 +3321,200 @@ describe.if(isLinux)( ) }, ) + +/** + * Where the manifests go follows from the user id, whatever the environment + * says. The child processes here load the library itself and work the places + * out with nothing put in their way, in a mount namespace in which directories + * of the test's own stand at /var/tmp, /run/user and /tmp. + */ +describe.if(isLinux)('The manifest directories at their real names', () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const UID = isLinux ? process.getuid!() : 0 + const PLACES = [ + ['/var/tmp', `srt-mount-points-${UID}`], + ['/run/user', `${UID}/srt-mount-points`], + ['/tmp', `srt-mount-points-${UID}`], + ] + .filter(([system]) => existsSync(system!)) + .map(([system, place]) => join(system!, place!)) + let BASE: string + let ROOT: string // what stands at those names is under here + let AREA: string + let LOCK: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-places-'))) + ROOT = join(BASE, 'root') + makePlaces(ROOT) + AREA = join(BASE, 'area') + mkdirSync(AREA) + LOCK = join(AREA, 'guard.lock') + }) + + afterEach(() => rmSync(BASE, { recursive: true, force: true })) + + /** + * Starts a process with a runtime directory and a temp dir of its own, named + * after `name`, that wraps `command`, runs it, cleans up after it and prints + * what it said. + */ + function running( + name: string, + command: string, + allowOnly: string[], + ): { said: Promise } { + const file = join(BASE, `${name}.ts`) + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${JSON.stringify(join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'))}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ${JSON.stringify(allowOnly)}, denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + `console.log(String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const env = { ...process.env } + for (const variable of ['XDG_RUNTIME_DIR', 'TMPDIR']) { + env[variable] = join(BASE, `${name}-${variable}`) + mkdirSync(env[variable], { mode: 0o700 }) + } + const argv = [...inPrivateNamespace(ROOT, [BASE]), process.execPath, file] + const child = spawn(argv[0]!, argv.slice(1), { + env, + stdio: ['ignore', 'pipe', 'inherit'], + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + let out = '' + child.stdout.on('data', (data: Buffer) => (out += data.toString())) + return { + said: new Promise(resolve => child.on('close', () => resolve(out))), + } + } + + async function until(file: string): Promise { + const deadline = Date.now() + 30000 + while (!existsSync(file)) { + if (Date.now() > deadline) throw new Error(`${file} never came`) + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be neither made, written, moved aside, removed nor replaced from inside a sandbox that may write all around them', + async () => { + // None is there when the wrap begins. A command that could make one, as + // the same user and with the right mode, would be believed. + const attempts = PLACES.flatMap(place => [ + `touch ${dirname(place)}/beside; echo beside=$?`, + `mkdir -m 700 ${place}; echo make=$?`, + `touch ${place}/forged.json; echo write=$?`, + `mv ${place} ${place}.aside; echo aside=$?`, + `rmdir ${place}; echo remove=$?`, + `mkdir -m 700 ${place}.mine; mv -T ${place}.mine ${place}; echo replace=$?`, + ]) + const said = await running( + 'attempts', + `exec 2>/dev/null; ${attempts.join('; ')}`, + [AREA, ...PLACES.map(place => dirname(place))], + ).said + expect(said.trim().split('\n')).toEqual( + PLACES.flatMap(() => [ + 'beside=0', + ...['make', 'write', 'aside', 'remove', 'replace'].map(what => + expect.stringMatching(new RegExp(`^${what}=[1-9]`)), + ), + ]), + ) + for (const place of PLACES) { + expect(lstatSync(join(ROOT, place)).mode & 0o40777).toBe(0o40700) + expect(readdirSync(join(ROOT, place))).toEqual([]) + expect(existsSync(join(ROOT, `${place}.aside`))).toBe(false) + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( + 'are read under a /run/user/UID that a sandboxed command has opened to others', + async () => { + // The name in /var/tmp is taken, so the manifest goes under + // /run/user/UID, which the command may write. What a killed process left + // in /tmp names the same path: a clean-up that no longer read the first + // would take the mount point from under the sandbox. + writeFileSync(join(ROOT, `/var/tmp/srt-mount-points-${UID}`), '') + const first = running( + 'first', + [ + `chmod 0777 /run/user/${UID}; echo chmod=$?`, + `echo up > ${AREA}/up; while [ ! -e ${AREA}/end ]; do sleep 0.05; done`, + `echo pwned > ${LOCK}; echo rc=$?`, + ].join('; '), + [AREA, `/run/user/${UID}`], + ) + await until(join(AREA, 'up')) + expect( + readdirSync(join(ROOT, `/run/user/${UID}/srt-mount-points`)), + ).toHaveLength(2) + const left = join(ROOT, `/tmp/srt-mount-points-${UID}`) + writeFileSync( + join(left, '4194000-00.json'), + JSON.stringify({ + version: 1, + pid: 4194000, + start: '1', + ns: readlinkSync('/proc/self/ns/pid'), + created: 1, + paths: [LOCK], + sources: [], + }), + { mode: 0o600 }, + ) + await running('second', 'true', [AREA]).said + expect(readdirSync(left)).toEqual([]) + expect(lstatSync(LOCK).size).toBe(0) + writeFileSync(join(AREA, 'end'), '') + const said = await first.said + expect(said).toContain('chmod=0') + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'keep a mount point for the sandbox of a process with another runtime directory and temp dir', + async () => { + // The second wrap finds the mount point of the first. Unless it reads + // that one's manifest it takes it for the user's own file and names it + // nowhere, and the first process's clean-up takes it from under it. + const waitFor = (name: string): string => + `echo up > ${AREA}/up-${name}; while [ ! -e ${AREA}/end-${name} ]; do sleep 0.05; done` + const first = running('first', waitFor('first'), [AREA]) + await until(join(AREA, 'up-first')) + const second = running( + 'second', + `${waitFor('second')}; echo pwned > ${LOCK}; echo rc=$?`, + [AREA], + ) + await until(join(AREA, 'up-second')) + writeFileSync(join(AREA, 'end-first'), '') + await first.said + expect(lstatSync(LOCK).size).toBe(0) + writeFileSync(join(AREA, 'end-second'), '') + expect(await second.said).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(join(ROOT, PLACES[0]!))).toEqual([]) + }, + 90000, + ) +}) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 549ca4ed7..f9eb36e21 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -26,13 +26,18 @@ import { discardMountPointManifest, forgetMountPointManifestDirectory, liveMountPoints, + mountPointManifestDirectories, namedMountPoints, type NamedMountPoints, publishMountPointManifest, + setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + makePlaces, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * The manifests and the pass that collects on their word, below the level of a @@ -42,10 +47,10 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() const MODULE = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/bwrap-mount-manifests.ts'), + join(import.meta.dir, '../helpers/isolated/manifests.ts'), ) const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const NOT_ROOT = process.getuid?.() !== 0 @@ -539,7 +544,11 @@ describe.if(isLinux)('The mount point manifests', () => { const written = JSON.parse(readFileSync(manifest, 'utf8')) as { created: number } - expect(written).toMatchObject({ pid: outer, start: startOf(stat) }) + expect(written).toMatchObject({ + pid: outer, + start: startOf(stat), + boot: readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), + }) expect(written.created).toBeGreaterThanOrEqual(before) expect(written.created).toBeLessThanOrEqual(Date.now()) }) @@ -1266,9 +1275,12 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('takes every manifest for live when it cannot tell which PID namespace it is in', () => { - // With no /proc every process asked after reads as gone. + // With no /proc every process asked after reads as gone. However old: that + // is a condition of this process, which the age of a file does not end. leftover(X) - started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + const manifest = manifestOfADeadProcess([X]) + aged(started(manifest, { pid: deadPid(), start: '1' }), 30 * 24 * 60) + aged(recordOf(manifest), 30 * 24 * 60) forgetMountPointManifestDirectory() const spy = spyOn(fs, 'readlinkSync').mockImplementation((() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -1529,6 +1541,202 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(manifest)).toBe(true) }) + it('is live for seven days when written in another PID namespace, or in none it records, and finished then', () => { + // Nothing else ends one whose namespace is gone. Counted from the later of + // the manifest and its record, which is when a sandbox last started. + const DAYS = 24 * 60 + for (const ns of ['pid:[1]', undefined]) { + leftover(X) + const manifest = started(manifestOfADeadProcess([X], { ns }), { + pid: deadPid(), + start: '1', + }) + for (const [written, recorded] of [ + [7 * DAYS - 1, 7 * DAYS + 1], + [7 * DAYS + 1, 7 * DAYS - 1], + ] as const) { + aged(manifest, written) + aged(recordOf(manifest), recorded) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + } + aged(recordOf(manifest), 7 * DAYS + 1) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + + // With no record, from the manifest alone. + leftover(X) + aged(manifestOfADeadProcess([X], { ns }), 7 * DAYS + 1) + expect(collectMountPoints()).toEqual([X]) + } + }) + + /** Has every directory be on a file system of that kind, or not say. */ + function onAFileSystemOf(type: number | bigint | Error): { + restore(): void + } { + const spy = spyOn(fs, 'statfsSync').mockImplementation((() => { + if (type instanceof Error) throw type + return { type } + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('is finished when written in another boot, whatever runs on its record and whichever PID namespace wrote it', () => { + // The directory outlives a reboot, and no process does. On each kind of + // file system that only this kernel reaches: tmpfs, ext4, xfs, btrfs, + // overlayfs, zfs, f2fs. The kind may come as a bigint. + for (const type of [ + ...[0x01021994, 0xef53, 0x58465342, 0x9123683e], + ...[0x794c7630, 0x2fc12fc1, 0xf2f52010, BigInt(0xef53)], + ]) { + const kind = onAFileSystemOf(type) + try { + for (const ns of [OWN_NAMESPACE, 'pid:[1]', undefined]) { + leftover(X) + started( + manifestOfADeadProcess([X], { ns, boot: 'another' }), + thisProcess(), + ) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + } + } finally { + kind.restore() + } + } + }) + + it('is not finished for its boot where another kernel may reach the directory, or that cannot be asked', () => { + // There another boot id may be that of a host that is up, with the sandbox + // running on it. NFS, 9p, FUSE and virtiofs, and a kind nobody has heard of. + leftover(X) + for (const type of [ + ...[0x6969, 0x01021997, 0x65735546, 0x12345678], + Object.assign(new Error('ENOSYS'), { code: 'ENOSYS' }), + ]) { + const manifest = started( + manifestOfADeadProcess([X], { boot: 'another' }), + thisProcess(), + ) + const kind = onAFileSystemOf(type) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + } finally { + kind.restore() + } + rmSync(manifest) + rmSync(recordOf(manifest)) + } + }) + + // Two hosts each in their initial PID namespace name that alike, so where + // another kernel may reach the directory only an equal boot id says that a + // manifest is this kernel's, and a pid on its record one to ask after here. + const BOOT_ID = '/proc/sys/kernel/random/boot_id' + const OWN_BOOT = isLinux ? readFileSync(BOOT_ID, 'utf8').trim() : '' + + for (const [what, fields, ownIsUnreadable] of [ + ['is of another boot', { boot: 'another' }, false], + ['records no boot', {}, false], + [ + 'is of a boot this process cannot hold against its own', + { boot: OWN_BOOT }, + true, + ], + ] as const) { + it(`is live for seven days on a file system another kernel may reach when it ${what}, though of this PID namespace by name and with nobody on its record here`, () => { + const left = (): string => { + leftover(X) + return started(manifestOfADeadProcess([X], fields), { + pid: deadPid(), + start: '1', + }) + } + const bothAged = (manifest: string, minutes: number): void => { + aged(manifest, minutes) + aged(recordOf(manifest), minutes) + } + forgetMountPointManifestDirectory() + const spies = [onAFileSystemOf(0x6969)] + if (ownIsUnreadable) { + spies.push(failingCall('readFileSync', BOOT_ID, 'EACCES')) + } + try { + const manifest = left() + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + bothAged(manifest, 6 * 24 * 60) + expect(collectMountPoints()).toEqual([]) + bothAged(manifest, 7 * 24 * 60 + 1) + expect(collectMountPoints()).toEqual([X]) + + // Where only this kernel reaches: at once, as ever. + spies.shift()!.restore() + spies.push(onAFileSystemOf(0xef53)) + left() + expect(collectMountPoints()).toEqual([X]) + } finally { + spies.forEach(spy => spy.restore()) + forgetMountPointManifestDirectory() + } + }) + } + + it('is judged as ever on a file system another kernel may reach when it is of this boot: the same kernel', () => { + for (const type of [0x6969, 0xef53]) { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { boot: OWN_BOOT }), + thisProcess(), + ) + const kind = onAFileSystemOf(type) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + started(manifest, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + } finally { + kind.restore() + } + } + }) + + it('asks which kind of file system a directory is on once for a reading, however many manifests are in it', () => { + for (let i = 0; i < 3; i++) { + manifestOfADeadProcess([X], { boot: 'another' }) + } + const statfs = spyOn(fs, 'statfsSync') + try { + expect(live()).toEqual([]) + expect(statfs.mock.calls.filter(([dir]) => dir === DIR)).toHaveLength(1) + } finally { + statfs.mockRestore() + } + }) + + it('concludes nothing from the boot where it cannot tell its own', () => { + leftover(X) + started(manifestOfADeadProcess([X], { boot: 'another' }), thisProcess()) + forgetMountPointManifestDirectory() + const failing = failingCall( + 'readFileSync', + '/proc/sys/kernel/random/boot_id', + 'EACCES', + ) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(readFileSync(publish([X]), 'utf8')).not.toContain('"boot"') + } finally { + failing.restore() + } + }) + // ---- where the manifests are kept -------------------------------------- it.if(BWRAP_CAN_NAMESPACE)( @@ -1698,6 +1906,388 @@ describe.if(isLinux)('The mount point manifests', () => { } }) + // ---- several directories ---- + // + // Where the manifests go follows from the user id first, so that processes + // with different environments read each other's, and a process believes + // more directories than the one it writes in. + + describe('in several directories', () => { + let PLACES: string[] // under /var/tmp, /run/user/UID and /tmp, none made + let NAMED: string[] // what the environment names + let Y: string + + beforeEach(() => { + PLACES = makePlaces(join(BASE, 'root')) + NAMED = [DIR, join(tmpdir(), `srt-mount-points-${process.getuid!()}`)] + Y = join(BASE, 'second.lock') + setMountPointManifestPlacesForTesting(join(BASE, 'root')) + }) + + afterEach(() => { + setMountPointManifestPlacesForTesting('/nonexistent') + rmSync(NAMED[1]!, { recursive: true, force: true }) + }) + + /** What `make` makes, in `place`, which is made, instead of in DIR. */ + function at(place: string, make: () => T): T { + mkdirSync(place, { recursive: true, mode: 0o700 }) + chmodSync(place, 0o700) + DIR = place + try { + return make() + } finally { + DIR = runtime.manifestDir() + } + } + + /** Has something else be at each of those names. */ + function taken(places: string[]): void { + for (const place of places) { + rmSync(place, { recursive: true, force: true }) + writeFileSync(place, '') + } + } + + it("are written in the first that can be made the user's alone: /var/tmp, /run/user/UID, /tmp, then as the environment says", () => { + const order = [...PLACES, ...NAMED] + try { + for (const place of order) { + expect(recordedIn()).toBe(place) + expect(lstatSync(place).mode & 0o777).toBe(0o700) + collectMountPoints() + taken([place]) + } + expect(order).not.toContain(recordedIn()) + } finally { + collectMountPoints() + order.forEach(place => rmSync(place)) + mkdirSync(DIR, { mode: 0o700 }) + } + }) + + it('are believed, collected in and bound, but not written, under a /run/user/UID that is open to others', () => { + // Where that is a write root a sandboxed command can open it up, and + // must not have other processes read less by that. + leftover(X) + const relied = at(PLACES[1]!, () => + started(manifestOfADeadProcess([X]), thisProcess()), + ) + at(PLACES[2]!, () => manifestOfADeadProcess([X])) + taken([PLACES[0]!]) + chmodSync(dirname(PLACES[1]!), 0o777) + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(PLACES[1]!)).toEqual([]) + + // Made, too, so that the command cannot. + rmSync(PLACES[1]!, { recursive: true }) + expect(mountPointManifestDirectories()).toContain(PLACES[1]) + expect(lstatSync(PLACES[1]!).mode & 0o40777).toBe(0o40700) + + // It is none to write in: with no other of the three, what the + // environment names is believed. + taken([PLACES[2]!]) + forgetMountPointManifestDirectory() + at(NAMED[1]!, () => manifestOfADeadProcess([Y])) + expect(named()).toEqual([Y]) + }) + + for (const [what, spoil] of [ + [ + 'a link', + (dir: string) => { + fs.renameSync(dir, `${dir}-moved`) + symlinkSync(`${dir}-moved`, dir) + }, + ], + ["somebody else's", (dir: string) => asSomebodyElses(dir)], + ] as const) { + it(`are neither written nor believed under a /run/user/UID that is ${what}`, () => { + leftover(X) + at(PLACES[1]!, () => manifestOfADeadProcess([X])) + taken([PLACES[0]!]) + const spoiled = spoil(dirname(PLACES[1]!)) + try { + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + expect(mountPointManifestDirectories()).not.toContain(PLACES[1]) + expect(named()).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + } finally { + spoiled?.restore() + } + }) + } + + for (const [what, squat] of [ + [ + 'a link', + (place: string) => { + fs.renameSync(place, `${place}-moved`) + symlinkSync(`${place}-moved`, place) + }, + ], + ["somebody else's", (place: string) => asSomebodyElses(place)], + ] as const) { + for (const [system, i, next] of [ + ['/var/tmp', 0, 1], + ['/tmp', 2, 0], + ] as const) { + it(`are neither written, believed nor bound at the name in ${system} when that is ${what}`, () => { + // The name is predictable, in a directory anyone may write. + leftover(X) + at(PLACES[i]!, () => manifestOfADeadProcess([X])) + const squatted = squat(PLACES[i]!) + try { + expect(dirname(publish([Y]))).toBe(PLACES[next]!) + expect(mountPointManifestDirectories()).not.toContain(PLACES[i]) + expect(named()).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + } finally { + squatted?.restore() + } + }) + } + } + + it('are made, every one, for a wrap to bind, and none by a mere look', () => { + rmSync(DIR, { recursive: true }) + expect(named()).toEqual([]) + expect(live()).toEqual([]) + expect([...PLACES, ...NAMED].filter(existsSync)).toEqual([]) + expect(mountPointManifestDirectories()).toEqual([...PLACES, ...NAMED]) + for (const place of [...PLACES, ...NAMED]) { + expect(lstatSync(place).mode & 0o40777).toBe(0o40700) + } + }) + + it('are believed in each of the three, whichever this process writes in', () => { + for (const place of PLACES) { + leftover(X) + const relied = at(place, () => + started(manifestOfADeadProcess([X]), thisProcess()), + ) + PLACES.forEach(each => at(each, () => manifestOfADeadProcess([X]))) + expect(named()).toEqual([X]) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + expect(PLACES.flatMap(each => readdirSync(each))).toEqual([]) + } + }) + + it('are believed where the environment says only by a process that can write in none of the three, or has settled there', () => { + // Only processes with that environment keep those out of a sandbox's + // reach. + leftover(X) + const planted = NAMED.map(place => + at(place, () => manifestOfADeadProcess([X])), + ) + const junk = join(NAMED[1]!, '4242-00.json') + writeFileSync(junk, '{}') + // A clean-up makes the first of the three before it looks. + expect(collectMountPoints()).toEqual([]) + expect(named()).toEqual([]) + expect(live()).toEqual([]) + expect(planted.filter(existsSync)).toEqual(planted) + + // Both of them, with none of the three to write in. + taken(PLACES) + forgetMountPointManifestDirectory() + expect(named()).toBeUndefined() + rmSync(junk) + rmSync(planted[0]!) + expect(named()).toEqual([X]) + expect(dirname(publish([Y]))).toBe(NAMED[0]!) + + // Only the one it writes in, once one of the three will do again. + PLACES.forEach(place => rmSync(place)) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + expect(named()).toEqual([Y]) + }) + + it('puts everything in doubt by what cannot be read in any of them', () => { + for (const place of PLACES) { + leftover(X) + PLACES.forEach(each => at(each, () => manifestOfADeadProcess([X]))) + const junk = join(place, '4242-00.json') + writeFileSync(junk, '{}') + expect(named()).toBeUndefined() + expect(live()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + expect(PLACES.flatMap(each => at(each, claims))).toEqual([]) + rmSync(junk) + expect(collectMountPoints()).toEqual([X]) + } + }) + + it("goes by the file system a manifest's own directory is on for what its boot says", () => { + leftover(X) + leftover(Y) + for (const [place, mountPoint] of [ + [PLACES[0]!, X], + [PLACES[2]!, Y], + ] as const) { + at(place, () => + started( + manifestOfADeadProcess([mountPoint], { boot: 'another' }), + thisProcess(), + ), + ) + } + const spy = spyOn(fs, 'statfsSync').mockImplementation((( + dir: string, + ) => ({ + type: dir === PLACES[2] ? 0x6969 : 0xef53, + })) as never) + try { + expect(live()).toEqual([Y]) + expect(collectMountPoints()).toEqual([X]) + } finally { + spy.mockRestore() + } + }) + + it('removes nothing when one of them cannot be looked at', () => { + // Not even what this process could not record and would remove on its + // own word. + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + unrecorded([Y]) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + const look = failingCall('lstatSync', PLACES[2]!, 'EIO') + try { + expect(named()).toBeUndefined() + expect(live()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + } finally { + look.restore() + } + expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + }) + + it('reads every directory again once it has claimed, not only where it claimed', () => { + // Published in another directory since the first reading, and claimed + // there by another pass that its sandbox got past. + leftover(X) + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + let claim: string | undefined + const pass = duringTheNextPass(() => { + claim = at(PLACES[2]!, () => + claimed(started(manifestOfADeadProcess([X]), thisProcess())), + ) + }) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(claim!)).toBe(true) + }) + + it('lists every directory again before every removal, one made since the pass began too', () => { + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X, Y])) + const removal = atTheRemovalOf(Y, () => + at(PLACES[2]!, () => + manifestOfADeadProcess([X], { + pid: process.pid, + created: Date.now(), + }), + ), + ) + try { + expect(collectMountPoints()).toEqual([Y]) + } finally { + removal.restore() + } + expect(existsSync(X)).toBe(true) + }) + + it('lists the first of them last before a removal, where most is published', () => { + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X, Y])) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + const readdir = fs.readdirSync + let listings = 0 + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + // Two readings, then the listing before the first removal. + if (args[0] === PLACES[2] && ++listings === 3) { + at(PLACES[0]!, () => + manifestOfADeadProcess([X, Y], { pid: process.pid }), + ) + } + return readdir(...args) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + it('answers a wrap with an earlier reading only while none of them lists anything new', () => { + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + const first = reads(namedMountPoints()) + expect(namedMountPoints(first)).toBe(first) + at(PLACES[2]!, () => manifestOfADeadProcess([Y])) + expect([...reads(namedMountPoints(first)).paths].sort()).toEqual( + [X, Y].sort(), + ) + }) + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'keeps all that is named in a directory that is read-only from here, and writes in the next', + () => { + // As /var/tmp is in a read-only container, and every place is to a + // process inside another sandbox: nothing can be claimed there. + const Z = join(BASE, 'third.lock') + ;[X, Y, Z].forEach(leftover) + const [manifest, claim] = at(PLACES[0]!, () => [ + manifestOfADeadProcess([X]), + claimed(manifestOfADeadProcess([Y])), + ]) + at(PLACES[2]!, () => manifestOfADeadProcess([X, Y, Z])) + const looked = inAChild( + [ + `m.setMountPointManifestPlacesForTesting(${JSON.stringify(join(BASE, 'root'))})`, + `const named = [...m.namedMountPoints().paths].sort()`, + `const removed = m.collectMountPoints()`, + `console.log(JSON.stringify({ named, removed, file: m.publishMountPointManifest(['/nonexistent/x'], []).file }))`, + ].join('\n'), + { + launcher: [ + ...['bwrap', '--dev-bind', '/', '/'], + ...['--ro-bind', PLACES[0]!, PLACES[0]!, '--'], + ], + }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ + named: [X, Y, Z].sort(), + removed: [Z], + file: expect.stringContaining(`${PLACES[1]}/`), + }) + expect([X, Y, manifest!, claim!].filter(existsSync)).toHaveLength(4) + }, + 15000, + ) + }) + // ---- with nowhere to record ---- // // No other process can know of these mount points, so the process that made @@ -1802,7 +2392,13 @@ describe.if(isLinux)('The mount point manifests', () => { return text } - const notAManifest: [string, (name: string, paths: string[]) => void][] = [ + // The last says what can be a manifest of no version, as a crash leaves one + // that was not on disk yet: it counts for the grace, not for the hour. + const notAManifest: [ + string, + (name: string, paths: string[]) => void, + 'of no version'?, + ][] = [ [ 'a link to a manifest kept somewhere else', (name, paths) => { @@ -1824,6 +2420,7 @@ describe.if(isLinux)('The mount point manifests', () => { 'a manifest cut short', (name, paths) => writeFileSync(name, finishedManifestText(paths).slice(0, -1)), + 'of no version', ], [ 'a manifest of a later version without a field this one goes by', @@ -1835,7 +2432,7 @@ describe.if(isLinux)('The mount point manifests', () => { .replace('"sources":[]', '"origins":[]'), ), ], - ['an empty file', name => writeFileSync(name, '')], + ['an empty file', name => writeFileSync(name, ''), 'of no version'], ['a directory', name => mkdirSync(name)], [ 'a FIFO nobody writes to', @@ -1849,8 +2446,8 @@ describe.if(isLinux)('The mount point manifests', () => { writeFileSync(name, finishedManifestText(paths), { mode: 0 }), ]) } - for (const [what, plant] of notAManifest) { - it(`removes nothing beside ${what} until it is an hour old, and passes over it then`, () => { + for (const [what, plant, ofNoVersion] of notAManifest) { + it(`removes nothing beside ${what} until it is ${ofNoVersion ? 'half a second' : 'an hour'} old, and passes over it then`, () => { const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -1872,6 +2469,8 @@ describe.if(isLinux)('The mount point manifests', () => { } // It names nothing that can be read, and may name anything. const inDoubt = { named: 'cannot tell', held: 'cannot tell', removed: [] } + // Not yet as old as the child takes to look, nor just short of the hour. + aged(junk, ofNoVersion ? -1 : 59) expect(look()).toEqual(inDoubt) // Nor while a process is on its record, however old it is. @@ -1880,6 +2479,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(look()).toEqual(inDoubt) // A record that names no process to ask after puts nothing off. + aged(junk, ofNoVersion ? 1 : 61) writeFileSync(recordOf(junk), 'ended\n') expect(look()).toEqual({ named: [Y], held: [], removed: [Y] }) expect(existsSync(X)).toBe(true) @@ -2024,7 +2624,7 @@ describe.if(isLinux)('The mount point manifests', () => { /** Has `call` on `target` fail with `code`. */ function failingCall( - call: 'readdirSync' | 'lstatSync', + call: 'readdirSync' | 'lstatSync' | 'readFileSync', target: string, code: string, ): { restore(): void } { diff --git a/test/sandbox/path-canonicalization.test.ts b/test/sandbox/path-canonicalization.test.ts index 20e676cc0..9bb577347 100644 --- a/test/sandbox/path-canonicalization.test.ts +++ b/test/sandbox/path-canonicalization.test.ts @@ -47,10 +47,8 @@ function normalizeWithHome( ): { homedir: string; results: string[]; tildeBindsRoot: boolean } { const module = new URL('../../src/sandbox/sandbox-utils.ts', import.meta.url) .href - const linuxModule = new URL( - '../../src/sandbox/linux-sandbox-utils.ts', - import.meta.url, - ).href + const linuxModule = new URL('../helpers/isolated/library.ts', import.meta.url) + .href const child = spawnSync( process.execPath, [ @@ -69,7 +67,8 @@ function normalizeWithHome( { encoding: 'utf8', timeout: 20000, - env: { PATH: process.env.PATH ?? '', HOME: home }, + // The temp dir is where the wrap keeps its mount point manifests. + env: { PATH: process.env.PATH ?? '', HOME: home, TMPDIR: tmpdir() }, // Its own directory: the Linux wrap scans the working directory and may // leave mount-point stubs there, which this process would not clean up. cwd: scratch, diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 2e139ef99..61bdc5f4e 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -481,7 +481,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), )}`, `const command = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, @@ -751,7 +751,7 @@ describe.if(isLinux)( script, [ `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), )}`, `process.umask(0o200)`, `for (let i = 0; i < 2; i++) {`, From 825276bbf2b22a8b0e28d63cea6a665cba5d4681 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:17:31 +0000 Subject: [PATCH 089/141] test: join main's newer suites with the manifests - Cases that compare two wrapped commands take the manifest's name out first: a wrap that names mount points names a manifest of its own. - What waits for the count of wraps is the manifest of a command that was wrapped and never started, not the removal of what a command that ran made. - The manifest of a wrap that names mount points is bound before the allow binds, and every wrap looks after the manifest directories. - The directory of the manifests is there to outlive the process. - Child processes load the library through test/helpers/isolated, so that no suite reaches the manifest directories the user's own processes keep. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/helpers/bwrap-argv.ts | 12 +++ test/sandbox/deny-order-independence.test.ts | 4 +- test/sandbox/glob-expand.test.ts | 21 ++++-- test/sandbox/linux-nested-userns.test.ts | 2 +- .../linux-unmakeable-mount-point.test.ts | 15 +++- test/sandbox/literal-path-entries.test.ts | 2 +- test/sandbox/mandatory-deny-paths.test.ts | 74 ++++++++++++++++--- ...les-through-links-on-every-runtime.test.ts | 2 +- test/sandbox/temp-objects-at-exit.test.ts | 13 +++- 9 files changed, 118 insertions(+), 27 deletions(-) diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 459a00763..059bf51a9 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -100,6 +100,18 @@ export function manifestOf(command: string): string | undefined { return id === undefined ? undefined : `${id}.json` } +/** + * `command` with the name of its manifest taken out. A wrap that names mount + * points names a manifest of its own for them, so two wraps by one policy + * differ in that name and in nothing else. + */ +export function withoutManifestName(command: string): string { + return command.replace( + /(srt-mount-points(?:-\d+)?\/)\d+-[0-9a-f]{16}/g, + '$1MANIFEST', + ) +} + /** How many times that whole mount appears. */ export function countMounts(command: string, ...words: MountWords): number { return runIndices(command, words).length diff --git a/test/sandbox/deny-order-independence.test.ts b/test/sandbox/deny-order-independence.test.ts index cbcaab1e9..8a30dbd70 100644 --- a/test/sandbox/deny-order-independence.test.ts +++ b/test/sandbox/deny-order-independence.test.ts @@ -15,7 +15,7 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' -import { mountsOf } from '../helpers/bwrap-argv.js' +import { mountsOf, withoutManifestName } from '../helpers/bwrap-argv.js' /** The empty directory mounted over an absent deny path's first missing * component is made fresh per wrap, so its name says nothing about the plan. */ @@ -23,7 +23,7 @@ const EMPTY_MOUNT_SOURCE = /\S*claude-empty-[A-Za-z0-9]+/g /** A profile's mounts as a multiset, for the reason mountsOf gives. */ function mountMultiset(command: string): string[] { - return mountsOf(command) + return mountsOf(withoutManifestName(command)) .map(mount => mount.replace(EMPTY_MOUNT_SOURCE, '')) .sort() } diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 38e8f5827..bb528a97b 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -58,6 +58,7 @@ import type { MappedDriveCwdError, SeclogonDisabledError, } from '../../src/sandbox/windows-sandbox-utils.js' +import { withoutManifestName } from '../helpers/bwrap-argv.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' import { isLinux, isWindows } from '../helpers/platform.js' import { spawnSync } from 'node:child_process' @@ -2787,7 +2788,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { readdirSpy.mockRestore() expect(listed).toBeGreaterThan(2) - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'second')) expect(disturbed).not.toContain(join(root, 'first')) } finally { @@ -2851,7 +2854,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { expect(listed[0]).toBe(root) expect(disturbed).toContain(join(root, 'key.pem')) - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) // The first attempt is left at once; the second lists all seven. expect(listed.length).toBe(at + 7) } finally { @@ -2907,7 +2912,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { const disturbed = await SandboxManager.wrapWithSandbox('true') readdirSpy.mockRestore() - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'to')) expect(disturbed).not.toContain(join(root, 'from')) expect(listed.length).toBe(at + 7) @@ -2948,7 +2955,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { const disturbed = await SandboxManager.wrapWithSandbox('true') expect(fs.readFileSync(scans, 'utf8')).toBe('ran\nran\n') - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'to')) expect(disturbed).not.toContain(join(root, 'from')) } finally { @@ -3308,7 +3317,9 @@ describe.if(isLinux)('wrapWithSandbox while another wrap walks', () => { expect(listed).toBe('aabbbaaa') expect(waited).toContain(join(root, holds)) expect(waited).not.toContain(join(root, lacks)) - expect(waited).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(waited)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) }) it('keeps the order through a reset()', async () => { diff --git a/test/sandbox/linux-nested-userns.test.ts b/test/sandbox/linux-nested-userns.test.ts index ee8cd578a..25c9d40d7 100644 --- a/test/sandbox/linux-nested-userns.test.ts +++ b/test/sandbox/linux-nested-userns.test.ts @@ -323,7 +323,7 @@ describe.if(isLinux)( it('says once per process that nothing limits namespaces, and only where that is so', () => { // A process of its own: the warning is given once in each. const script = ` - import { wrapCommandWithSandboxLinux } from ${JSON.stringify(join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'))} + import { wrapCommandWithSandboxLinux } from ${JSON.stringify(join(import.meta.dir, '../helpers/isolated/library.ts'))} const wrap = bwrapPath => wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index e6a6c40ba..509cf698e 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -182,8 +182,13 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { expect(mountsAt(command, join(DIR, 'absent'))).toEqual( planned ? [emptyDirectoryAt(join(DIR, 'absent'))] : [], ) - // The parent is pinned either way, beneath the allow bind. - expect(mountsOf(command).indexOf(`--ro-bind ${DIR} ${DIR}`)).toBe(1) + // The parent is pinned either way, beneath the allow bind. The + // manifest of a wrap that names mount points is bound before both. + expect( + mountsOf(command) + .filter(mount => !mount.includes('srt-mount-points')) + .indexOf(`--ro-bind ${DIR} ${DIR}`), + ).toBe(1) // And the question is asked without a trace. expect(fs.readdirSync(DIR)).toEqual([]) }, @@ -596,7 +601,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('asks nothing of the host for a path that needs no placeholder', async () => { const opened: unknown[] = [] stub('openSync', p => { - opened.push(p) + // Every wrap looks after the manifest directories, whatever it denies. + if (!String(p).includes('srt-mount-points')) opened.push(p) return undefined }) fs.writeFileSync(join(ROOT, 'file'), '') @@ -617,7 +623,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('asks once for deny paths that share a missing directory', async () => { const opened: unknown[] = [] stub('openSync', p => { - opened.push(p) + // Every wrap looks after the manifest directories, whatever it denies. + if (!String(p).includes('srt-mount-points')) opened.push(p) return undefined }) diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index 5dc79e9a4..2bd00311d 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -2354,7 +2354,7 @@ describe.if(!isWindows)( join(import.meta.dir, '../../src/sandbox/sandbox-utils.ts'), )}) const { SandboxManager } = await import(${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/sandbox-manager.ts'), + join(import.meta.dir, '../helpers/isolated/manager.ts'), )}) const kept = paths => paths.filter(p => p.endsWith('/debug') || p.endsWith('/_logs')).map(p => p.split('/').slice(-2).join('/')) ${body}` diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 3090265b2..0061ff42b 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -38,6 +38,7 @@ import { DANGEROUS_FILES, getDangerousDirectories, } from '../../src/sandbox/sandbox-utils.js' +import { manifestOf } from '../helpers/bwrap-argv.js' import { isLinux, isSupportedPlatform } from '../helpers/platform.js' /** @@ -1083,8 +1084,10 @@ describe.if(isSupportedPlatform)( } }, 15000) - // The caller runs no cleanup for a wrap that threw, so a removal - // deferred to that wrap has nobody else to run it. + // The caller runs no cleanup for a wrap that threw, so what was + // deferred to that wrap has nobody else to let go of it. A command that + // ran and has ended is let go of by any clean-up call; what waits for + // the count is the manifest of one that was wrapped and never started. const wrapInCwd = ( command: string, @@ -1108,26 +1111,24 @@ describe.if(isSupportedPlatform)( process.chdir(raceDir) try { - spawnSync(await wrapInCwd('true'), { - shell: true, - timeout: 10000, - }) - expect(readdirSync(raceDir)).toContain('.bashrc') + const manifestA = manifestOf(await wrapInCwd('true')) ?? '' + expect(existsSync(manifestA)).toBe(true) // B is still looking for the mandatory denies when A's caller - // cleans up. + // cleans up, and nothing tells that call from one for B. const controller = new AbortController() const wrappedB = wrapInCwd('true', { abortSignal: controller.signal, binShell: how === 'refused' ? 'no-such-shell' : undefined, }) cleanupBwrapMountPoints() - expect(readdirSync(raceDir)).toContain('.bashrc') + expect(existsSync(manifestA)).toBe(true) if (how === 'aborted') controller.abort(new Error('stopped')) expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( Error, ) + expect(existsSync(manifestA)).toBe(false) expect(readdirSync(raceDir)).toEqual([]) } finally { process.chdir(TEST_DIR) @@ -1235,6 +1236,58 @@ describe.if(isSupportedPlatform)( 15000, ) + // A running sandbox answers for its own mount points, so what a count + // that is one short costs is the start of a command that is wrapped and + // has not started: bubblewrap refuses to start without its manifest. + it.each(['aborted', 'handed out'])( + 'a wrap from before a forced cleanup that is %s leaves the manifest of a command wrapped since', + async how => { + const raceDir = join(TEST_DIR, 'race-test-9') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + const heldScan = join(raceDir, 'held-scan') + writeFileSync( + heldScan, + `#!/bin/sh\nuntil [ -e '${raceDir}/scanned' ]; do sleep 0.1; done\n`, + { mode: 0o755 }, + ) + + try { + const controller = new AbortController() + const wrappedB = wrapInCwd('true', { + abortSignal: controller.signal, + ripgrepConfig: { command: heldScan }, + }) + cleanupBwrapMountPoints({ force: true }) + const commandC = await wrapInCwd('echo STARTED') + + if (how === 'aborted') { + controller.abort(new Error('stopped')) + expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( + Error, + ) + } else { + writeFileSync('scanned', '') + spawnSync(await wrappedB, { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + } + + const ranC = spawnSync(commandC, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + expect(ranC.stdout).toContain('STARTED') + cleanupBwrapMountPoints() + expect(existsSync('.bashrc')).toBe(false) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }, + 15000, + ) + it('leaves nothing of a directory mount point that another sandbox was wrapped under', async () => { const raceDir = join(TEST_DIR, 'race-test-7') mkdirSync(raceDir, { recursive: true }) @@ -1253,9 +1306,10 @@ describe.if(isSupportedPlatform)( } // T's .claude is there by now, so X's mount points go inside it. + // They are collected once X has ended; the directory is T's still. spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) cleanupBwrapMountPoints() - expect(readdirSync('.claude')).not.toEqual([]) + expect(readdirSync('.claude')).toEqual([]) writeFileSync('go', '') await exitT diff --git a/test/sandbox/rules-through-links-on-every-runtime.test.ts b/test/sandbox/rules-through-links-on-every-runtime.test.ts index 3adb55508..70be4a807 100644 --- a/test/sandbox/rules-through-links-on-every-runtime.test.ts +++ b/test/sandbox/rules-through-links-on-every-runtime.test.ts @@ -26,7 +26,7 @@ import { isLinux, isMacOS } from '../helpers/platform.js' * hold bytes that are no text, which no rule and no mount can spell. */ -const CLI_PATH = join(process.cwd(), 'dist', 'cli.js') +const CLI_PATH = join(process.cwd(), 'test/helpers/isolated/dist-cli.mjs') const RUNTIMES = [ ['node', ['node', '--max-old-space-size=256']], ['bun', [process.execPath]], diff --git a/test/sandbox/temp-objects-at-exit.test.ts b/test/sandbox/temp-objects-at-exit.test.ts index 0dca4c22c..b6d3617a6 100644 --- a/test/sandbox/temp-objects-at-exit.test.ts +++ b/test/sandbox/temp-objects-at-exit.test.ts @@ -43,9 +43,16 @@ describe.if(isLinux || isMacOS)('temp objects when the process ends', () => { rmSync(BASE, { recursive: true, force: true }) }) - /** The library's objects among `names`, by kind: `srt-ca-Ab12Cd` is an `srt-ca`. */ + /** + * The library's objects among `names`, by kind: `srt-ca-Ab12Cd` is an + * `srt-ca`. Not the directory of the mount-point manifests: it is the + * user's, shared with other processes, and there to outlive this one. + */ const kinds = (names: string[]): string[] => - names.flatMap(name => /^(srt|claude)-[a-z]+/.exec(name)?.[0] ?? []).sort() + names + .filter(name => !name.startsWith('srt-mount-points')) + .flatMap(name => /^(srt|claude)-[a-z]+/.exec(name)?.[0] ?? []) + .sort() const START = `await SandboxManager.initialize(config) await SandboxManager.wrapWithSandbox('true')` @@ -64,7 +71,7 @@ describe.if(isLinux || isMacOS)('temp objects when the process ends', () => { const script = ` const { readdirSync } = await import('node:fs') const { SandboxManager } = await import(${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/sandbox-manager.ts'), + join(import.meta.dir, '../helpers/isolated/manager.ts'), )}) const config = { network: { From 7f0dc43723805a2e1d8526e1f5898c93fb051111 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:17:31 +0000 Subject: [PATCH 090/141] linux: a clean-up is on record while it removes, and a command line may be run again A clean-up decides on a reading of the manifests and removes afterwards. Held up between the two (stopped, paused, starved) while a wrap published and its sandbox started, it took a mount point from under that sandbox, and the denied write landed. Listing again before each removal made the gap short, not safe. Now each says what it is about before it looks at what the other has said. The clean-up puts itself on record, in an empty file named for its process, before the reading it decides on, and takes that away after its last removal. The wrap lists those records once it has published, waits for the ones it finds, and only then looks at the host again and hands its command out. A process in the same PID namespace is asked after, so one that is stopped is waited for and one that is gone is not; any other goes by the age of its record. A manifest stays live while the process that wrote it runs and has not let go of it, so the command line can be run again with other processes cleaning up in between. The writer lets go of the commands that have run and ended at every clean-up its caller makes. Of a record past 64 KiB the last runs are read. A process that has exited and is not waited for counts as gone. Under a process 1 that reaps nothing it stays in /proc for good, and so did what its sandbox relied on. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- .github/workflows/integration-tests.yml | 6 +- README.md | 18 +- src/sandbox/bwrap-mount-manifests.ts | 381 +++++++-- src/sandbox/linux-sandbox-utils.ts | 100 ++- src/sandbox/sandbox-manager.ts | 16 +- test/sandbox/mount-point-liveness.test.ts | 15 +- test/sandbox/mount-point-manifests.test.ts | 20 +- test/sandbox/mount-point-passes.test.ts | 930 +++++++++++++++++++++ 8 files changed, 1386 insertions(+), 100 deletions(-) create mode 100644 test/sandbox/mount-point-passes.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e6f997f01..74baade6c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -288,7 +288,11 @@ jobs: test/sandbox/path-canonicalization.test.ts \ test/sandbox/credential-deny.test.ts \ test/sandbox/symlink-boundary.test.ts \ - test/sandbox/stale-mount-point.test.ts + test/sandbox/mount-point-manifests.test.ts \ + test/sandbox/mount-point-liveness.test.ts \ + test/sandbox/mount-point-passes.test.ts \ + test/sandbox/stale-mount-point.test.ts \ + test/sandbox/linux-bwrap-args-file.test.ts - name: Run Node.js fallback tests if: matrix.os != 'windows' diff --git a/README.md b/README.md index b2217103a..fed47787e 100644 --- a/README.md +++ b/README.md @@ -1127,23 +1127,25 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and those of processes that are gone or have let go of them, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made before the wrap's manifest is published, so a process killed between the two leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. - **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. - **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/var/tmp` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none, for seven days from the last change to the manifest or to its record, whichever is later, and for finished then. Until then it removes nothing that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. The seven days are for a namespace that has gone: after `docker restart` the container has its files, the host's boot id and a new PID namespace, and nothing else would ever end what the earlier one left. A sandbox that runs longer than that in another PID namespace, sharing both this directory and the project, is no longer protected from processes outside its namespace. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/var/tmp` or `/tmp` can delete and forge manifests there. A process that can write in none of the first three places keeps its manifests where its environment says, which the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where one of the names, or a directory above it, is a symbolic link of the user's that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where the parent of a name does not exist, and the user may write the directory it is missing from, and that lies inside a write root (a process that runs as root and has no `/run/user/0`, with `/` or `/run` writable; an `XDG_RUNTIME_DIR` or `TMPDIR` that is not there), the wrap cannot make the manifest directory and a sandboxed command can, parent and all: every process that looks there afterwards believes it. Where `/run/user/` itself is a write root the command can open it to others; every process goes on reading what is under it, but another local user can then move that manifest directory aside. What a cleanup removes on such a manifest's word is bounded as above. - _A `/var/tmp/srt-mount-points-` that another user made._ Another local user can make that name, or the one in `/tmp`, before the user's first wrap. It fails the owner check and is passed over, never repaired, and the user's processes write in the next place. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in every directory it believes, and it lists them again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. - - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a container made anew with the project on a volume, a cleaner of `/var/tmp`, and, for a process that had to write in a later place, the last logout (`/run/user/` is a tmpfs that logind removes) or a reboot. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. - - _An older release beside this one._ A process of 0.0.77 takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running; one of an earlier release removes at its cleanup what it made itself, whoever relies on it by then. The protection holds between processes of this release and later. - - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. + - _A cleanup that cannot be asked after._ A wrap waits for a cleanup for as long as that one's process runs, which it can ask only of a process in its own PID namespace. The record of any other (a container that shares `/var/tmp` with the host) counts for a minute, and a cleanup removes nothing more once it has been on record for two seconds. So a cleanup in another PID namespace that is held up for over a minute in the moment between its look at the clock and the removal after it still removes a path from under a sandbox that has started since. Within one PID namespace there is no such moment. + - _A cleanup that is stopped._ While a process is stopped half way through a cleanup, which takes milliseconds, every wrap of that user that names a mount point waits its five seconds and is refused. The error names the record, and that begins with the pid: continue or end that process. + - _A directory made again._ A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. + - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.81 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a container made anew with the project on a volume, a cleaner of `/var/tmp`, and, for a process that had to write in a later place, the last logout (`/run/user/` is a tmpfs that logind removes) or a reboot. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. + - _An older release beside this one._ A process of 0.0.77 to 0.0.79 takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running; one of any other release up to 0.0.81 removes at its cleanup what it made itself, whoever relies on it by then. None of them writes a manifest, reads one, or puts its cleanup on record. The protection holds between processes of this release and later. + - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone, or have exited, a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when one writes where the other reads. Every process reads the first three places, so what is left is: a process that can write in none of them, which records where its environment says, where only a process in the same position looks, in a private directory (it runs inside another sandbox, where all are read-only), or nowhere; two processes that share a project but none of the three (a container beside the host, a service with a private `/tmp` and `/var/tmp`); and a release that wrote no manifest. Nothing the other process reads then says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that reads the manifest directory they are recorded in; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. @@ -1151,10 +1153,10 @@ Filesystem restrictions are enforced at the OS level: - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest, and a second run is then refused. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier: when the command's own `commandId` is passed, and at any call made once a run of it has ended). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. Until then it may be run again and again; each run adds its line to the record, of which the last 64 KiB are read, some two hundred runs. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them, or another process's cleanup is held up half way and what it set out to remove may go at any moment (`.cause` is its record, whose name begins with its pid); wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 73120345e..c913ef06a 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -14,17 +14,28 @@ * it hands out is a shell that appends its own /proc/self/stat line to * `.started` and then execs bubblewrap: the process on record is bubblewrap, * recorded before it has made a mount, and the wrap always passes - * --die-with-parent, so the sandbox dies with it. A manifest with a record is - * live exactly while a process with a recorded pid and start time exists, which - * any process can ask, so cleanup is a garbage collect any process may run at - * any time. + * --die-with-parent, so the sandbox dies with it. A manifest is live while a + * process with a recorded pid and start time exists, and while the process that + * wrote it exists and has not let go of it (the command line may be run again + * until then), which it does by claiming it. Any process can ask after both, so + * cleanup is a garbage collect any process may run at any time. * * bubblewrap binds the manifest before it makes a mount point, and a pass * claims a finished manifest, renaming it to `.claimed`, before it removes * what that names. A command started after the claim fails having made nothing; * one that got past it has its record on disk, which the pass reads after the * claim. Whatever cannot be read or asked (a manifest, a record, /proc) counts - * as live: only "no such process" and a different start time end a sandbox. + * as live: only "no such process", one that has exited and a different start + * time end a sandbox. + * + * A pass and a wrap each say what they are about before they look at what the + * other has said. The pass puts itself on record, `.pass`, and then + * reads the manifests it decides on; the wrap publishes its manifest and then + * lists the passes, and waits for those under way to end before it looks at the + * host again and hands its command out. So either the pass has read the + * manifest and keeps what it names, or the wrap has seen the pass and no + * sandbox starts until that has removed what it set out to, however long it is + * held up between its reading and a removal (see {@link collect}). * * A reading in doubt refuses every wrap of this user that restricts writes, so * NO LASTING STATE OF THE DIRECTORY REFUSES WRAPS FOR EVER. Every source of doubt @@ -71,6 +82,7 @@ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' const STARTED_SUFFIX = '.started' const CLAIMED_SUFFIX = '.claimed' +const PASS_SUFFIX = '.pass' /** * A manifest this young counts as live with no started record yet, so that a @@ -113,10 +125,31 @@ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 */ const OTHER_NAMESPACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +/** + * A pass removes nothing more once it has been on record for this long, and + * leaves the rest to the next: an ordinary one takes milliseconds. + */ +const PASS_GOOD_FOR_MS = 2 * 1000 + +/** + * A pass that cannot be asked after (another PID namespace's) counts as under + * way until its record is this old: well past {@link PASS_GOOD_FOR_MS}. + */ +const PASS_MAX_AGE_MS = 60 * 1000 + +/** + * For how long a wrap waits for the passes under way before it gives up: past + * {@link PASS_GOOD_FOR_MS}, so that only one that is held up outlasts it. + */ +const WAITS_FOR_PASSES_MS = 5 * 1000 + /** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 -/** The largest started record read: a line of some 300 bytes for each run. */ +/** + * How much of a started record is read, from its end: a line of some 300 bytes + * for each run, so the last two hundred runs of one command line. + */ const RECORD_MAX_BYTES = 64 * 1024 /** @@ -128,7 +161,7 @@ const TEMPORARY_SUFFIX = '.tmp' const ManifestSchema = z.object({ /** * INVARIANT for every later version: it may add fields, and keeps these, the - * three file names and what they mean, so a reader takes a later version by + * four file names and what they mean, so a reader takes a later version by * the fields it knows. A manifest that is not read puts everything in doubt, * which would refuse every wrap of an older process for as long as a newer * one is at work beside it. A change that cannot keep to this takes another @@ -198,17 +231,24 @@ export function keepTrackOfMountSource(source: string): void { /** * Which of this process's own manifests a collect may release. Only the caller - * knows that a command is over: + * knows that a command is over, and will not be run again: * * - `all`: no wrap of this process is outstanding, or the process is ending; - * - `none`: some are, and nothing says which is over, so only what other - * processes have finished with is collected; - * - one command: that command is over, whatever else is running. + * - `ended`: some are, and the caller says one is over and not which, so those + * that have run and ended are; + * - one command: that command, whatever else is running, and those that have + * run and ended; + * - `none`: the caller has said nothing, so only what other processes have + * finished with is collected. * - * A released manifest whose command has not started is collected, and the - * command is then refused its start. + * A released manifest is collected once no process on its record runs, and its + * command line is refused its start from then on. */ -export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } +export type OwnManifestRelease = + | 'all' + | 'ended' + | 'none' + | { commandKey: string } let manifestDirectory: string | undefined // Whether that is the last resort below, which no other process knows of. @@ -258,10 +298,15 @@ function ownProcess(): { pid: number; start: string } { /** * Whether the process with that pid and start time is running. Only "no such - * process" and a start time that differs say no: a sandboxed command can use up - * what this user may open, and a /proc that cannot be read must not make a live - * sandbox's mount points collectable. An unknown `start` leaves the pid alone - * to go by. + * process", a process that has exited and a start time that differs say no: a + * sandboxed command can use up what this user may open, and a /proc that cannot + * be read must not make a live sandbox's mount points collectable. An unknown + * `start` leaves the pid alone to go by. + * + * One that has exited stays in /proc until its parent asks after it. Where that + * is a process 1 that never asks, as in many a container, it stays for good, + * and so would what its sandbox relied on. It has let go of its namespaces and + * sent its children their signal by then, like one that is gone. */ function isRunning(pid: number, start: string): boolean { let stat: string @@ -271,6 +316,10 @@ function isRunning(pid: number, start: string): boolean { const code = (e as NodeJS.ErrnoException).code return code !== 'ENOENT' && code !== 'ESRCH' } + // Field 3, the first after the comm: Z is a zombie, X and x are dead. + if (/^ [ZXx] /.test(stat.slice(stat.lastIndexOf(')') + 1))) { + return false + } const found = startTimeFromProcStat(stat) return found === undefined || !/^\d+$/.test(start) || found === start } @@ -665,8 +714,13 @@ function isOwnReadableFile(file: string): boolean { * of the user's, no larger than `max`, is read, through a descriptor opened * without following a link and without blocking. Throws {@link NotOurs} for * anything else that is there, and the error itself where that cannot be told. + * With `itsLastLines`, of a larger one the whole lines in its last `max` bytes. */ -function readOwnFile(file: string, max: number): string | undefined { +function readOwnFile( + file: string, + max: number, + itsLastLines = false, +): string | undefined { let fd: number try { fd = fs.openSync( @@ -682,10 +736,21 @@ function readOwnFile(file: string, max: number): string | undefined { } try { const stat = fs.fstatSync(fd) - if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.size > max) { + if ( + !stat.isFile() || + stat.uid !== process.getuid?.() || + (stat.size > max && !itsLastLines) + ) { throw new NotOurs(`${file} is not a small regular file of this user's`) } - return fs.readFileSync(fd, 'utf8') + if (stat.size <= max) { + return fs.readFileSync(fd, 'utf8') + } + const end = Buffer.alloc(max) + const text = end + .subarray(0, fs.readSync(fd, end, 0, max, stat.size - max)) + .toString('utf8') + return text.slice(text.indexOf('\n') + 1) } finally { fs.closeSync(fd) } @@ -726,9 +791,13 @@ function readManifest(file: string): Manifest | undefined { /** * Whether a process on the started record is running, or `undefined` with no - * whole line on record. A record that cannot be read, is not the user's small - * regular file, or holds a line that is no /proc/PID/stat line vouches: only a - * process that is gone says its sandbox has ended. + * whole line on record. A record that cannot be read, is not the user's regular + * file, or holds a line that is no /proc/PID/stat line vouches: only a process + * that is gone says its sandbox has ended. + * + * A command line may be run again and again, and each run adds its line. Of a + * record past {@link RECORD_MAX_BYTES} the last runs are asked after: refusing + * to read it would keep what it vouches for named for ever. * * Not so an `orphan`, which has no manifest that can be read: it names no path, * so it protects none. Only a process that can be asked after vouches there, and @@ -743,7 +812,7 @@ function readManifest(file: string): Manifest | undefined { function recordVouches(record: string, orphan = false): boolean | undefined { let lines: string[] try { - const text = readOwnFile(record, RECORD_MAX_BYTES) + const text = readOwnFile(record, RECORD_MAX_BYTES, true) if (text === undefined) { return undefined } @@ -767,9 +836,11 @@ function recordVouches(record: string, orphan = false): boolean | undefined { * Whether a sandbox that named this manifest may be running, or may yet start. * None of another boot is, whatever else the manifest says. Only a process in * the PID namespace that wrote it can tell; to any other it is live until {@link - * OTHER_NAMESPACE_MAX_AGE_MS}. With a started record, the record alone says. - * With none, no sandbox has got past a claim, and one the caller has not - * released is live while it is young or its writer runs. + * OTHER_NAMESPACE_MAX_AGE_MS}. A process on its started record that runs keeps + * it live, whatever else. With none, no sandbox is past a claim, and one at its + * own name that the caller has not released is live while its writer runs (its + * command line may be run yet, for the first time or again), or while it is + * young and has no record. */ function isLive(manifest: Manifest): boolean { if ( @@ -789,13 +860,14 @@ function isLive(manifest: Manifest): boolean { ) } const vouched = recordVouches(manifest.record) - if (vouched !== undefined) { - return vouched + if (vouched === true) { + return true } return ( !manifest.claimed && !manifest.released && - (Date.now() - manifest.created < MANIFEST_GRACE_MS || + ((vouched === undefined && + Date.now() - manifest.created < MANIFEST_GRACE_MS) || isRunning(manifest.pid, manifest.start)) ) } @@ -1119,6 +1191,16 @@ function readManifests(dirs: readonly string[]): Reading { if (olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS)) { reading.spent.push(file) } + } else if (file.endsWith(PASS_SUFFIX)) { + // Left by a process killed during its pass. One that is only overdue + // may be under way yet, to those who can ask after it. + const state = stateOfPass(file, oneKernel.includes(path.dirname(file))) + if ( + state === 'over' || + (state === 'overdue' && olderThan(file, OTHER_NAMESPACE_MAX_AGE_MS)) + ) { + reading.spent.push(file) + } } else if (file.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { // Asked for at its own name first, and the record read last. Only a // claim given back can then pass between the two names unseen, and a @@ -1275,6 +1357,152 @@ export function liveMountPoints(): ReadonlySet | undefined { return spared } +/** In the name of every pass of this copy of the library, to tell its own. */ +const ownPassMark = randomBytes(8).toString('hex') + +/** The records of the pass this copy of the library is making, while it is. */ +let ownPassOnRecord: readonly string[] = [] + +/** The number in `pid:[4026531836]`, and the boot id without its hyphens. */ +const inAPassName = { + ns: (): string => /\d+/.exec(ownPidNamespace() ?? '')?.[0] ?? '', + boot: (): string => (ownBootId() ?? '').replace(/-/g, ''), +} + +/** + * Puts a pass of this process on record in each of `dirs`, until {@link + * takePassOffRecord}. `false`, with nothing left there, where it could not be + * in one of them: nothing may then be removed on a manifest's word. + * + * The name says whose it is: the pid, its start time, its PID namespace and the + * boot, each left empty where it cannot be read. In the name, so that a pass is + * on record whole or not at all and a listing is all it takes to judge one. + */ +function putPassOnRecord(dirs: readonly string[]): boolean { + const { pid, start } = ownProcess() + const name = `${pid}-${start}-${inAPassName.ns()}-${inAPassName.boot()}-${ownPassMark}${randomBytes(4).toString('hex')}${PASS_SUFFIX}` + ownPassOnRecord = dirs.map(dir => path.join(dir, name)) + try { + for (const file of ownPassOnRecord) { + fs.closeSync(fs.openSync(file, 'wx', 0o600)) + } + return true + } catch (e) { + takePassOffRecord() + logForDebugging( + `[Sandbox Linux] The clean-up of mount points could not be put on record (${String(e)}) - leaving what the manifests name where it is`, + { level: 'warn' }, + ) + return false + } +} + +/** One that stays behind is dropped by the next pass, of any process. */ +function takePassOffRecord(): void { + const files = ownPassOnRecord + ownPassOnRecord = [] + files.forEach(drop) +} + +/** + * Whether the pass on record at `file` may still remove something. + * + * A process of this kernel and PID namespace is asked after, and only one that + * is gone says `over`. One of an earlier boot is, where only this kernel reaches + * the directory (`oneKernel`), and so is one of this copy of the library that + * it is not making now: a pass does not give the thread back. + * + * Any other, and a name that is none of ours, goes by its age, and is `overdue` + * past {@link PASS_MAX_AGE_MS}: not to be waited for from here, and not known to + * be over either. Its record is left for those who can ask. + */ +function stateOfPass( + file: string, + oneKernel: boolean, +): 'under way' | 'over' | 'overdue' { + const byItsAge = (): 'under way' | 'over' | 'overdue' => { + try { + return Date.now() - fs.lstatSync(file).mtimeMs > PASS_MAX_AGE_MS + ? 'overdue' + : 'under way' + } catch (e) { + return isAbsenceErrno(e) ? 'over' : 'under way' + } + } + const [pid, start, ns, boot, mark] = + /^(\d+)-(\d*)-(\d*)-([0-9a-f]*)-([0-9a-f]+)\.pass$/ + .exec(path.basename(file)) + ?.slice(1) ?? [] + if (pid === undefined || start === undefined || mark === undefined) { + return byItsAge() + } + if (mark.startsWith(ownPassMark)) { + return ownPassOnRecord.includes(file) ? 'under way' : 'over' + } + if (boot === '' || boot !== inAPassName.boot()) { + if (!oneKernel) return byItsAge() + if (boot !== '' && inAPassName.boot() !== '') return 'over' + } + if (ns === '' || ns !== inAPassName.ns()) { + return byItsAge() + } + return isRunning(Number(pid), start) ? 'under way' : 'over' +} + +/** + * The passes on record that may be under way. Throws where the directories + * cannot be told or listed. + */ +export function passesUnderWay(): string[] { + if (!onLinux()) { + return [] + } + const dirs = believedManifestDirectories() + const oneKernel = dirs.filter(isReachedByOneKernel) + return filesIn(dirs).filter( + file => + file.endsWith(PASS_SUFFIX) && + stateOfPass(file, oneKernel.includes(path.dirname(file))) === 'under way', + ) +} + +/** + * Resolves once none of `passes` is under way any more, with `undefined`, or + * with one that still is after `waitMs`: its process is held up (stopped, or + * starved), and what it set out to remove may go at any moment after. + * + * For a wrap that has published its manifest, with what {@link passesUnderWay} + * listed after that. Every pass that did not read the manifest is among them + * (see {@link collect}); one that goes on record later has read it, and is not + * waited for. Rejects like {@link passesUnderWay}, and with `signal`'s reason + * once that is aborted. + */ +export async function passesToEnd( + passes: readonly string[], + signal?: AbortSignal, + waitMs = WAITS_FOR_PASSES_MS, +): Promise { + const deadline = performance.now() + waitMs + let left = passes + for (let pause = 1; ; pause = Math.min(pause * 2, 50)) { + if (left.length === 0 || performance.now() >= deadline) { + return left[0] + } + await new Promise(resolve => setTimeout(resolve, pause)) + signal?.throwIfAborted() + const underWay = new Set(passesUnderWay()) + left = left.filter(pass => underWay.has(pass)) + } +} + +/** + * Whether the manifest this process published at `file` is still its to start a + * command under: not once a clean-up has been told that its command is over. + */ +export function holdsMountPointManifest(file: string): boolean { + return ownManifests.get(file)?.over === false +} + /** * Release the manifests of the wraps of this process that `release` says are * over, and remove every mount point no live manifest names, from any process, @@ -1289,13 +1517,12 @@ export function collectMountPoints( } // Recorded first and kept whatever becomes of this pass, so a later pass // knows these commands are over even when told to release nothing. - for (const own of ownManifests.values()) { - if ( + for (const [file, own] of ownManifests) { + own.over ||= release === 'all' || - (release !== 'none' && own.commandKey === release.commandKey) - ) { - own.over = true - } + (release !== 'none' && + ((release !== 'ended' && own.commandKey === release.commandKey) || + recordVouches(`${idOf(file)}${STARTED_SUFFIX}`) === false)) } // Made first: with one of the first places to write in, what the environment // names is not believed. @@ -1347,16 +1574,23 @@ const childrenFirst = (paths: Iterable): string[] => * mount point from under a sandbox that started in between. * * A pass gives back the claims it made itself, and only when that reading finds - * a process on the record. So what remains is a manifest that comes to its own - * name after the reading, published or given back, whose sandbox reaches its - * bind on a path a claimed manifest names too. The directories, worked out - * afresh, are listed again before every removal (in a pass of over {@link - * LISTS_BEFORE_EACH_REMOVAL_UP_TO} candidates, whenever the listing is {@link - * LISTING_GOOD_FOR_MS} old), and what has come to its name keeps what it names, - * so the pass would have to be held up between a listing and the removal that - * follows it for as long as a sandbox takes to start (for a directory a wrap - * only relies on: until that wrap, having published, has looked at it again, - * and its command is then refused its start). + * a process on the record, which every reading after the claim finds too. So + * what remains is a manifest published after the reading, whose sandbox would + * reach its bind on a path a claimed manifest names too. + * + * INVARIANT: the pass is on record in every directory it can write from before + * that reading until after its last removal, and removes nothing where it could + * not be put there. A wrap lists the passes once it has published, and hands no + * command out while one is under way (see {@link passesToEnd}). So the wrap of + * a manifest that the reading missed finds the pass on record, and no sandbox + * starts under that manifest until the pass is over, for however long the pass + * is held up between its reading and a removal. + * + * The directories, worked out afresh, are still listed again before every + * removal (in a pass of over {@link LISTS_BEFORE_EACH_REMOVAL_UP_TO} + * candidates, whenever the listing is {@link LISTING_GOOD_FOR_MS} old), and + * what has come to its name keeps what it names: the wrap that waits then finds + * what it planned on and need not plan again. * * A manifest is all that names its mount points, so its claim is dropped last, * and stays, for a later pass, when the pass turns back, a removal is refused, @@ -1389,7 +1623,49 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { claims ||= manifest.claimed || own.size > 0 } } - const reading = claims ? readManifests(dirs) : before + // Nothing to remove, or nothing that may be: not worth a record, which every + // wrap that is about to hand its command out would wait for. + if ( + before.inDoubt !== undefined || + (!claims && + !(unrecordedToo && unrecorded.paths.size + unrecorded.sources.size > 0)) + ) { + if (before.inDoubt !== undefined) { + logForDebugging( + `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${before.inDoubt})`, + { level: 'warn' }, + ) + } + return [] + } + const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) + try { + // Off the record it claims in no directory: the claims it has made go back, + // and what is left to remove is what this process could record nowhere, + // which no wrap elsewhere knows for a mount point or waits for. + return removeWhatIsFinished( + dirs, + putPassOnRecord(writable) ? writable : [], + own, + unrecordedToo, + ) + } finally { + takePassOffRecord() + } +} + +/** + * The rest of {@link collect}, for as long as the pass is on record: `own` are + * the claims it has made, `writable` the directories it can claim in. + */ +function removeWhatIsFinished( + dirs: readonly string[], + writable: readonly string[], + own: ReadonlySet, + unrecordedToo: boolean, +): string[] { + const since = performance.now() + const reading = readManifests(dirs) if (reading.inDoubt !== undefined) { logForDebugging( `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${reading.inDoubt})`, @@ -1405,7 +1681,6 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { } } const finished: Manifest[] = [] - const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) for (const manifest of reading.manifests) { if ( manifest.claimed && @@ -1468,6 +1743,14 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { const removed: string[] = [] const notRemoved = new Set() for (const candidate of childrenFirst(candidates.keys())) { + // To a process that cannot ask after this one the pass is over by its age. + if (performance.now() - since > PASS_GOOD_FOR_MS) { + logForDebugging( + '[Sandbox Linux] The clean-up of mount points has taken too long - leaving the rest to the next', + { level: 'warn' }, + ) + return removed + } if ( performance.now() - listedAt >= goodFor && !newcomersKeepWhatTheyName() diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index ac6836ef2..2eae7c5bd 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -12,10 +12,13 @@ import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, + holdsMountPointManifest, keepTrackOfMountSource, kindOfMountPoint, mountPointManifestDirectories, namedMountPoints, + passesToEnd, + passesUnderWay, publishMountPointManifest, removePrivateManifestDirectory, type MountPointManifest, @@ -994,7 +997,9 @@ export type LinuxSandboxProfileErrorCode = /** * Other sandboxes' mount points kept being made or removed under what the * profile relies on, or their manifests kept moving while they were read, - * each time the wrap planned it. Passing: wrap again. + * each time the wrap planned it; or another process's clean-up is held up + * half way (stopped, or starved), and `.cause` is its record, named for its + * pid. Passing: wrap again. */ | 'mount_points_changed' @@ -1061,7 +1066,7 @@ function shellForSteps(): string[] { if (stepShell === undefined) { const isBash = (shell: string): boolean => { try { - return path.basename(fs.realpathSync(shell)) === 'bash' + return path.basename(realPathOf(shell)) === 'bash' } catch { return false } @@ -1076,8 +1081,9 @@ function shellForSteps(): string[] { /** * Thrown by the wrap when the host changed its working directory while the - * mandatory denies were looked for. Nothing has been handed out or left on - * the host by then. `SandboxManager` starts the wrap over. + * mandatory denies were looked for, or while a clean-up was waited for. Nothing + * has been handed out by then, and the wrap has let go of what it had named. + * `SandboxManager` starts the wrap over. */ export class WorkingDirectoryChanged extends Error { constructor() { @@ -1253,16 +1259,19 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called on process exit as a safety net. * - * A command that has run is judged by its own record: every call, in any - * process, collects what it relied on once it has ended, whatever else is - * outstanding. What only this process can say is that a command which has NOT - * started is over, and what it releases of those: + * A wrapped command may be run, and run again, until this process lets go of + * it: no other process collects what it relies on while this one runs. Every + * call lets go of the commands that have run and ended, whatever else is + * outstanding. What only the caller can say is that a command which has NOT + * started is over, and what the call releases of those: * * - with no `commandId`: none until it has been called once for every wrap * handed out, since nothing can tell which command the call is for; * - with the `commandId` its wrap was given: that command's, at once; * - with `force`: all, because the process or session is ending. * + * A command line let go of is refused its start from then on. + * * The --args profiles go when none is outstanding. A mount point goes only when * no running sandbox relies on it, which each sandbox's own process answers * (see bwrap-mount-manifests.ts). A call too many takes nothing from a running @@ -1274,11 +1283,24 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean commandId?: string }): void { + cleanUpAfter(opts ?? {}, 'ended') +} + +/** + * {@link cleanupBwrapMountPoints}. `otherwise` is what is released with wraps + * still outstanding and no command named: the commands that have ended when it + * is the caller who says that one is over, none when it is a wrap that came to + * nothing giving its place in the count back. + */ +function cleanUpAfter( + opts: { force?: boolean; commandId?: string }, + otherwise: 'ended' | 'none', +): void { if (process.platform !== 'linux') { return } let release: OwnManifestRelease - if (opts?.force) { + if (opts.force) { activeSandboxCount = 0 forcedCleanups++ release = 'all' @@ -1289,15 +1311,16 @@ export function cleanupBwrapMountPoints(opts?: { release = activeSandboxCount === 0 ? 'all' - : opts?.commandId !== undefined + : opts.commandId !== undefined ? { commandKey: opts.commandId } - : 'none' + : otherwise if (release !== 'all') { logForDebugging( `[Sandbox Linux] ${activeSandboxCount} sandbox(es) of this process still outstanding - ` + (release === 'none' ? 'keeping what this process made' - : `releasing only what ${opts?.commandId} relied on`), + : 'releasing only what the commands that have ended relied on' + + (release === 'ended' ? '' : `, and ${opts.commandId}`)), ) } } @@ -1317,7 +1340,7 @@ export function cleanupBwrapMountPoints(opts?: { closeBwrapArgsProfile(argsFd) } } - if (opts?.force) { + if (opts.force) { removePrivateManifestDirectory() } } @@ -3719,11 +3742,11 @@ async function generateFilesystemArgs( `Sandbox profile relies on ${names.length} mount points, more than one manifest holds; reduce the write denies on paths that do not exist`, ) } - // From here a clean-up keeps what the manifest names. Until here any of it - // could go, or be made again by a sandbox that no reading above knew of. So - // the wrap looks again, this once, and plans afresh if anything is amiss. - // What cannot be looked at counts as there: bubblewrap does not start on what - // it cannot bind. + // From here a clean-up that reads the manifests keeps what this one names. + // Until here any of it could go, or be made again by a sandbox that no + // reading above knew of. So the wrap looks again, this once, and plans afresh + // if anything is amiss. What cannot be looked at counts as there: bubblewrap + // does not start on what it cannot bind. const isThere = (relied: string, asDirectory: boolean): boolean => { try { return fs.statSync(relied).isDirectory() || !asDirectory @@ -3733,7 +3756,43 @@ async function generateFilesystemArgs( } let holds = false try { + // INVARIANT: no command is handed out while a clean-up is under way that + // may have read the manifests before this one was among them. It is on + // record by now, and what it set out to remove goes whenever its process + // gets to it: under a sandbox that has started, that lifts the deny. So it + // is waited for, and the look at the host comes after. Asked without giving + // the thread back first, which is all there is to it but for a moment now + // and then. + if (manifest !== undefined) { + let heldUp: string | undefined + try { + const underWay = passesUnderWay() + heldUp = + underWay.length > 0 + ? await passesToEnd(underWay, abortSignal) + : undefined + } catch (err) { + if (abortSignal?.aborted) throw err + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `The mount point manifests cannot be listed (${errorText(err)}), so a clean-up that is about to remove what the profile relies on cannot be told`, + errorText(err), + ) + } + if (heldUp !== undefined) { + throw new LinuxSandboxProfileError( + 'mount_points_changed', + `A clean-up of mount points by another process is held up (${heldUp}, named for its pid), and what it set out to remove may go from under a sandbox started now`, + heldUp, + ) + } + // One plan, one working directory, on both sides of this await too. + if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + } holds = + // Else let go of during the wait, by a forced clean-up: no command starts + // under it any more. + (manifest === undefined || holdsMountPointManifest(manifest.file)) && [...boundFrom].every(relied => isThere(relied, false)) && [...madeIn].every(relied => isThere(relied, true)) && mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && @@ -4279,8 +4338,9 @@ export async function wrapCommandWithSandboxLinux( // Its place in the count goes back through the clean-up: a command that // ended meanwhile left to this wrap what goes when none is outstanding. // After a forced cleanup the count is of other wraps only: one taken from - // it has a command that has not started refused its start. - if (forcedCleanups === countedAfter) cleanupBwrapMountPoints() + // it has a command that has not started refused its start. It says nothing + // of the other commands: one that has run may be run again. + if (forcedCleanups === countedAfter) cleanUpAfter({}, 'none') throw error } } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index d08393609..fa23fb2a5 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -2335,7 +2335,7 @@ export type WrapWithSandboxOptions = { * points its command relies on: pass the same value to * `cleanupAfterCommand({ commandId })` to let go of them at once when the * command will never be started, whatever other commands are still in - * flight (a command that ran is let go of by any cleanup once it has + * flight (a command that ran is let go of by any such call once it has * ended). It has to be unique among the wraps in flight. Only a value passed * here counts for that; the `command` default does not, since two wraps in * flight may share their text. @@ -2762,7 +2762,8 @@ async function wrapWithSandboxOnce( observeSocketPath: linuxMonitor?.observeSocketPath, abortSignal, }).catch((err: unknown) => { - // It awaits once more, for its scan: see the invariant above. + // It awaits again, for its scan and for a clean-up under way: see the + // invariant above. if (err instanceof WorkingDirectoryChanged) return startOver() throw err }) @@ -3076,12 +3077,13 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * These persist after bwrap exits. This function removes them. * * Call it once for each wrapped command, when that command is over. Every - * call removes what commands that ran and have ended relied on, this - * process's and other processes'. A command that was wrapped and has not - * started is let go of only once it has been called for every wrap handed - * out, because a call cannot tell which command it is for; pass the + * call removes what commands that ran and have ended relied on: this + * process's, and those of processes that are gone. A command that was wrapped + * and has not started is let go of only once it has been called for every wrap + * handed out, because a call cannot tell which command it is for; pass the * `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to let - * go of that one at once. + * go of that one at once. A wrapped string that has been let go of refuses to + * start; until then it may be run again. * * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index cfa69f23a..92f4e6b8f 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -737,7 +737,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ] as const) { it(`takes them in /bin/sh where bash is at neither, which is ${sh}: ${begins}`, async () => { const access = fs.accessSync - const realpath = fs.realpathSync + const realpath = fs.realpathSync.native const spies = [ spyOn(fs, 'accessSync').mockImplementation((( file: unknown, @@ -748,7 +748,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { } return (access as (...args: unknown[]) => void)(file, mode) }) as never), - spyOn(fs, 'realpathSync').mockImplementation((( + spyOn(fs.realpathSync, 'native').mockImplementation((( file: unknown, ...rest: unknown[] ) => @@ -759,9 +759,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ...rest, )) as never), ] - // The library resolves every other path through `.native`, which a spy - // does not carry over. - Object.assign(fs.realpathSync, { native: realpath.native }) try { // The shell is found once, so in a copy of the library of its own. const fresh = (await import( @@ -1016,10 +1013,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // ---- which command a clean-up is for ---- // - // A command that ran is judged by its record alone: any clean-up collects it - // once it has ended. Of one that never started, only the caller can say that - // it is over, so these commands never start. What each holds on to is a mount - // point a killed process left, which the wrap covers and names. + // A command that ran and has ended is let go of by any clean-up call of its + // process. Of one that never started, only the caller can say that it is + // over, so these commands never start. What each holds on to is a mount point + // a killed process left, which the wrap covers and names. function leftByAKilledProcess(mountPoint: string): void { writeFileSync(mountPoint, '') diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index f9eb36e21..41bd1ff4e 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -573,12 +573,24 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([X, Y].sort()) }) - it('is finished once no process on its record runs, though the process that wrote it does', () => { + it('is live while the process that wrote it runs, though no process on its record does: its command line may be run again', () => { leftover(X) const manifest = started(manifestOfADeadProcess([X], thisProcess()), { pid: deadPid(), start: '1', }) + expect(live()).toEqual([X]) + expect(collectMountPoints('none')).toEqual([]) + expect(existsSync(manifest)).toBe(true) + expect(existsSync(recordOf(manifest))).toBe(true) + }) + + it('is finished once neither a process on its record nor the one that wrote it runs, however young', () => { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { created: Date.now() }), + { pid: deadPid(), start: '1' }, + ) expect(live()).toEqual([]) expect(collectMountPoints('none')).toEqual([X]) expect(existsSync(manifest)).toBe(false) @@ -1000,10 +1012,6 @@ describe.if(isLinux)('The mount point manifests', () => { 'a line of a process and then something else', r => write(r, `${gone()}?\n`), ], - [ - 'larger than a record can be', - r => write(r, gone().repeat(1 + Math.ceil(65536 / gone().length))), - ], [ 'a link to the record of a process that is gone', r => { @@ -1099,7 +1107,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('goes by the whole lines of a record whose last line is cut short', () => { // The same command line run again, its shell not yet done writing. leftover(X) - const manifest = manifestOfADeadProcess([X], thisProcess()) + const manifest = manifestOfADeadProcess([X]) write(recordOf(manifest), statLine(thisProcess()) + gone().slice(0, -1)) expect(collectMountPoints('none')).toEqual([]) write(recordOf(manifest), gone() + gone().slice(0, -1)) diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts new file mode 100644 index 000000000..5bc398438 --- /dev/null +++ b/test/sandbox/mount-point-passes.test.ts @@ -0,0 +1,930 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { + collectMountPoints, + liveMountPoints, + passesToEnd, + passesUnderWay, + publishMountPointManifest, +} from '../../src/sandbox/bwrap-mount-manifests.js' +import { + cleanupBwrapMountPoints, + LinuxSandboxProfileError, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { manifestOf } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' + +/** + * A clean-up decides on a reading of the manifests and removes afterwards, and + * can be held up between the two for any length of time. So it puts itself on + * record before it reads, and a wrap that has published its manifest hands no + * command out while a clean-up is on record: whichever of the two came second + * has seen the other. + */ +describe.if(isLinux)('A clean-up of mount points, on record', () => { + const runtime = usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const MODULE = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/manifests.ts'), + ) + const OWN_NAMESPACE = isLinux ? readlinkSync('/proc/self/ns/pid') : '' + const OWN_BOOT = isLinux + ? readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + : '' + /** Echoed first by every command that runs for real. */ + const BOOTED = 'BOOTED' + + let BASE: string + let DIR: string // where the manifests and the passes go + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let others: ChildProcess[] // ended after each test + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-passes-'))) + AREA = join(BASE, 'area') + mkdirSync(AREA) + LOCK = join(AREA, 'config.lock') + DIR = runtime.manifestDir() + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + chmodSync(DIR, 0o700) + others = [] + }) + + afterEach(() => { + for (const other of others) { + if (other.pid !== undefined) { + try { + process.kill(other.pid, 'SIGCONT') + process.kill(other.pid, 'SIGKILL') + } catch { + // Gone already. + } + } + } + for (const name of readdirSync(DIR)) rmSync(join(DIR, name)) + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ + function leftover(p: string): void { + writeFileSync(p, '') + chmodSync(p, 0o444) + } + + /** A pid nothing in this PID namespace has. */ + function deadPid(): number { + for (let pid = 4194000; pid > 1; pid--) { + if (!existsSync(`/proc/${pid}`)) return pid + } + throw new Error('no free pid') + } + + type Process = { pid: number; start: string } + + /** That process as a manifest, a record or a pass names one. */ + function processAt(pid: number): Process { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + return { + pid, + start: stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19]!, + } + } + + /** Another process of this user, which runs until the test is over. */ + function anotherProcess(): Process { + const other = spawn('sleep', ['600'], { stdio: 'ignore' }) + others.push(other) + return processAt(other.pid!) + } + + /** What a killed process leaves: its manifest, long past its grace. */ + function manifestOfADeadProcess(paths: string[], writer?: Process): string { + const { pid, start } = writer ?? { pid: deadPid(), start: '1' } + const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) + writeFileSync( + file, + JSON.stringify({ + version: 1, + pid, + start, + ns: OWN_NAMESPACE, + boot: OWN_BOOT, + created: Date.now() - 60_000, + paths, + sources: [], + }), + { mode: 0o600 }, + ) + return file + } + + /** Puts `manifest` on record as started by those processes. */ + function started(manifest: string, ...by: Process[]): string { + const line = (who: Process): string => + `${who.pid} (bwrap) ${['S', ...Array(18).fill('0'), who.start, '0'].join(' ')}\n` + writeFileSync( + manifest.replace(/\.json$/, '.started'), + by.map(line).join(''), + { mode: 0o600 }, + ) + return manifest + } + + /** Puts a pass of `who` on record, as the library names one. */ + function passOf( + who: Process, + where: { ns?: string; boot?: string } = {}, + ): string { + const ns = where.ns ?? /\d+/.exec(OWN_NAMESPACE)![0] + const boot = (where.boot ?? OWN_BOOT).replace(/-/g, '') + const file = join( + DIR, + `${who.pid}-${who.start}-${ns}-${boot}-0123456789abcdef.pass`, + ) + writeFileSync(file, '', { mode: 0o600 }) + return file + } + + function aged(file: string, ms: number): string { + const then = new Date(Date.now() - ms) + utimesSync(file, then, then) + return file + } + + const passes = (): string[] => + readdirSync(DIR).filter(name => name.endsWith('.pass')) + + function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function until(what: string, holds: () => boolean): Promise { + const deadline = Date.now() + 30000 + while (!holds()) { + if (Date.now() > deadline) + throw new Error(`timed out waiting for ${what}`) + await sleep(10) + } + } + + function wrap(command = 'true', deny = [LOCK]): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: deny }, + }) + } + + function run(command: string): { status: number | null; said: string } { + const r = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + }) + return { status: r.status, said: `${r.stdout}${r.stderr}` } + } + + function script(name: string, lines: string[]): string { + const file = join(BASE, name) + writeFileSync(file, lines.join('\n')) + return file + } + + // ---- the pass ------------------------------------------------------------ + + it('goes on record before the reading it decides on, stays there while it removes, and is off it afterwards', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const open = fs.openSync + const unlink = fs.unlinkSync + const seen: string[] = [] + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (typeof file === 'string' && /\.(pass|claimed)$/.test(file)) { + seen.push(file.endsWith('.pass') ? 'on record' : 'reads the claim') + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never), + spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === LOCK) + seen.push(`removes, with ${passes().length} on record`) + unlink(file) + }) as never), + ] + try { + expect(collectMountPoints()).toEqual([LOCK]) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + expect(seen).toEqual([ + 'on record', + 'reads the claim', + 'removes, with 1 on record', + ]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('is named for its process, so that a listing is all it takes to ask after it', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const unlink = fs.unlinkSync + let name: string | undefined + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === LOCK) [name] = passes() + unlink(file) + }) as never) + try { + collectMountPoints() + } finally { + spy.mockRestore() + } + const { pid, start } = processAt(process.pid) + expect(name).toStartWith( + `${pid}-${start}-${/\d+/.exec(OWN_NAMESPACE)![0]}-${OWN_BOOT.replace(/-/g, '')}-`, + ) + }) + + it('puts nothing on record where there is nothing to remove', () => { + manifestOfADeadProcess([LOCK], anotherProcess()) + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints()).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it("removes nothing on a manifest's word where it cannot be put on record, and the next pass does", () => { + leftover(LOCK) + const manifest = manifestOfADeadProcess([LOCK]) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.pass')) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(LOCK)).toBe(true) + // Its claim has gone back, for a wrap to read as before. + expect(readdirSync(DIR)).toEqual([basename(manifest)]) + expect(collectMountPoints()).toEqual([LOCK]) + }) + + it('still removes what this process could record nowhere, on a disk that takes neither a manifest nor a record', () => { + // No other process knows it for a mount point, or waits for a pass over it. + const full = (file: unknown): void => { + if (/\.(pass|tmp)$/.test(String(file))) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + } + const open = fs.openSync + const write = fs.writeFileSync + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + full(file) + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never), + spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + full(file) + ;(write as (...args: unknown[]) => void)(file, ...rest) + }) as never), + ] + try { + expect(publishMountPointManifest([LOCK], [])).toBeUndefined() + leftover(LOCK) + expect(collectMountPoints()).toEqual([LOCK]) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + }) + + it('goes on no record over manifests it cannot read, where it may remove nothing', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + writeFileSync(join(DIR, '1-0123456789abcdef.json'), '{"version":"?"}') + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints()).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it('is off the record when the pass throws', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const now = performance.now.bind(performance) + const spy = spyOn(performance, 'now').mockImplementation(() => { + if (passes().length > 0) throw new TypeError('a bug') + return now() + }) + try { + expect(() => collectMountPoints()).toThrow('a bug') + } finally { + spy.mockRestore() + } + expect(passes()).toEqual([]) + }) + + it('leaves the rest to the next pass once it has been on record for two seconds', () => { + const second = join(AREA, 'second.lock') + leftover(LOCK) + leftover(second) + manifestOfADeadProcess([LOCK, second]) + const unlink = fs.unlinkSync + const now = performance.now.bind(performance) + let late = 0 + const spies = [ + spyOn(performance, 'now').mockImplementation(() => now() + late), + spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + unlink(file) + if (file === second) late = 2_001 + }) as never), + ] + try { + // Children first, which among siblings is the later name first. + expect(collectMountPoints()).toEqual([second]) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + expect(existsSync(LOCK)).toBe(true) + expect(passes()).toEqual([]) + expect(collectMountPoints()).toEqual([LOCK]) + }) + + // ---- which passes are under way -------------------------------------------- + + it('is under way while its process runs, however old its record', () => { + const pass = aged(passOf(anotherProcess()), 24 * 60 * 60 * 1000) + expect(passesUnderWay()).toEqual([pass]) + }) + + it('is under way while its process is stopped', async () => { + const other = anotherProcess() + process.kill(other.pid, 'SIGSTOP') + await until('the process to be stopped', () => + / T /.test(readFileSync(`/proc/${other.pid}/stat`, 'utf8')), + ) + const pass = passOf(other) + expect(passesUnderWay()).toEqual([pass]) + }) + + it.each([ + [ + 'its process is gone', + (): string => passOf({ pid: deadPid(), start: '1' }), + ], + [ + 'its pid is a later process', + (): string => { + const { pid, start } = anotherProcess() + return passOf({ pid, start: String(Number(start) - 1) }) + }, + ], + [ + 'it is of an earlier boot', + (): string => + passOf(anotherProcess(), { + boot: '00000000-0000-0000-0000-000000000000', + }), + ], + ])('is over when %s, and the next pass drops its record', (_what, plant) => { + const pass = plant() + expect(passesUnderWay()).toEqual([]) + collectMountPoints() + expect(existsSync(pass)).toBe(false) + }) + + it.each([ + [ + "another PID namespace's", + (): string => passOf(anotherProcess(), { ns: '1' }), + ], + [ + 'of a process that could read neither its namespace nor the boot', + (): string => passOf(anotherProcess(), { ns: '', boot: '' }), + ], + [ + 'at a name that is none of ours', + (): string => { + const file = join(DIR, 'whatever.pass') + writeFileSync(file, '') + return file + }, + ], + ])( + 'goes by the age of its record where it cannot be asked after: %s', + (_what, plant) => { + const pass = plant() + expect(passesUnderWay()).toEqual([pass]) + aged(pass, 59_000) + expect(passesUnderWay()).toEqual([pass]) + collectMountPoints() + expect(existsSync(pass)).toBe(true) + aged(pass, 61_000) + expect(passesUnderWay()).toEqual([]) + // Its record is not this process's to drop: its own may be stopped half + // way, and those who can ask after it still wait for it. + collectMountPoints() + expect(existsSync(pass)).toBe(true) + aged(pass, 8 * 24 * 60 * 60 * 1000) + collectMountPoints() + expect(existsSync(pass)).toBe(false) + }, + ) + + it('is over, to the copy of the library that made it, once that has gone on to something else', () => { + // What a record that could not be dropped would otherwise do: hold up every + // wrap of the user for as long as this process runs. + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file.endsWith('.pass')) { + throw Object.assign(new Error('EIO'), { code: 'EIO' }) + } + unlink(file) + }) as never) + try { + expect(collectMountPoints()).toEqual([LOCK]) + } finally { + spy.mockRestore() + } + expect(passes()).toHaveLength(1) + expect(passesUnderWay()).toEqual([]) + collectMountPoints() + expect(passes()).toEqual([]) + }) + + it('is under way to another copy of the library in the same process', () => { + // A worker thread has the pid of the thread that started it. + const pass = passOf(processAt(process.pid)) + expect(passesUnderWay()).toEqual([pass]) + }) + + it('does not put a reading of the manifests in doubt', () => { + passOf(anotherProcess()) + leftover(LOCK) + manifestOfADeadProcess([LOCK], anotherProcess()) + expect([...(liveMountPoints() ?? [])]).toEqual([LOCK]) + }) + + // ---- the wait -------------------------------------------------------------- + + it('is waited for until its record goes', async () => { + const pass = passOf(anotherProcess()) + let ended: string | undefined | null = null + const waiting = passesToEnd([pass]).then(heldUp => (ended = heldUp)) + await sleep(150) + expect(ended).toBeNull() + rmSync(pass) + await waiting + expect(ended).toBeUndefined() + }) + + it('is waited for until its process goes', async () => { + const other = anotherProcess() + const waiting = passesToEnd([passOf(other)]) + await sleep(50) + process.kill(other.pid, 'SIGKILL') + expect(await waiting).toBeUndefined() + }) + + it('is named to the caller when it outlasts the wait', async () => { + const pass = passOf(anotherProcess()) + const began = performance.now() + expect(await passesToEnd([pass], undefined, 200)).toBe(pass) + expect(performance.now() - began).toBeGreaterThanOrEqual(200) + }) + + it('is not waited for once the caller has given up', async () => { + const gaveUp = new AbortController() + const waiting = passesToEnd([passOf(anotherProcess())], gaveUp.signal) + gaveUp.abort(new Error('gave up')) + await expect(waiting).rejects.toThrow('gave up') + }) + + it('is not waited for when it went on record after the wrap had listed them: it has read the manifest', async () => { + const first = passOf(anotherProcess()) + const waiting = passesToEnd([first]) + const later = passOf(anotherProcess()) + await sleep(20) + rmSync(first) + expect(await waiting).toBeUndefined() + expect(passesUnderWay()).toEqual([later]) + }) + + // ---- the wrap ---------------------------------------------------------------- + + it('holds back the command of a wrap that names a mount point, until it is over', async () => { + const pass = passOf(anotherProcess()) + let handedOut = false + const wrapping = wrap().then(command => { + handedOut = true + return command + }) + await until('the wrap to publish', () => + readdirSync(DIR).some(name => name.endsWith('.json')), + ) + await sleep(150) + expect(handedOut).toBe(false) + rmSync(pass) + expect(manifestOf(await wrapping)).toBeDefined() + }) + + it('holds back no wrap that relies on nothing a clean-up could remove', async () => { + passOf(anotherProcess()) + writeFileSync(LOCK, 'held by somebody') + expect(manifestOf(await wrap())).toBeUndefined() + }) + + it('has the wrap look at the host after it, not before', async () => { + // The mount point of a finished sandbox, which the wrap plans to cover + // again, in a directory that is one too, which the wrap plans to make its + // own in. The pass that is under way takes both. + const directory = join(AREA, '.claude') + const inside = join(directory, 'commands') + mkdirSync(directory) + manifestOfADeadProcess([directory]) + const pass = passOf(anotherProcess()) + const wrapping = wrap('true', [inside]) + await until('the wrap to publish', () => + readdirSync(DIR).some( + name => + name.endsWith('.json') && + readFileSync(join(DIR, name), 'utf8').includes(inside), + ), + ) + rmSync(directory, { recursive: true }) + rmSync(pass) + // Planned again: what is missing now is the directory. + const manifest = manifestOf(await wrapping) + const { paths } = JSON.parse(readFileSync(manifest!, 'utf8')) as { + paths: string[] + } + expect(paths).toEqual([directory]) + }) + + it('refuses the wrap when it is held up for longer than a wrap waits, and names its record', async () => { + const pass = passOf(anotherProcess()) + const refused = await wrap().then( + () => undefined, + (error: unknown) => error, + ) + expect(refused).toBeInstanceOf(LinuxSandboxProfileError) + expect((refused as LinuxSandboxProfileError).code).toBe( + 'mount_points_changed', + ) + expect((refused as LinuxSandboxProfileError).cause).toBe(pass) + expect((refused as LinuxSandboxProfileError).message).toContain(pass) + // The wrap has let go of what it named. + expect(readdirSync(DIR)).toEqual([basename(pass)]) + }, 15000) + + it('lets the caller give the wrap up while it waits', async () => { + passOf(anotherProcess()) + const gaveUp = new AbortController() + const wrapping = wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + abortSignal: gaveUp.signal, + }) + await until('the wrap to publish', () => + readdirSync(DIR).some(name => name.endsWith('.json')), + ) + gaveUp.abort(new Error('gave up')) + await expect(wrapping).rejects.toThrow('gave up') + }) + + it('plans again when a forced clean-up has let go of its manifest while it waited', async () => { + const pass = passOf(anotherProcess()) + const published = (): string[] => + readdirSync(DIR).filter(name => name.endsWith('.json')) + const wrapping = wrap() + await until('the wrap to publish', () => published().length > 0) + const [first] = published() + cleanupBwrapMountPoints({ force: true }) + rmSync(pass) + const manifest = manifestOf(await wrapping) + expect(basename(manifest!)).not.toBe(first) + expect(existsSync(manifest!)).toBe(true) + }) + + it('says nothing of the other commands of its process when it comes to nothing', async () => { + // One that has run, whose caller has not cleaned up after it and may run it + // again. + const kept = await wrap() + started(manifestOf(kept)!, { pid: deadPid(), start: '1' }) + const gaveUp = new AbortController() + gaveUp.abort(new Error('gave up')) + await expect( + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + abortSignal: gaveUp.signal, + }), + ).rejects.toThrow() + expect(existsSync(manifestOf(kept)!)).toBe(true) + cleanupBwrapMountPoints() + expect(existsSync(manifestOf(kept)!)).toBe(false) + }) + + // A clean-up in a process of its own, stopped between its last look at the + // manifests and the removal, for as long as the test likes. + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot take a mount point from under a sandbox that starts while it is stopped before the removal', + async () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const about = join(BASE, 'about-to-remove') + const cleaner = spawn( + process.execPath, + [ + script('stopped-cleaner.ts', [ + `import { spyOn } from 'bun:test'`, + `import * as fs from 'node:fs'`, + `const m = await import(${MODULE})`, + `const unlink = fs.unlinkSync`, + `spyOn(fs, 'unlinkSync').mockImplementation((file) => {`, + ` if (file === ${JSON.stringify(LOCK)}) {`, + ` fs.writeFileSync(${JSON.stringify(about)}, '')`, + ` process.kill(process.pid, 'SIGSTOP')`, + ` }`, + ` unlink(file)`, + `})`, + `m.collectMountPoints()`, + ]), + ], + { stdio: 'ignore' }, + ) + others.push(cleaner) + const cleaned = new Promise(resolve => cleaner.on('exit', resolve)) + await until('the clean-up to reach the removal', () => existsSync(about)) + await until('the clean-up to be stopped', () => + / T /.test(readFileSync(`/proc/${cleaner.pid}/stat`, 'utf8')), + ) + + // What the sandbox tries once it is up: the removal first, which only a + // bind still in place refuses. + const up = join(AREA, 'up') + const go = join(AREA, 'go') + let command: string | undefined + const wrapping = wrap( + `echo ${BOOTED}; echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.02; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, + ).then(wrapped => (command = wrapped)) + await sleep(1000) + // No command, so no sandbox, for as long as the removal is pending. + expect(command).toBeUndefined() + expect(existsSync(LOCK)).toBe(true) + + process.kill(cleaner.pid!, 'SIGCONT') + await cleaned + await wrapping + + const sandbox = spawn(command!, { shell: true }) + others.push(sandbox) + let said = '' + sandbox.stdout.on('data', chunk => (said += String(chunk))) + sandbox.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => sandbox.on('exit', resolve)) + await until('the sandbox to be up', () => existsSync(up)) + writeFileSync(go, '') + await exited + expect(said).toContain(BOOTED) + expect(said).toMatch(/rc=[1-9]/) + expect(readFileSync(LOCK, 'utf8')).toBe('') + + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) + + // ---- a command line run again ------------------------------------------------ + + /** A process that wrapped nothing, cleaning up. */ + function anotherProcessCleansUp(): void { + const cleaned = spawnSync( + process.execPath, + [ + script('cleaner.ts', [ + `const m = await import(${MODULE})`, + `m.collectMountPoints()`, + ]), + ], + { encoding: 'utf8', timeout: 30000 }, + ) + expect(cleaned.status).toBe(0) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'leaves a command line good for another run, with other processes cleaning up in between, until its own process lets go of it', + async () => { + const command = await wrap( + `echo ${BOOTED}; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, + ) + for (let time = 0; time < 3; time++) { + const { said } = run(command) + expect(said).toContain(BOOTED) + expect(said).toMatch(/rc=[1-9]/) + anotherProcessCleansUp() + expect(existsSync(manifestOf(command)!)).toBe(true) + expect(readFileSync(LOCK, 'utf8')).toBe('') + } + + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + const refused = run(command) + expect(refused.status).not.toBe(0) + expect(refused.said).not.toContain(BOOTED) + expect(refused.said).toContain("Can't find source path") + // It made nothing, and the line it put on record goes with the next pass. + expect(existsSync(LOCK)).toBe(false) + collectMountPoints('none') + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) + + it('keeps what a command that has run relied on while the process that wrapped it runs, to every other process', () => { + leftover(LOCK) + const manifest = started(manifestOfADeadProcess([LOCK], anotherProcess()), { + pid: deadPid(), + start: '1', + }) + expect([...(liveMountPoints() ?? [])]).toEqual([LOCK]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(manifest)).toBe(true) + }) + + it('goes by the last runs of a command line that has been run more often than a record is read for', () => { + leftover(LOCK) + const gone = Array(1500).fill({ pid: deadPid(), start: '1' }) + const manifest = manifestOfADeadProcess([LOCK]) + started(manifest, ...gone, anotherProcess()) + expect( + fs.statSync(manifest.replace(/\.json$/, '.started')).size, + ).toBeGreaterThan(64 * 1024) + expect(collectMountPoints()).toEqual([]) + started(manifest, ...gone) + expect(collectMountPoints()).toEqual([LOCK]) + }) + + it('takes it once that process is gone', () => { + leftover(LOCK) + started(manifestOfADeadProcess([LOCK]), { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([LOCK]) + }) + + /** The manifest this process publishes for a wrap that names `paths`. */ + function publish(paths: string[], commandKey?: string): string { + const manifest = publishMountPointManifest(paths, [], commandKey) + if (manifest === undefined || manifest === 'too-large') { + throw new Error(`not published: ${manifest}`) + } + return manifest.file + } + + it('lets go of the commands that have run and ended at every clean-up the caller makes, and of no other', () => { + const [ended, running, notStarted] = ['ended', 'running', 'waiting'].map( + name => join(AREA, name), + ) as [string, string, string] + for (const mountPoint of [ended, running, notStarted]) leftover(mountPoint) + started(publish([ended]), { pid: deadPid(), start: '1' }) + started(publish([running]), anotherProcess()) + publish([notStarted]) + + // A wrap of this process that came to nothing says nothing of the others. + expect(collectMountPoints('none')).toEqual([]) + expect(collectMountPoints('ended')).toEqual([ended]) + expect(collectMountPoints('ended')).toEqual([]) + expect(collectMountPoints('all')).toEqual([notStarted]) + expect(existsSync(running)).toBe(true) + }) + + it('lets go of them along with the command the caller names', () => { + const [ended, named, other] = ['ended', 'named', 'other'].map(name => + join(AREA, name), + ) as [string, string, string] + for (const mountPoint of [ended, named, other]) leftover(mountPoint) + started(publish([ended], 'a'), { pid: deadPid(), start: '1' }) + publish([named], 'b') + publish([other], 'c') + expect(collectMountPoints({ commandKey: 'b' }).sort()).toEqual( + [ended, named].sort(), + ) + expect(existsSync(other)).toBe(true) + }) + + // ---- a process that has exited and is not waited for ------------------------- + + it('takes a process that has exited for gone, though nothing has asked after it yet', async () => { + // The shell starts a child and becomes a program that never waits for one. + const parent = spawn('sh', ['-c', 'true & echo $!; exec sleep 600'], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + others.push(parent) + let said = '' + parent.stdout.on('data', chunk => (said += String(chunk))) + await until('the pid of the child', () => said.includes('\n')) + const pid = Number(said) + await until('the child to have exited', () => + / Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')), + ) + const exited = processAt(pid) + + leftover(LOCK) + started(manifestOfADeadProcess([LOCK]), exited) + const pass = passOf(exited) + expect(passesUnderWay()).toEqual([]) + expect([...(liveMountPoints() ?? [])]).toEqual([]) + expect(collectMountPoints()).toEqual([LOCK]) + expect(existsSync(pass)).toBe(false) + // Still there to be asked after: it is what it says that counts. + expect(existsSync(`/proc/${pid}`)).toBe(true) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cleans up after a sandbox that was killed and is never waited for', + async () => { + // As under a process 1 that reaps nothing: the sandbox is the child of a + // program that never waits for it. + const up = join(AREA, 'up') + const command = await wrap(`echo up > ${up}; sleep 600`) + const parent = spawn('sh', ['-c', `${command} & exec sleep 600`], { + stdio: 'ignore', + }) + others.push(parent) + await until('the sandbox to be up', () => existsSync(up)) + const record = readFileSync( + manifestOf(command)!.replace(/\.json$/, '.started'), + 'utf8', + ) + const bwrap = Number(/^\d+/.exec(record)![0]) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) + + process.kill(bwrap, 'SIGKILL') + await until('bubblewrap to have exited', () => + / Z /.test(readFileSync(`/proc/${bwrap}/stat`, 'utf8')), + ) + collectMountPoints('none') + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) +}) From 9268612cb426514b71a4cd2e3700db44093b4d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:39:44 +0000 Subject: [PATCH 091/141] test: no awaited `rejects`, and a helper that loads where there is no /bin/sh CI's bun-types declare the `.rejects` matchers as returning nothing, so the lint refuses the `await` in front of them. The three cases take the rejection as a value instead, as the rest of the suite does. `glob-expand.test.ts` runs on Windows too and now loads `bwrap-argv.ts`, which resolved `/bin/sh` as it was loaded. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/helpers/bwrap-argv.ts | 4 +++- test/sandbox/mount-point-passes.test.ts | 25 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 059bf51a9..8cf894e21 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -14,7 +14,9 @@ const ARGS_FILE_ARGV0 = 'srt-args' export const STEP_SHELL = ((): string => { const bash = ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) ?? - ['/bin/sh'].find(shell => basename(realpathSync(shell)) === 'bash') + ['/bin/sh'].find( + shell => existsSync(shell) && basename(realpathSync(shell)) === 'bash', + ) return bash === undefined ? '/bin/sh -c' : `${bash} -p -c` })() diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 5bc398438..731a72b4c 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -549,7 +549,12 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { const gaveUp = new AbortController() const waiting = passesToEnd([passOf(anotherProcess())], gaveUp.signal) gaveUp.abort(new Error('gave up')) - await expect(waiting).rejects.toThrow('gave up') + expect( + await waiting.then( + () => undefined, + (error: unknown) => error, + ), + ).toEqual(new Error('gave up')) }) it('is not waited for when it went on record after the wrap had listed them: it has read the manifest', async () => { @@ -643,7 +648,12 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { readdirSync(DIR).some(name => name.endsWith('.json')), ) gaveUp.abort(new Error('gave up')) - await expect(wrapping).rejects.toThrow('gave up') + expect( + await wrapping.then( + () => undefined, + (error: unknown) => error, + ), + ).toEqual(new Error('gave up')) }) it('plans again when a forced clean-up has let go of its manifest while it waited', async () => { @@ -667,15 +677,18 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { started(manifestOf(kept)!, { pid: deadPid(), start: '1' }) const gaveUp = new AbortController() gaveUp.abort(new Error('gave up')) - await expect( - wrapCommandWithSandboxLinux({ + expect( + await wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, readConfig: { denyOnly: [] }, writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, abortSignal: gaveUp.signal, - }), - ).rejects.toThrow() + }).then( + () => undefined, + (error: unknown) => error, + ), + ).toBeInstanceOf(Error) expect(existsSync(manifestOf(kept)!)).toBe(true) cleanupBwrapMountPoints() expect(existsSync(manifestOf(kept)!)).toBe(false) From cdfb8a8534a1b47e6c8f9d67108b5f79b7927563 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:50:16 +0000 Subject: [PATCH 092/141] linux: a clean-up says which manifests are finished, for those that cannot ask A path that a pass keeps for another manifest is dropped from its own manifests and left to that one. To a pass in another PID namespace a claimed manifest is live like any other, for seven days. So two passes in two namespaces, each finishing with a manifest that names the same path at the same moment, each kept the path for the other's manifest and dropped their own: the path stayed on the host and nothing named it any more. A pass that can ask after a manifest's processes now renames the claim of one it finds none behind to `.finished`, before the reading it decides on, drops it only at that name, and a pass that cannot ask keeps nothing on the word of a manifest at that name. Whichever of the two reads second finds the other's finished, and of all the manifests that name a path, the pass over the last to be finished keeps it for none. What such a manifest names alone is still its own namespace's to remove, or anybody's after the seven days. A pass that goes by a manifest's age does not give it the name, and one that can ask goes by what it finds, whatever the name. A pass no longer goes on record for claims it can do nothing about. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/bwrap-mount-manifests.ts | 126 +++++++--- test/sandbox/mount-point-manifests.test.ts | 256 +++++++++++++++++++-- test/sandbox/mount-point-passes.test.ts | 22 +- 4 files changed, 355 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index fed47787e..378327809 100644 --- a/README.md +++ b/README.md @@ -1136,7 +1136,7 @@ Filesystem restrictions are enforced at the OS level: - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. - **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** - - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/var/tmp` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none, for seven days from the last change to the manifest or to its record, whichever is later, and for finished then. Until then it removes nothing that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. The seven days are for a namespace that has gone: after `docker restart` the container has its files, the host's boot id and a new PID namespace, and nothing else would ever end what the earlier one left. A sandbox that runs longer than that in another PID namespace, sharing both this directory and the project, is no longer protected from processes outside its namespace. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. + - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/var/tmp` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none, for seven days from the last change to the manifest or to its record, whichever is later, and for finished then. Until then it removes nothing that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. A cleanup that can ask says what it found, for those that cannot: once it has claimed a manifest and found no process behind it, it renames the claim once more (to `.finished`), before the reading it decides on, and a process that cannot ask keeps no path for a manifest at that name (one that goes by a manifest's age does not rename it so: whoever can ask goes on asking). Without that, two cleanups in two namespaces that each finish with a manifest naming the same path at the same moment would each keep the path for the other's manifest and drop their own, and the path would stay with nothing left to name it. What such a manifest names alone is still its own namespace's to remove, or anybody's after the seven days. The seven days are for a namespace that has gone: after `docker restart` the container has its files, the host's boot id and a new PID namespace, and nothing else would ever end what the earlier one left. A sandbox that runs longer than that in another PID namespace, sharing both this directory and the project, is no longer protected from processes outside its namespace. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/var/tmp` or `/tmp` can delete and forge manifests there. A process that can write in none of the first three places keeps its manifests where its environment says, which the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where one of the names, or a directory above it, is a symbolic link of the user's that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where the parent of a name does not exist, and the user may write the directory it is missing from, and that lies inside a write root (a process that runs as root and has no `/run/user/0`, with `/` or `/run` writable; an `XDG_RUNTIME_DIR` or `TMPDIR` that is not there), the wrap cannot make the manifest directory and a sandboxed command can, parent and all: every process that looks there afterwards believes it. Where `/run/user/` itself is a write root the command can open it to others; every process goes on reading what is under it, but another local user can then move that manifest directory aside. What a cleanup removes on such a manifest's word is bounded as above. - _A `/var/tmp/srt-mount-points-` that another user made._ Another local user can make that name, or the one in `/tmp`, before the user's first wrap. It fails the owner check and is passed over, never repaired, and the user's processes write in the next place. diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index c913ef06a..a75ba292c 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -64,6 +64,13 @@ * is live, for {@link OTHER_NAMESPACE_MAX_AGE_MS}. It records the boot too: no * process of an earlier one runs, where only this kernel reaches the directory. * NO LASTING STATE KEEPS WHAT IS LEFT FOR EVER. + * + * So a pass that can judge says what it found, for those that cannot: it renames + * the claim of a manifest with no process left to `.finished`, before the + * reading it decides on, and they keep nothing on the word of a manifest at that + * name. Else two passes that cannot judge each other's manifests would each keep + * a path because the other's names it and drop their own, and nothing would name + * the path any more (see {@link collect}). */ import { randomBytes } from 'node:crypto' @@ -82,7 +89,10 @@ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' const STARTED_SUFFIX = '.started' const CLAIMED_SUFFIX = '.claimed' +const FINISHED_SUFFIX = '.finished' const PASS_SUFFIX = '.pass' +/** Where a manifest is found, in the order it goes through them. */ +const MANIFEST_SUFFIXES = [MANIFEST_SUFFIX, CLAIMED_SUFFIX, FINISHED_SUFFIX] /** * A manifest this young counts as live with no started record yet, so that a @@ -161,7 +171,7 @@ const TEMPORARY_SUFFIX = '.tmp' const ManifestSchema = z.object({ /** * INVARIANT for every later version: it may add fields, and keeps these, the - * four file names and what they mean, so a reader takes a later version by + * five file names and what they mean, so a reader takes a later version by * the fields it knows. A manifest that is not read puts everything in doubt, * which would refuse every wrap of an older process for as long as a newer * one is at work beside it. A change that cannot keep to this takes another @@ -195,9 +205,11 @@ const ManifestSchema = z.object({ }) type Manifest = z.infer & { - /** Where it is: at its own name, `.json`, or at a claim's. */ + /** Where it is: at its own name, `.json`, or at one of a claim's two. */ file: string claimed: boolean + /** Claimed, and by its name no process was left on its record after that. */ + finished: boolean /** Its started record, `.started`. */ record: string /** This process wrote it and the caller says its command is over. */ @@ -760,7 +772,7 @@ function readOwnFile( const idOf = (file: string): string => file.slice(0, file.lastIndexOf('.')) /** - * The manifest at `file`, which is its own name or its claim's, or `undefined` + * The manifest at `file`, which is its own name or a claim's, or `undefined` * where nothing is there. Throws like {@link readOwnFile}, and {@link NotOurs} * for what does not hold a manifest's fields. */ @@ -783,7 +795,8 @@ function readManifest(file: string): Manifest | undefined { return { ...parsed.data, file, - claimed: file.endsWith(CLAIMED_SUFFIX), + claimed: !file.endsWith(MANIFEST_SUFFIX), + finished: file.endsWith(FINISHED_SUFFIX), record: `${id}${STARTED_SUFFIX}`, released: ownManifests.get(`${id}${MANIFEST_SUFFIX}`)?.over === true, } @@ -832,6 +845,22 @@ function recordVouches(record: string, orphan = false): boolean | undefined { }) } +const isOfAnotherBoot = (manifest: Manifest): boolean => + manifest.boot !== undefined && + manifest.boot !== (ownBootId() ?? manifest.boot) + +/** + * Whether this process can tell if the processes behind a manifest run: none of + * another boot does, and of this one it can ask after those of its own PID + * namespace. What {@link isLive} says of any other goes by its age alone. + */ +function canBeAskedAfter(manifest: Manifest): boolean { + const here = ownPidNamespace() + return ( + isOfAnotherBoot(manifest) || (here !== undefined && manifest.ns === here) + ) +} + /** * Whether a sandbox that named this manifest may be running, or may yet start. * None of another boot is, whatever else the manifest says. Only a process in @@ -843,20 +872,16 @@ function recordVouches(record: string, orphan = false): boolean | undefined { * young and has no record. */ function isLive(manifest: Manifest): boolean { - if ( - manifest.boot !== undefined && - manifest.boot !== (ownBootId() ?? manifest.boot) - ) { + if (isOfAnotherBoot(manifest)) { return false } - const here = ownPidNamespace() - if (here === undefined) { - return true - } - if (manifest.ns !== here) { - return !( - olderThan(manifest.file, OTHER_NAMESPACE_MAX_AGE_MS) && - olderThan(manifest.record, OTHER_NAMESPACE_MAX_AGE_MS, true) + if (!canBeAskedAfter(manifest)) { + return ( + ownPidNamespace() === undefined || + !( + olderThan(manifest.file, OTHER_NAMESPACE_MAX_AGE_MS) && + olderThan(manifest.record, OTHER_NAMESPACE_MAX_AGE_MS, true) + ) ) } const vouched = recordVouches(manifest.record) @@ -1108,14 +1133,15 @@ type Reading = { * Reads the manifests in `dirs`, every one that the listings showed. Doubt in * one directory is doubt. * - * A pass renames a manifest to claim it and to give it back, which can hide it + * A pass renames a manifest to claim it, to give it back and to say that it is + * finished, which can hide it * from a listing under way and from the open that follows one, and one published * while the directory is listed can be missed with its record shown. A record is * never renamed. So the reading starts over for a listed manifest that is gone * when opened, and for a record with no manifest listed beside it whose manifest * is there when asked for by name. * - * A record with no manifest at either name puts the reading in doubt only while + * A record with no manifest at any of its names puts the reading in doubt only while * a process on it runs (see {@link recordVouches}) and is spent otherwise, one * with no whole line past {@link MANIFEST_GRACE_MS} (its shell may be about to * write to it). @@ -1149,7 +1175,7 @@ function readManifests(dirs: readonly string[]): Reading { const listed = new Set() let moved = false for (const file of files) { - if (!file.endsWith(MANIFEST_SUFFIX) && !file.endsWith(CLAIMED_SUFFIX)) { + if (!MANIFEST_SUFFIXES.some(suffix => file.endsWith(suffix))) { continue } listed.add(idOf(file)) @@ -1202,13 +1228,12 @@ function readManifests(dirs: readonly string[]): Reading { reading.spent.push(file) } } else if (file.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { - // Asked for at its own name first, and the record read last. Only a - // claim given back can then pass between the two names unseen, and a - // pass gives back only once it has found a process on the record, + // Asked for in the order it goes through its names, and the record read + // last. Only a claim given back can then pass between them unseen, and + // a pass gives back only once it has found a process on the record, // which the read below finds too. if ( - isThere(`${idOf(file)}${MANIFEST_SUFFIX}`) || - isThere(`${idOf(file)}${CLAIMED_SUFFIX}`) + MANIFEST_SUFFIXES.some(suffix => isThere(`${idOf(file)}${suffix}`)) ) { moved = true continue @@ -1338,7 +1363,10 @@ export function liveMountPoints(): ReadonlySet | undefined { throw new Error(reading.inDoubt) } for (const manifest of reading.manifests) { - if (isLive(manifest)) { + if ( + isLive(manifest) && + !(manifest.finished && !canBeAskedAfter(manifest)) + ) { for (const mountPoint of manifest.paths) named.add(mountPoint) } } @@ -1578,6 +1606,19 @@ const childrenFirst = (paths: Iterable): string[] => * what remains is a manifest published after the reading, whose sandbox would * reach its bind on a path a claimed manifest names too. * + * INVARIANT: a pass drops a manifest whose processes it can ask after only at + * the name `.finished`, which such a pass gives a claim it finds no process + * behind, before that reading; and a pass that cannot ask keeps nothing on the + * word of a manifest at that name. A path that is kept is dropped from the + * manifests of this pass and left to the one it is kept for. To a pass that + * cannot ask (one in another PID namespace) a claim is live like any other, so + * without the name each of two such passes would leave the path to the other's + * manifest. With it, whichever reads second finds the other's finished, and of + * all the manifests that name a path the pass over the last to be finished + * keeps it for none. What such a manifest names alone is still its own + * namespace's to remove, or anybody's once it is old. A pass that goes by the + * age of a manifest does not give it the name: whoever can ask goes on asking. + * * INVARIANT: the pass is on record in every directory it can write from before * that reading until after its last removal, and removes nothing where it could * not be put there. A wrap lists the passes once it has published, and hands no @@ -1611,16 +1652,31 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { } } for (const manifest of before.manifests) { - if (!manifest.claimed && !isLive(manifest)) { - const claim = `${idOf(manifest.file)}${CLAIMED_SUFFIX}` + const claim = manifest.claimed + ? manifest.file + : `${idOf(manifest.file)}${CLAIMED_SUFFIX}` + if (!manifest.claimed) { + if (isLive(manifest)) continue try { fs.renameSync(manifest.file, claim) own.add(claim) } catch { // Another pass has it, or the directory cannot be written. + continue + } + } + // Asked again, now that no sandbox can start under it. + const over = !isLive({ ...manifest, file: claim, claimed: true }) + claims ||= over || own.has(claim) + // Not of one that is only old: those who can ask go on asking. + if (over && !manifest.finished && canBeAskedAfter(manifest)) { + try { + fs.renameSync(claim, `${idOf(claim)}${FINISHED_SUFFIX}`) + own.delete(claim) + } catch { + // Another pass has said so, or has dropped it. } } - claims ||= manifest.claimed || own.size > 0 } } // Nothing to remove, or nothing that may be: not worth a record, which every @@ -1640,9 +1696,9 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { } const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) try { - // Off the record it claims in no directory: the claims it has made go back, - // and what is left to remove is what this process could record nowhere, - // which no wrap elsewhere knows for a mount point or waits for. + // Off the record it removes nothing on a manifest's word. What is left to + // remove is what this process could record nowhere, which no wrap elsewhere + // knows for a mount point or waits for. return removeWhatIsFinished( dirs, putPassOnRecord(writable) ? writable : [], @@ -1682,14 +1738,22 @@ function removeWhatIsFinished( } const finished: Manifest[] = [] for (const manifest of reading.manifests) { + const asked = canBeAskedAfter(manifest) if ( manifest.claimed && !isLive(manifest) && + // Else its last process has ended, or the renaming has failed, since the + // claims: it is the next pass's, which says so before it reads. + (manifest.finished || !asked) && writable.includes(path.dirname(manifest.file)) ) { finished.push(manifest) continue } + // Not for this pass to remove, and nothing to keep anything for. + if (manifest.finished && !asked) { + continue + } keep(manifest) if (own.has(manifest.file)) { try { diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 41bd1ff4e..81d84be88 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import * as fs from 'node:fs' import { chmodSync, @@ -192,7 +192,7 @@ describe.if(isLinux)('The mount point manifests', () => { /** The claims in the manifest directory, by name. */ function claims(): string[] { - return readdirSync(DIR).filter(name => name.endsWith('.claimed')) + return readdirSync(DIR).filter(name => /\.(claimed|finished)$/.test(name)) } /** The name a pass gives `manifest` when it claims it. */ @@ -200,6 +200,11 @@ describe.if(isLinux)('The mount point manifests', () => { return basename(manifest).replace(/\.json$/, '.claimed') } + /** And the one it gives that claim once it has found no process behind it. */ + function finishedOf(manifest: string): string { + return basename(manifest).replace(/\.json$/, '.finished') + } + /** * Runs `source` with the module as `m` in a process of its own, which is * killed if it does not end by itself: what these cases guard against blocks @@ -369,7 +374,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(collectMountPoints()).toEqual([]) expect(existsSync(inside)).toBe(true) // Under its claim, which any later pass takes up. - expect(claims()).toEqual([claimOf(manifest)]) + expect(claims()).toEqual([finishedOf(manifest)]) expect(named()).toEqual([inside]) } finally { chmodSync(readOnly, 0o755) @@ -397,7 +402,7 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(collectMountPoints('all')).toEqual([]) expect(existsSync(inside)).toBe(true) - expect(claims()).toEqual([claimOf(manifest)]) + expect(claims()).toEqual([finishedOf(manifest)]) } finally { chmodSync(readOnly, 0o755) } @@ -423,7 +428,7 @@ describe.if(isLinux)('The mount point manifests', () => { pass.restore() } expect(existsSync(X)).toBe(true) - expect(claims()).toEqual([claimOf(manifest)]) + expect(claims()).toEqual([finishedOf(manifest)]) rmSync(stranger) expect(collectMountPoints('none')).toEqual([X]) @@ -654,7 +659,7 @@ describe.if(isLinux)('The mount point manifests', () => { } finally { removal.restore() } - expect(seen).toEqual([{ manifest: false, claims: [claimOf(manifest)] }]) + expect(seen).toEqual([{ manifest: false, claims: [finishedOf(manifest)] }]) expect(readdirSync(DIR)).toEqual([]) }) @@ -773,16 +778,15 @@ describe.if(isLinux)('The mount point manifests', () => { expect(claims()).toEqual([basename(claim)]) }) - it('leaves a manifest and its record that were given back while it removed what they name', () => { - // The pass that made the claim found a process on the record after this one - // had read it, and a sandbox now runs under the manifest again. + it('leaves a manifest and its record that are back at its own name while it removes what they name', () => { + // No pass gives back what it has found finished. Should the manifest be at + // its own name again all the same, a sandbox can start under it. const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) const manifest = manifestOfADeadProcess([X, Y]) - const claim = claimed(manifest) const removal = atTheRemovalOf(Y, () => { - fs.renameSync(claim, manifest) + fs.renameSync(join(DIR, finishedOf(manifest)), manifest) started(manifest, thisProcess()) }) try { @@ -1334,7 +1338,7 @@ describe.if(isLinux)('The mount point manifests', () => { injected?.restore() } expect(existsSync(X)).toBe(true) - expect(claims()).toEqual([claimOf(manifest)]) + expect(claims()).toEqual([finishedOf(manifest)]) expect(collectMountPoints()).toEqual([X]) }) } @@ -1403,7 +1407,7 @@ describe.if(isLinux)('The mount point manifests', () => { const outer = manifestOfADeadProcess([Q]) const inner = started(manifestOfADeadProcess(inside), thisProcess()) expect(collectMountPoints()).toEqual([]) - expect(claims()).toEqual([claimOf(outer)]) + expect(claims()).toEqual([finishedOf(outer)]) started(inner, { pid: deadPid(), start: '1' }) expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) @@ -1419,7 +1423,7 @@ describe.if(isLinux)('The mount point manifests', () => { chmodSync(Q, 0o555) try { expect(collectMountPoints()).toEqual([]) - expect(claims()).toContain(claimOf(outer)) + expect(claims()).toContain(finishedOf(outer)) } finally { chmodSync(Q, 0o755) } @@ -1580,6 +1584,230 @@ describe.if(isLinux)('The mount point manifests', () => { } }) + // ---- what a pass that can judge says for those that cannot ---- + + /** Has `manifest` finished by its name, as a pass that could judge it does. */ + function finished(manifest: string): string { + const file = join(DIR, finishedOf(manifest)) + fs.renameSync(manifest, file) + return file + } + + it('keeps nothing for a manifest of another PID namespace that is finished by its name, and leaves that one to its own', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const theirs = finished(manifestOfADeadProcess([X, Y], { ns: 'pid:[1]' })) + manifestOfADeadProcess([X]) + // A wrap still takes both for mount points, which they are. + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + // What it names alone may be another file in that namespace's mounts. + expect(existsSync(Y)).toBe(true) + expect(readdirSync(DIR)).toEqual([basename(theirs)]) + }) + + it('goes on no record for a manifest of another PID namespace that is finished by its name', () => { + leftover(X) + finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints()).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it('removes what such a manifest names alone once it is seven days old', () => { + leftover(X) + const theirs = finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + aged(theirs, 7 * 24 * 60 - 1) + expect(collectMountPoints()).toEqual([]) + aged(theirs, 7 * 24 * 60 + 1) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('does not say of a claim it cannot judge that it is finished', () => { + leftover(X) + const claim = claimed(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + manifestOfADeadProcess([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(readdirSync(DIR)).toEqual([basename(claim)]) + }) + + it('does not say so of a claim with a process on its record either', () => { + leftover(X) + const claim = claimed(started(manifestOfADeadProcess([X]), thisProcess())) + collectMountPoints() + expect(claims()).toEqual([basename(claim)]) + }) + + it('says so of a claim another pass left, and collects it', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + claimed(manifest) + const seen: string[][] = [] + const removal = atTheRemovalOf(X, () => seen.push(claims())) + try { + expect(collectMountPoints()).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([[finishedOf(manifest)]]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it("takes the record beside a manifest that is finished by its name for that one's", () => { + leftover(X) + const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { + pid: deadPid(), + start: '1', + }) + const theirs = finished(manifest) + aged(recordOf(manifest)) + expect(collectMountPoints()).toEqual([]) + expect(readdirSync(DIR).sort()).toEqual( + [basename(theirs), basename(recordOf(manifest))].sort(), + ) + }) + + it('does not say so of a manifest it only takes for finished by its age: whoever can ask goes on asking', () => { + leftover(X) + const theirs = aged( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + 7 * 24 * 60 + 1, + ) + const seen: string[][] = [] + const removal = atTheRemovalOf(X, () => seen.push(claims())) + try { + expect(collectMountPoints()).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([[claimOf(theirs)]]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('goes by what it finds, not by the name, where it can ask: a process on the record keeps what the manifest names', () => { + leftover(X) + const theirs = finished(started(manifestOfADeadProcess([X]), thisProcess())) + manifestOfADeadProcess([X]) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([basename(theirs)]) + }) + + it('leaves a claim that could not be given the name to the next pass, with all it names', () => { + // Dropped at the claim's name, with X kept for a manifest of another + // namespace, a pass over that one would still take it for live. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + if (to.endsWith('.finished')) { + throw Object.assign(new Error('EIO'), { code: 'EIO' }) + } + rename(from, to) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(named()).toEqual([X]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + expect(existsSync(manifest)).toBe(false) + }) + + it('finds by name one that the listing missed, and leaves its record', () => { + // Renamed while the directory was being listed. Taken for a record without + // a manifest it would be dropped, and a wrap would take X for the caller's. + leftover(X) + const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { + pid: deadPid(), + start: '1', + }) + const theirs = finished(manifest) + for (const look of [named, () => collectMountPoints('none')]) { + const listing = missedByTheNextListing(basename(theirs)) + try { + expect(look()).toEqual(look === named ? [X] : []) + } finally { + listing.restore() + } + } + expect(existsSync(recordOf(manifest))).toBe(true) + }) + + it('leaves a path to neither of two clean-ups that cannot judge each other, each of which would keep it for the other', async () => { + // Two processes that take themselves to be in PID namespaces of their own, + // each with a manifest that names X. Each meets the other once it has made + // its claims, and again once it has read and is about to remove or drop. + leftover(X) + const side = (me: string, other: string, ns: string): string => { + const file = join(BASE, `${me}.ts`) + const at = (who: string, where: string): string => + JSON.stringify(join(BASE, `${who}.${where}`)) + const meet = (where: string): string => + `fs.writeFileSync(${at(me, where)}, ''); while (!fs.existsSync(${at(other, where)})) Bun.sleepSync(1)` + writeFileSync( + file, + [ + `import { spyOn } from 'bun:test'`, + `import * as fs from 'node:fs'`, + `const readlink = fs.readlinkSync`, + `spyOn(fs, 'readlinkSync').mockImplementation((p, ...rest) =>`, + ` p === '/proc/self/ns/pid' ? ${JSON.stringify(ns)} : readlink(p, ...rest))`, + `const m = await import(${MODULE})`, + `m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + meet('published'), + `const open = fs.openSync`, + `const unlink = fs.unlinkSync`, + `let met = 0`, + `spyOn(fs, 'openSync').mockImplementation((p, ...rest) => {`, + ` if (met === 0 && String(p).endsWith('.pass')) { met = 1; ${meet('claimed')} }`, + ` return open(p, ...rest)`, + `})`, + `spyOn(fs, 'unlinkSync').mockImplementation(p => {`, + ` if (met === 1) { met = 2; ${meet('read')} }`, + ` return unlink(p)`, + `})`, + `m.collectMountPoints()`, + ].join('\n'), + ) + return file + } + const ended = [ + side('one', 'other', 'pid:[1]'), + side('other', 'one', 'pid:[2]'), + ].map( + file => + new Promise(resolve => + spawn(process.execPath, [file], { + env: { ...process.env, TMPDIR: TMP }, + stdio: 'inherit', + timeout: 10000, + killSignal: 'SIGKILL', + }).on('exit', resolve), + ), + ) + expect(await Promise.all(ended)).toEqual([0, 0]) + expect(existsSync(X)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, 15000) + /** Has every directory be on a file system of that kind, or not say. */ function onAFileSystemOf(type: number | bigint | Error): { restore(): void diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 731a72b4c..ede698e3a 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -15,7 +15,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, extname, join } from 'node:path' import { collectMountPoints, liveMountPoints, @@ -217,10 +217,11 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { // ---- the pass ------------------------------------------------------------ - it('goes on record before the reading it decides on, stays there while it removes, and is off it afterwards', () => { + it('says what is finished and goes on record before the reading it decides on, stays there while it removes, and is off it afterwards', () => { leftover(LOCK) manifestOfADeadProcess([LOCK]) const open = fs.openSync + const rename = fs.renameSync const unlink = fs.unlinkSync const seen: string[] = [] const spies = [ @@ -228,11 +229,18 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { file: unknown, ...rest: unknown[] ) => { - if (typeof file === 'string' && /\.(pass|claimed)$/.test(file)) { + if (typeof file === 'string' && /\.(pass|finished)$/.test(file)) { seen.push(file.endsWith('.pass') ? 'on record' : 'reads the claim') } return (open as (...args: unknown[]) => number)(file, ...rest) }) as never), + spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + seen.push(`renames to ${extname(to)}`) + rename(from, to) + }) as never), spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { if (file === LOCK) seen.push(`removes, with ${passes().length} on record`) @@ -245,6 +253,8 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { spies.forEach(spy => spy.mockRestore()) } expect(seen).toEqual([ + 'renames to .claimed', + 'renames to .finished', 'on record', 'reads the claim', 'removes, with 1 on record', @@ -304,8 +314,10 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { spy.mockRestore() } expect(existsSync(LOCK)).toBe(true) - // Its claim has gone back, for a wrap to read as before. - expect(readdirSync(DIR)).toEqual([basename(manifest)]) + // What it has found finished stays so, and named, for the next. + expect(readdirSync(DIR)).toEqual([ + basename(manifest).replace(/json$/, 'finished'), + ]) expect(collectMountPoints()).toEqual([LOCK]) }) From 9f5b10860b3841ccc6c9876e8418f769593fdda0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:17:27 +0000 Subject: [PATCH 093/141] linux: the record step leaves the caller's variables alone It read /proc/self/stat into `s`. A caller's exported `s` reached the command with the host shell's stat line in it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/linux-sandbox-utils.ts | 13 +++++++----- test/helpers/bwrap-argv.ts | 2 +- test/sandbox/mount-point-liveness.test.ts | 25 +++++++++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 378327809..07ebc875c 100644 --- a/README.md +++ b/README.md @@ -1127,7 +1127,7 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and those of processes that are gone or have let go of them, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), which has to know `local`, as dash, ash and mksh do, and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and those of processes that are gone or have let go of them, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made before the wrap's manifest is published, so a process killed between the two leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 2eae7c5bd..8cc8f9a4d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1057,8 +1057,8 @@ let stepShell: string[] | undefined * * THREAT: this shell runs on the host, unsandboxed, in an environment a * sandboxed command may have had a hand in. So `-p`: plain `bash -c` imports - * exported functions, and one named `shift`, `read`, `printf` or `exec` would - * run in place of the step's own word; it also reads the file `$BASH_ENV` + * exported functions, and one named `shift`, `local`, `read`, `printf` or `exec` + * would run in place of the step's own word; it also reads the file `$BASH_ENV` * names. And never a PATH look-up or the caller's `binShell`: a copy planted in * a writable PATH directory would be run out here. */ @@ -1107,8 +1107,11 @@ export class WorkingDirectoryChanged extends Error { * execs bwrap in its place: the pid on record is bwrap's, written before bwrap * has made a mount. Read by the shell itself, a redirection on a builtin: a * child's line would be that of a process gone at once. Not /proc/$$: under an - * outer /proc `$$` is nobody there, or an unrelated process. Appended, not - * written over, so that every run of the string is on record while it lasts. + * outer /proc `$$` is nobody there, or an unrelated process. Into a variable + * local to a function: assigned to, one of that name that the caller exports + * would reach the command with the line in it. (`local` is in bash, dash, ash + * and mksh, not in POSIX; the function's name is none a shell has an alias for.) Appended, not written over, so + * that every run of the string is on record while it lasts. * One shell does both where both are needed. * Throws {@link LinuxSandboxProfileError} when the profile cannot run. */ @@ -1125,7 +1128,7 @@ function renderBwrapInvocation( ? [] : [ { - run: 'read -r s >"$1"', + run: 'srt_record() { local s && read -r s >"$1"; } && srt_record "$1"', file: started, }, ] diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 8cf894e21..2c6919f61 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -22,7 +22,7 @@ export const STEP_SHELL = ((): string => { /** The step that puts the command on its manifest's started record. */ export const RECORD_STEP = - 'read -r s >"$1" && shift' + 'srt_record() { local s && read -r s >"$1"; } && srt_record "$1" && shift' /** The mount flags this generator emits with a source and a destination. */ const MOUNT_FLAGS = ['--bind', '--ro-bind'] diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 92f4e6b8f..cb850c2d5 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -798,6 +798,31 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + it.if(BWRAP_CAN_NAMESPACE).each([STEP_SHELL, '/bin/sh -c'])( + 'leaves an entry named like a variable of its own steps as it was (steps run by %s)', + async stepShell => { + // Else the command gets the host shell's /proc/self/stat line for it. The + // steps are run by /bin/sh where there is no bash. + const wrapped = await wrap('echo "s=[${s-unset}]"') + expect(wrapped).toStartWith(`${STEP_SHELL} `) + const command = `${stepShell}${wrapped.slice(STEP_SHELL.length)}` + for (const [s, said] of [ + ['precious', 's=[precious]\n'], + [undefined, 's=[unset]\n'], + ] as const) { + const run = spawnSync(command, { + shell: true, + env: { ...process.env, s }, + encoding: 'utf8', + timeout: 60000, + }) + expect(`${run.stdout}${run.stderr}`).toBe(said) + } + expect(onRecord(wrapped)).toHaveLength(2) + }, + 90000, + ) + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( 'runs nothing on the host that the environment plants for a shell', async () => { From fa15c63af24a5e850facd373c73dde48e1c749ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:17:27 +0000 Subject: [PATCH 094/141] README: a paragraph the merge of main doubled Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 07ebc875c..5497feae7 100644 --- a/README.md +++ b/README.md @@ -1118,8 +1118,6 @@ Filesystem restrictions are enforced at the OS level: **Writes through links (Linux and macOS):** a write is judged by where it lands, after symbolic links are followed. A link inside an allowed write path that leads out of it gives a command nothing: the write is refused. A link that leads into an allowed write path works like any other name for that place. A hard link is a second name for the file itself: on Linux a file outside the allowed write paths that already has a hard link inside one can be written through it, and a sandboxed command cannot create such a link. On macOS nothing is promised for such a file. -**Writes through links (Linux and macOS):** a write is judged by where it lands, after symbolic links are followed. A link inside an allowed write path that leads out of it gives a command nothing: the write is refused. A link that leads into an allowed write path works like any other name for that place. A hard link is a second name for the file itself: on Linux a file outside the allowed write paths that already has a hard link inside one can be written through it, and a sandboxed command cannot create such a link. On macOS nothing is promised for such a file. - **Read-side rules (Linux):** an entry is matched by the name it is, not by what it points at. - An `allowRead` entry re-allows the name it names, so a link planted at an allowed path cannot re-open a denied one. One that is itself a symlink is bound back at its own name, from the target it was checked against: the target's own path stays hidden, and the target's contents are reachable only through the name. A `denyRead` entry or a credential mask that overlaps that target — at it, inside it, or covering it — wins over the carve-out, which is then not restored at all: the name is absent inside the sandbox rather than serving what the deny hides. The denied directory the carve-out is being restored into is not such an overlap, nor is anything above it: those are what the carve-out is an exception to. Neither is an entry that denies nothing — one naming a path that is not there, or a file an `allowRead` entry lifts. From 90312c63b65a6f0939186738a92945f60a879eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:18:14 +0000 Subject: [PATCH 095/141] linux: a manifest directory that has two names is one directory Where /var/tmp is a link to /tmp, or a bind mount of it, the first and the third place are one. A clean-up could not go on record in it twice, so it removed nothing, ever. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 33 ++++++++++- test/sandbox/mount-point-manifests.test.ts | 64 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index a75ba292c..c77fac0d9 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -560,6 +560,10 @@ function sharedManifestDirectoryNames( * written, because only processes with that environment keep them out of a * sandbox's reach; and the one it has settled on. Each only while it is a * directory of ours alone. Makes nothing. Throws where it cannot tell. + * + * Each directory once, whatever it is called: where /var/tmp is a link to /tmp, + * or a bind mount of it, two of the names are one directory. Every manifest in + * it would be read at two names, and no pass could go on record in it twice. */ function believedManifestDirectories(): string[] { const names = fixedManifestDirectoryNames(true, true).some(dir => @@ -568,9 +572,32 @@ function believedManifestDirectories(): string[] { ? fixedManifestDirectoryNames(true) : sharedManifestDirectoryNames(true) if (manifestDirectory !== undefined) names.push(manifestDirectory) - return [...new Set(names)].filter(dir => - isOurPrivateDirectory(dir, true, false), - ) + const byIdentity = new Map() + for (const dir of names) { + if (!isOurPrivateDirectory(dir, true, false)) continue + let identity: string + try { + // As big integers: a number does not hold every inode number apart. + const { dev, ino } = fs.lstatSync(dir, { bigint: true }) + identity = `${dev}:${ino}` + } catch (e) { + if (isAbsenceErrno(e)) continue + throw e + } + // By the name this process writes under, which it knows its own manifests + // by; else by one it can be written at (the other may be a read-only bind). + const sofar = byIdentity.get(identity) + if ( + sofar === undefined || + dir === manifestDirectory || + (sofar !== manifestDirectory && + !isOurPrivateDirectory(sofar) && + isOurPrivateDirectory(dir)) + ) { + byIdentity.set(identity, dir) + } + } + return [...byIdentity.values()] } /** diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 81d84be88..b430e2d99 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -2202,6 +2202,70 @@ describe.if(isLinux)('The mount point manifests', () => { } }) + it.each([ + ['/var/tmp', 0, 2], + ['/tmp', 2, 0], + ] as const)( + 'are one directory where %s is a link to the other, read once and collected in like any', + (_, link, target) => { + // A pass cannot go on record twice under one name, and off the record it + // removes nothing. + rmSync(dirname(PLACES[link]!), { recursive: true }) + symlinkSync(dirname(PLACES[target]!), dirname(PLACES[link]!)) + forgetMountPointManifestDirectory() + leftover(X) + leftover(Y) + at(PLACES[target]!, () => manifestOfADeadProcess([X])) + expect(dirname(publish([Y]))).toBe(PLACES[0]!) + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([Y]) + expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + expect(readdirSync(PLACES[target]!)).toEqual([]) + // Both names are kept out of a command's reach all the same. + expect(mountPointManifestDirectories()).toEqual( + expect.arrayContaining([PLACES[0]!, PLACES[2]!]), + ) + }, + ) + + it('goes by the name it can be written at where the other is read-only, as a read-only bind of it is', () => { + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + // This process writes elsewhere, and knows neither name for its own. + expect(dirname(publish([Y]))).toBe(PLACES[0]!) + collectMountPoints('all') + forgetMountPointManifestDirectory() + leftover(X) + at(PLACES[2]!, () => manifestOfADeadProcess([X])) + const access = fs.accessSync + const spy = spyOn(fs, 'accessSync').mockImplementation((( + file: unknown, + mode: unknown, + ) => { + if (file === PLACES[0]) { + throw Object.assign(new Error('EROFS'), { code: 'EROFS' }) + } + ;(access as (f: unknown, m: unknown) => void)(file, mode) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([X]) + } finally { + spy.mockRestore() + } + expect(readdirSync(PLACES[2]!)).toEqual([]) + }) + + it('goes by the name it writes under where that is the later of the two', () => { + // Settled on /tmp while /var/tmp could not be written. + taken([PLACES[0]!, dirname(PLACES[1]!)]) + leftover(Y) + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + expect(collectMountPoints()).toEqual([Y]) + expect(readdirSync(PLACES[2]!)).toEqual([]) + }) + it('are believed, collected in and bound, but not written, under a /run/user/UID that is open to others', () => { // Where that is a write root a sandboxed command can open it up, and // must not have other processes read less by that. From b6b0b7c8c41ea0e9e5afe5ecd2d8d12db06ee9d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:19:46 +0000 Subject: [PATCH 096/141] linux: every run on a started record is asked after, not the last two hundred Only the last 64 KiB were read. A first run that outlasted some 215 later runs of the same command line was forgotten, and its mount points were removed from under it. The record is read whole now, up to 4 MiB, and past that it vouches. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/bwrap-mount-manifests.ts | 38 +++++++------------------ test/sandbox/mount-point-passes.test.ts | 35 +++++++++++++++++------ 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 5497feae7..89fb71410 100644 --- a/README.md +++ b/README.md @@ -1151,7 +1151,7 @@ Filesystem restrictions are enforced at the OS level: - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier: when the command's own `commandId` is passed, and at any call made once a run of it has ended). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. Until then it may be run again and again; each run adds its line to the record, of which the last 64 KiB are read, some two hundred runs. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier: when the command's own `commandId` is passed, and at any call made once a run of it has ended). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. Until then it may be run again and again; each run adds its line to the record, and every run on it is asked after. A record past 4 MiB, some fourteen thousand runs, vouches like any that cannot be read: what its manifest names then stays until the next boot. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. - Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them, or another process's cleanup is held up half way and what it set out to remove may go at any moment (`.cause` is its record, whose name begins with its pid); wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index c77fac0d9..ab0dd0d27 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -157,10 +157,10 @@ const WAITS_FOR_PASSES_MS = 5 * 1000 const MANIFEST_MAX_BYTES = 1024 * 1024 /** - * How much of a started record is read, from its end: a line of some 300 bytes - * for each run, so the last two hundred runs of one command line. + * The largest file read as a started record: a line of some 300 bytes for each + * run, so fourteen thousand runs of one command line. */ -const RECORD_MAX_BYTES = 64 * 1024 +const RECORD_MAX_BYTES = 4 * 1024 * 1024 /** * Suffix of a manifest before it is moved into place. One left by a killed @@ -753,13 +753,8 @@ function isOwnReadableFile(file: string): boolean { * of the user's, no larger than `max`, is read, through a descriptor opened * without following a link and without blocking. Throws {@link NotOurs} for * anything else that is there, and the error itself where that cannot be told. - * With `itsLastLines`, of a larger one the whole lines in its last `max` bytes. */ -function readOwnFile( - file: string, - max: number, - itsLastLines = false, -): string | undefined { +function readOwnFile(file: string, max: number): string | undefined { let fd: number try { fd = fs.openSync( @@ -775,21 +770,10 @@ function readOwnFile( } try { const stat = fs.fstatSync(fd) - if ( - !stat.isFile() || - stat.uid !== process.getuid?.() || - (stat.size > max && !itsLastLines) - ) { + if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.size > max) { throw new NotOurs(`${file} is not a small regular file of this user's`) } - if (stat.size <= max) { - return fs.readFileSync(fd, 'utf8') - } - const end = Buffer.alloc(max) - const text = end - .subarray(0, fs.readSync(fd, end, 0, max, stat.size - max)) - .toString('utf8') - return text.slice(text.indexOf('\n') + 1) + return fs.readFileSync(fd, 'utf8') } finally { fs.closeSync(fd) } @@ -835,9 +819,9 @@ function readManifest(file: string): Manifest | undefined { * file, or holds a line that is no /proc/PID/stat line vouches: only a process * that is gone says its sandbox has ended. * - * A command line may be run again and again, and each run adds its line. Of a - * record past {@link RECORD_MAX_BYTES} the last runs are asked after: refusing - * to read it would keep what it vouches for named for ever. + * A command line may be run again and again, and each run adds its line. Every + * one of them is asked after, the last first: the first run may outlast all that + * follow it. * * Not so an `orphan`, which has no manifest that can be read: it names no path, * so it protects none. Only a process that can be asked after vouches there, and @@ -852,11 +836,11 @@ function readManifest(file: string): Manifest | undefined { function recordVouches(record: string, orphan = false): boolean | undefined { let lines: string[] try { - const text = readOwnFile(record, RECORD_MAX_BYTES, true) + const text = readOwnFile(record, RECORD_MAX_BYTES) if (text === undefined) { return undefined } - lines = text.split('\n').slice(0, -1) + lines = text.split('\n').slice(0, -1).reverse() } catch (e) { return !(orphan && e instanceof NotOurs) } diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index ede698e3a..6c8a113d6 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -835,17 +835,36 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(existsSync(manifest)).toBe(true) }) - it('goes by the last runs of a command line that has been run more often than a record is read for', () => { + it.each(['first', 'last'] as const)( + 'asks after every run of a command line that has been run thousands of times: the %s may be the one that lasts', + which => { + leftover(LOCK) + const gone = Array(3000).fill({ pid: deadPid(), start: '1' }) + const manifest = manifestOfADeadProcess([LOCK]) + started( + manifest, + ...(which === 'first' + ? [anotherProcess(), ...gone] + : [...gone, anotherProcess()]), + ) + expect(collectMountPoints()).toEqual([]) + started(manifest, ...gone) + expect(collectMountPoints()).toEqual([LOCK]) + }, + ) + + it('takes a record too long to be read to vouch, like any that cannot be read', () => { leftover(LOCK) - const gone = Array(1500).fill({ pid: deadPid(), start: '1' }) const manifest = manifestOfADeadProcess([LOCK]) - started(manifest, ...gone, anotherProcess()) - expect( - fs.statSync(manifest.replace(/\.json$/, '.started')).size, - ).toBeGreaterThan(64 * 1024) + started(manifest, { pid: deadPid(), start: '1' }) + const record = manifest.replace(/\.json$/, '.started') + const line = fs.readFileSync(record, 'utf8') + fs.writeFileSync( + record, + line.repeat(Math.ceil((4 * 1024 * 1024 + 1) / line.length)), + ) expect(collectMountPoints()).toEqual([]) - started(manifest, ...gone) - expect(collectMountPoints()).toEqual([LOCK]) + expect(existsSync(LOCK)).toBe(true) }) it('takes it once that process is gone', () => { From db2308a98aee03ddbd82876e09289a72cf689a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:21:11 +0000 Subject: [PATCH 097/141] linux: what a plan makes on a 9p drive is kept track of until a manifest names it There the plan makes the mount point itself. A plan that threw or was given up before its manifest was published left it on the host with nothing to name it, and the next wrap took it for the caller's own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 25 +++++-- src/sandbox/linux-sandbox-utils.ts | 14 +++- .../linux-unmakeable-mount-point.test.ts | 74 +++++++++++++++++++ test/sandbox/mount-point-manifests.test.ts | 17 +++++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index ab0dd0d27..8a93548d4 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -228,11 +228,13 @@ const ownManifests = new Map< >() /** - * The mount points, and the directories they bind from, of wraps that could - * record them nowhere. No other process knows of them, so they are this - * process's to remove once none of its wraps is outstanding: in a pass like - * any other where there is a directory by then, so that a path another - * process's manifest names is kept, and on its own word where there is none. + * The mount points, and the directories they bind from, that no manifest names: + * of wraps that could record them nowhere, and what a plan has made on the host + * itself until its manifest is published, which takes it out of here. No other + * process knows of them, so they are this process's to remove once none of its + * wraps is outstanding: in a pass like any other where there is a directory by + * then, so that a path another process's manifest names is kept, and on its own + * word where there is none. */ const unrecorded = { paths: new Set(), sources: new Set() } @@ -241,6 +243,14 @@ export function keepTrackOfMountSource(source: string): void { unrecorded.sources.add(source) } +/** + * The same for a mount point that a plan has made on the host itself, from then + * until a manifest names it: the plan may come to nothing before that. + */ +export function keepTrackOfMountPoint(mountPoint: string): void { + unrecorded.paths.add(mountPoint) +} + /** * Which of this process's own manifests a collect may release. Only the caller * knows that a command is over, and will not be run again: @@ -1041,7 +1051,7 @@ export type MountPointManifest = { * `undefined` when there is nothing to record or no manifest could be written. * What could not be recorded is kept in memory and removed at this process's * own clean-up, since no other process will know of it. `too-large`, with - * nothing written or kept, for one its own reader would refuse: every pass of + * nothing written or added to that, for one its own reader would refuse: every pass of * every process would turn back at it. */ export function publishMountPointManifest( @@ -1079,6 +1089,7 @@ export function publishMountPointManifest( fs.writeFileSync(temporary, text, { mode: 0o600 }) fs.renameSync(temporary, file) ownManifests.set(file, { commandKey, over: false }) + for (const mountPoint of mountPoints) unrecorded.paths.delete(mountPoint) return { dir, file, started: `${id}${STARTED_SUFFIX}` } } catch (e) { try { @@ -1297,6 +1308,8 @@ export function namedMountPoints( const dirs = onLinux() ? believedManifestDirectories() : [] if ( earlier !== undefined && + // What a plan has made itself since then is named from that moment. + [...unrecorded.paths].every(kept => earlier.paths.has(kept)) && filesIn(dirs).every(file => earlier.listed.has(file) || file === own) ) { return earlier diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 8cc8f9a4d..46e19bf98 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -13,6 +13,7 @@ import { collectMountPoints, discardMountPointManifest, holdsMountPointManifest, + keepTrackOfMountPoint, keepTrackOfMountSource, kindOfMountPoint, mountPointManifestDirectories, @@ -363,6 +364,11 @@ function makeableMountPoint( throw err } } + /** Until a manifest names it, and should the process end before one does. */ + const keepTrackOf = (made: string): void => { + keepTrackOfMountPoint(made) + registerExitCleanupHandler() + } let fd: number | undefined try { // One descriptor answers every question, through /proc: a command still @@ -394,7 +400,8 @@ function makeableMountPoint( // On a Windows drive the Windows ACL decides, and nothing on this side // tells it: every directory there is this user's, with a mode that lets it // write, also one that takes subfolders and no files. So the mount point - // is made here instead of by bubblewrap, which finds it there. + // is made here instead of by bubblewrap, which finds it there. No manifest + // names it yet, and the plan may be given up before one does. if (refusal === undefined) { const entry = `${parent}/${path.basename(dest)}` if ( @@ -402,9 +409,11 @@ function makeableMountPoint( refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === undefined ) { + keepTrackOf(dest) return MountPointKind.File } if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { + keepTrackOf(dest) return MountPointKind.Directory } } @@ -709,8 +718,9 @@ function ensureEmptyMountSourceDir(): string { // directory lands on 0500. Set the mode here rather than let the check // above reject the directory this call has just made. fs.chmodSync(emptyMountSourceDir, 0o700) - // The wrap may throw before a manifest names it. + // The wrap may throw before a manifest names it, and the process end then. keepTrackOfMountSource(emptyMountSourceDir) + registerExitCleanupHandler() return emptyMountSourceDir } diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index 509cf698e..d0d8c3d41 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -445,6 +445,80 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { expect(listing()).toEqual([]) }) + it('has what it made removed as the process ends, when all it did was a wrap that came to nothing', () => { + // No plan held, which is what used to have the clean-up at exit set up. + const absent = join(DIR, 'policy.json') + const own = join(DIR, 'own.empty') + fs.writeFileSync(own, '') + const manifests = join(BASE, 'run', 'srt-mount-points') + fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + fs.chmodSync(join(BASE, 'run'), 0o700) + const junk = join(manifests, '1-junk.json') + fs.writeFileSync(junk, '{}') + const child = spawnSync( + process.execPath, + [ + '-e', + `const fs = require('node:fs') + const statfs = fs.statfsSync + fs.statfsSync = (...args) => ({ ...statfs(...args), type: ${V9FS_MAGIC} }) + const { wrapCommandWithSandboxLinux } = await import('./test/helpers/isolated/library.ts') + const refused = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [${JSON.stringify(ROOT)}], denyWithinAllow: ${JSON.stringify([absent, own])} }, + }).then(() => undefined, error => error) + console.log(refused?.code, fs.existsSync(${JSON.stringify(absent)})) + fs.rmSync(${JSON.stringify(junk)})`, + ], + { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, XDG_RUNTIME_DIR: join(BASE, 'run') }, + }, + ) + expect(child.stdout).toBe('mount_points_unreadable true\n') + expect(listing()).toEqual(['own.empty']) + }) + + it.each(['a forced clean-up', 'the next wrap and its clean-up'])( + 'has what it made removed by %s when the plan comes to nothing', + async by => { + const absent = join(DIR, 'policy.json') + const own = join(DIR, 'own.empty') + fs.writeFileSync(own, '') + // What is no manifest puts the reading in doubt that the caller's empty + // file asks for, once the mount point has been made. + const manifests = join( + process.env['XDG_RUNTIME_DIR']!, + 'srt-mount-points', + ) + fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + fs.writeFileSync(join(manifests, '1-junk.json'), '{}') + try { + const refused = await wrap([absent, own]).then( + () => undefined, + (error: unknown) => error, + ) + expect((refused as { code?: string }).code).toBe( + 'mount_points_unreadable', + ) + } finally { + fs.rmSync(join(manifests, '1-junk.json')) + } + expect(listing()).toEqual(['own.empty', 'policy.json']) + if (by === 'a forced clean-up') { + cleanupBwrapMountPoints({ force: true }) + } else { + expect(mountsAt(await wrap([absent, own]), absent)).toEqual([ + `--ro-bind /dev/null ${absent}`, + ]) + cleanupBwrapMountPoints() + } + expect(listing()).toEqual(['own.empty']) + }, + ) + it('covers a file with a directory in a folder that takes subfolders and no files', async () => { const denied = join(DIR, 'policy.json') refuse('openSync', denied) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index b430e2d99..00dfbf1f9 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -25,6 +25,7 @@ import { collectMountPoints, discardMountPointManifest, forgetMountPointManifestDirectory, + keepTrackOfMountPoint, liveMountPoints, mountPointManifestDirectories, namedMountPoints, @@ -3020,6 +3021,22 @@ describe.if(isLinux)('The mount point manifests', () => { ) }) + it('names what a plan has made on the host itself from that moment, until a manifest names it', () => { + const first = reads(namedMountPoints()) + keepTrackOfMountPoint(X) + leftover(X) + expect([...reads(namedMountPoints(first)).paths]).toEqual([X]) + + // From then on it is the manifest's, and goes with that. + publishMountPointManifest([X], [], 'the command') + expect(collectMountPoints({ commandKey: 'the command' })).toEqual([X]) + // What the user puts there afterwards is the user's. + writeFileSync(X, '') + expect(named()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + }) + it('is one listing of the directory, and changes nothing in it', () => { leftover(X) publishMountPointManifest([X], []) From 7f00bf369b41502fe0bc3ba86067a7cfcce81074 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:24:03 +0000 Subject: [PATCH 098/141] linux: a manifest directory that a read deny hides stays hidden The read-only binds of the manifest directories come after the read denies, so a denyRead on /tmp or /var/tmp left them readable: other projects' paths, host pids. The tmpfs keeps the command out of them already. The argv helper of the tests did not know --ro-bind-try, and so saw none of these binds. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/linux-sandbox-utils.ts | 11 ++- test/helpers/bwrap-argv.ts | 3 +- test/sandbox/mount-point-liveness.test.ts | 76 ++++++++++++++++++++ test/sandbox/readonly-deny-dir-binds.test.ts | 5 +- 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 89fb71410..1e89752f0 100644 --- a/README.md +++ b/README.md @@ -1130,7 +1130,7 @@ Filesystem restrictions are enforced at the OS level: - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made before the wrap's manifest is published, so a process killed between the two leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. -- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. +- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. - **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 46e19bf98..5552094d1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3819,6 +3819,11 @@ async function generateFilesystemArgs( if (!holds) { return undefined } + // A read deny that covers a manifest directory keeps the command out of it + // already. Bound on top of that, it could be read after all: other projects' + // paths, the pids of their sandboxes. + const isInReach = (dir: string): boolean => + !isHiddenByTmpfs(dir, 'writes and reads') let started: string | undefined let onTheLine = 0 if (manifest !== undefined) { @@ -3836,7 +3841,7 @@ async function generateFilesystemArgs( // opened by a descriptor number that another wrap's file may have by then. args.splice(afterRootBind, 0, '--ro-bind', file, file) onTheLine = afterRootBind + 3 - args.push('--ro-bind', dir, dir) + if (isInReach(dir)) args.push('--ro-bind', dir, dir) } if (names.length > 0 || emptySource !== undefined) { registerExitCleanupHandler() @@ -3846,8 +3851,8 @@ async function generateFilesystemArgs( // gone by the time the command starts - a temp dir whose parent was // replaced - is not an error. for (const manifestDir of manifestDirs) { - if (manifestDir !== manifest?.dir) { - const dir = canonicalForm(manifestDir) + const dir = canonicalForm(manifestDir) + if (manifestDir !== manifest?.dir && isInReach(dir)) { args.push('--ro-bind-try', dir, dir) } } diff --git a/test/helpers/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index 2c6919f61..be73f074d 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -25,12 +25,13 @@ export const RECORD_STEP = 'srt_record() { local s && read -r s >"$1"; } && srt_record "$1" && shift' /** The mount flags this generator emits with a source and a destination. */ -const MOUNT_FLAGS = ['--bind', '--ro-bind'] +const MOUNT_FLAGS = ['--bind', '--ro-bind', '--ro-bind-try'] /** How many words follow each mount flag this generator emits. */ const MOUNT_ARITY: Record = { '--bind': 2, '--ro-bind': 2, + '--ro-bind-try': 2, '--dev-bind': 2, '--tmpfs': 1, '--dev': 1, diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index cb850c2d5..402bd7866 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -28,6 +28,7 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { countMounts, indexOfMount, + lastMountAt, manifestOf as recordedBy, RECORD_STEP, STEP_SHELL, @@ -2810,6 +2811,81 @@ describe.if(isLinux)( expect(countMounts(command, '--ro-bind', BASE, BASE)).toBe(0) }, 30000) + describe('under a read deny', () => { + let AREA: string // a write root outside HOME + let KEPT: string // where the manifests are kept, under TMP + const noRuntimeDirectory = { XDG_RUNTIME_DIR: undefined } + + beforeEach(() => { + AREA = join(BASE, 'area') + mkdirSync(AREA) + KEPT = `${TMP}/srt-mount-points-${UID}` + }) + + it.each([ + ['the one this wrap records in', (): string[] => [join(AREA, 'lock')]], + ['one it only keeps out of reach', (): string[] => []], + ] as const)( + 'are not bound on top of it, where they could be read after all: %s', + (_, denyWithinAllow) => { + const command = wrapped( + { + readConfig: { denyOnly: [TMP] }, + writeConfig: { + allowOnly: [AREA], + denyWithinAllow: denyWithinAllow(), + }, + }, + noRuntimeDirectory, + ) + expect(lastMountAt(command, TMP)).toBe(`--tmpfs ${TMP}`) + expect(lastMountAt(command, KEPT)).toBeUndefined() + }, + 30000, + ) + + it('are bound where a write root brings them back into reach', () => { + const command = wrapped( + { + readConfig: { denyOnly: [HOME] }, + writeConfig: { allowOnly: [TMP], denyWithinAllow: [] }, + }, + noRuntimeDirectory, + ) + expect(lastMountAt(command, KEPT)).toBe(`--ro-bind-try ${KEPT} ${KEPT}`) + }, 30000) + + it.if(BWRAP_CAN_NAMESPACE)( + 'show a command nothing of the manifests, its own included', + () => { + // Run by the process that wrapped it, whose manifest goes with it. + const child = inAChild( + 'run.ts', + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const command = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(`echo started; ls -A ${TMP}`)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [${JSON.stringify(TMP)}] },`, + ` writeConfig: {`, + ` allowOnly: [${JSON.stringify(AREA)}],`, + ` denyWithinAllow: [${JSON.stringify(join(AREA, 'lock'))}],`, + ` },`, + `})`, + `const run = spawnSync(command, { shell: true, encoding: 'utf8' })`, + `console.log(JSON.stringify([command, run.stdout + run.stderr]))`, + ], + noRuntimeDirectory, + ) + const [command, said] = JSON.parse(child.stdout) as [string, string] + expect(dirname(recordedBy(command)!)).toBe(KEPT) + expect(said).toBe('started\n') + }, + 30000, + ) + }) + it('are pinned above the manifest directory under the runtime directory', () => { const RUN = join(HOME, 'run', 'user') mkdirSync(RUN, { recursive: true, mode: 0o700 }) diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index 87cb7eb08..0e6fe6d2a 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -1481,7 +1481,10 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { ] it('puts every one of them back after the bind, the masks last', async () => { - const mounts = mountsOf(await wrapAll([PROJ])) + // But for the manifest directories, which every wrap binds. + const mounts = mountsOf(await wrapAll([PROJ])).filter( + mount => !mount.startsWith('--ro-bind-try '), + ) expect( mounts.slice(mounts.indexOf(`--ro-bind ${PROJ} ${PROJ}`)), From d805bf7a4a9332d4ad73b52104cf4aa14f81b249 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:25:33 +0000 Subject: [PATCH 099/141] test: four more child processes keep out of the user's own manifest directories They loaded the library straight from src or dist, so they made, read and collected in /var/tmp/srt-mount-points- and its like. The helper that gives a suite a directory of its own no longer counts on the preload either. The end-to-end suite that runs in a container goes on using the real places. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/helpers/private-manifest-directory.ts | 1 + test/sandbox/default-write-paths.test.ts | 9 ++++++--- test/sandbox/linux-unmakeable-mount-point.test.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index c0c1dccca..7357e9341 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' +import './isolated/places.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' /** Field 22 of a /proc/PID/stat line: when that process started. */ diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index 94ea6f883..b54c4059e 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -123,7 +123,10 @@ describe.if(!isWindows)('getDefaultWritePaths', () => { runtime, [ '-e', - `import('./dist/index.js').then(async ({ SandboxManager }) => { + `import('./dist/sandbox/bwrap-mount-manifests.js').then(async places => { + // As test/helpers/isolated does, for the built library. + places.setMountPointManifestPlacesForTesting('/nonexistent') + const { SandboxManager } = await import('./dist/index.js') await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, filesystem: { @@ -261,7 +264,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const wrapped = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') console.log(JSON.stringify(await SandboxManager.wrapWithSandbox('true', undefined, { filesystem: { denyRead: [], allowWrite: [], denyWrite: ['~/.npm'] }, })))`, @@ -360,7 +363,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(home, NPM_LOGS), { recursive: true }) return runWithHome( home, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') const fs = await import('node:fs') const home = process.env.HOME const logs = home + '/${NPM_LOGS}' diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index d0d8c3d41..4c49e4482 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -780,7 +780,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { () => { const denied = join(DIR, 'policy.json') const script = ` - const { wrapCommandWithSandboxLinux } = await import('./src/sandbox/linux-sandbox-utils.ts') + const { wrapCommandWithSandboxLinux } = await import('./test/helpers/isolated/library.ts') const { spawnSync } = await import('node:child_process') const command = await wrapCommandWithSandboxLinux({ command: 'echo BOOTED; echo x > ${denied}; echo x > ${join(ROOT, 'new')}', From e147a732767479c457db92c2b360388e63cd3066 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:26:36 +0000 Subject: [PATCH 100/141] linux: no command is handed out while a manifest directory cannot be looked at An error of the moment from lstat was taken for "not there", and the directory was neither bound nor pinned. The readings, which throw on such an error, believed it again a moment later. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 4 +-- src/sandbox/bwrap-mount-manifests.ts | 24 ++++++++--------- src/sandbox/linux-sandbox-utils.ts | 30 ++++++++++++++-------- test/sandbox/mount-point-manifests.test.ts | 27 +++++++++++++++++++ 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1e89752f0..8238a1ebd 100644 --- a/README.md +++ b/README.md @@ -1130,7 +1130,7 @@ Filesystem restrictions are enforced at the OS level: - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made before the wrap's manifest is published, so a process killed between the two leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. -- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. +- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. While one of them cannot be looked at (an error of the moment, not "not there"), no command that restricts writes is handed out (`mount_points_unreadable`): left unbound, it would be believed again a moment later. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. - **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** @@ -1154,7 +1154,7 @@ Filesystem restrictions are enforced at the OS level: - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier: when the command's own `commandId` is passed, and at any call made once a run of it has ended). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. Until then it may be run again and again; each run adds its line to the record, and every run on it is asked after. A record past 4 MiB, some fourteen thousand runs, vouches like any that cannot be read: what its manifest names then stays until the next boot. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them, or another process's cleanup is held up half way and what it set out to remove may go at any moment (`.cause` is its record, whose name begins with its pid); wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess; or one of their directories could not be looked at, so it cannot be kept out of the command's reach. It refuses every wrap of that user that restricts writes, and ends by itself, but for a file system that has gone from under `TMPDIR` or `XDG_RUNTIME_DIR` and stays gone: this process is out of descriptors or memory, until it is not; a file system gives an error, until it answers again; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the reason, and the file where there is one. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them, or another process's cleanup is held up half way and what it set out to remove may go at any moment (`.cause` is its record, whose name begins with its pid); wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 8a93548d4..a12b58e52 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -666,16 +666,6 @@ function ensureManifestDirectory(): string | undefined { } } -/** Whether `dir` is a real directory, not a link to one, and the user's. */ -function isOurDirectory(dir: string): boolean { - try { - const stat = fs.lstatSync(dir) - return stat.isDirectory() && stat.uid === process.getuid?.() - } catch { - return false - } -} - /** * Every directory a wrap must bind read-only because some process of this user * keeps manifests in it and a collect believes what it finds there: every @@ -692,23 +682,29 @@ function isOurDirectory(dir: string): boolean { * can remove or rename it. * * With `recording`, the directory this wrap's manifest will go to is settled - * first, so the wrap can pin what lies above it before it publishes. + * first, so the wrap can pin what lies above it before it publishes. Throws + * where it cannot tell whether one of them is there. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { return [] } const dirs = new Set() - for (const candidate of sharedManifestDirectoryNames()) { + // A real directory, not a link to one, and the user's. An error of the moment + // is not "not there": left unbound on that, it is believed again a moment + // later, by this process and by every other. + const isToBeBound = (dir: string): boolean => + isOurPrivateDirectory(dir, true, false, false) + for (const candidate of sharedManifestDirectoryNames(true)) { makeOurPrivateDirectory(candidate) - if (isOurDirectory(candidate)) { + if (isToBeBound(candidate)) { dirs.add(candidate) } } if (recording) { ensureManifestDirectory() } - if (manifestDirectory !== undefined && isOurDirectory(manifestDirectory)) { + if (manifestDirectory !== undefined && isToBeBound(manifestDirectory)) { dirs.add(manifestDirectory) } return [...dirs] diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 5552094d1..ee9f91c5f 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -998,10 +998,12 @@ export type LinuxSandboxProfileErrorCode = | 'too_many_mount_points' /** * The mount point manifests cannot be read, so a path cannot be told from a - * mount point a running sandbox relies on. It ends by itself: when this - * process has descriptors again, when what sits among them and is no manifest - * is an hour old, when the process on a record with no manifest has ended. - * `.cause` is the file and the reason, which the message holds too. + * mount point a running sandbox relies on; or one of their directories cannot + * be looked at, so it cannot be kept out of the command's reach. It ends by + * itself: when this process has descriptors again, when the file system + * answers again, when what sits among them and is no manifest is an hour old, + * when the process on a record with no manifest has ended. `.cause` is the + * reason, with the file where there is one, which the message holds too. */ | 'mount_points_unreadable' /** @@ -3524,12 +3526,20 @@ async function generateFilesystemArgs( // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. - const manifestDirs = - writeConfig !== undefined - ? mountPointManifestDirectories( - mountPoints.length > 0 || emptySource !== undefined, - ) - : [] + let manifestDirs: string[] = [] + if (writeConfig !== undefined) { + try { + manifestDirs = mountPointManifestDirectories( + mountPoints.length > 0 || emptySource !== undefined, + ) + } catch (err) { + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, + errorText(err), + ) + } + } const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 00dfbf1f9..ab320f41b 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -33,6 +33,7 @@ import { publishMountPointManifest, setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' import { @@ -2475,6 +2476,32 @@ describe.if(isLinux)('The mount point manifests', () => { expect(collectMountPoints().sort()).toEqual([X, Y].sort()) }) + it('are not left unbound when one of them cannot be looked at: no command is handed out', async () => { + // One that this process binds and does not believe, so that no reading + // of its own is in doubt: a process that can write none of the first + // three believes it, with the command in it. + mountPointManifestDirectories() + const look = failingCall('lstatSync', NAMED[1]!, 'EIO') + try { + expect(named()).toEqual([]) + expect(() => mountPointManifestDirectories()).toThrow('EIO') + const refused = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [BASE], denyWithinAllow: [] }, + }).then( + () => undefined, + (error: unknown) => error, + ) + expect((refused as { code?: string }).code).toBe( + 'mount_points_unreadable', + ) + } finally { + look.restore() + } + expect(mountPointManifestDirectories()).toContain(NAMED[1]!) + }) + it('reads every directory again once it has claimed, not only where it claimed', () => { // Published in another directory since the first reading, and claimed // there by another pass that its sandbox got past. From 2491361380ec8da87f5bfdb7d3e4b0c3fbefe54b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:30:15 +0000 Subject: [PATCH 101/141] linux: a clean-up times itself by both clocks, from before its record, and looks last Its two seconds ran on the monotonic clock, which stands still while the host is suspended, from after its record was made, and were looked at before the listing that precedes a removal. Whoever cannot ask after it goes by the wall-clock age of the record. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/bwrap-mount-manifests.ts | 39 ++++++--- test/sandbox/mount-point-passes.test.ts | 110 ++++++++++++++++++------ 3 files changed, 114 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8238a1ebd..d48252c4b 100644 --- a/README.md +++ b/README.md @@ -1138,7 +1138,7 @@ Filesystem restrictions are enforced at the OS level: - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/var/tmp` or `/tmp` can delete and forge manifests there. A process that can write in none of the first three places keeps its manifests where its environment says, which the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where one of the names, or a directory above it, is a symbolic link of the user's that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where the parent of a name does not exist, and the user may write the directory it is missing from, and that lies inside a write root (a process that runs as root and has no `/run/user/0`, with `/` or `/run` writable; an `XDG_RUNTIME_DIR` or `TMPDIR` that is not there), the wrap cannot make the manifest directory and a sandboxed command can, parent and all: every process that looks there afterwards believes it. Where `/run/user/` itself is a write root the command can open it to others; every process goes on reading what is under it, but another local user can then move that manifest directory aside. What a cleanup removes on such a manifest's word is bounded as above. - _A `/var/tmp/srt-mount-points-` that another user made._ Another local user can make that name, or the one in `/tmp`, before the user's first wrap. It fails the owner check and is passed over, never repaired, and the user's processes write in the next place. - - _A cleanup that cannot be asked after._ A wrap waits for a cleanup for as long as that one's process runs, which it can ask only of a process in its own PID namespace. The record of any other (a container that shares `/var/tmp` with the host) counts for a minute, and a cleanup removes nothing more once it has been on record for two seconds. So a cleanup in another PID namespace that is held up for over a minute in the moment between its look at the clock and the removal after it still removes a path from under a sandbox that has started since. Within one PID namespace there is no such moment. + - _A cleanup that cannot be asked after._ A wrap waits for a cleanup for as long as that one's process runs, which it can ask only of a process in its own PID namespace. The record of any other (a container that shares `/var/tmp` with the host) counts for a minute, and a cleanup removes nothing more two seconds after it set out to go on record, by the wall clock as well as by the one that stands still while the host is suspended. So a cleanup in another PID namespace that is held up for over a minute in the moment between its look at the clock and the removal after it still removes a path from under a sandbox that has started since. Within one PID namespace there is no such moment. - _A cleanup that is stopped._ While a process is stopped half way through a cleanup, which takes milliseconds, every wrap of that user that names a mount point waits its five seconds and is refused. The error names the record, and that begins with the pid: continue or end that process. - _A directory made again._ A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.81 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a container made anew with the project on a volume, a cleaner of `/var/tmp`, and, for a process that had to write in a later place, the last logout (`/run/user/` is a tmpfs that logind removes) or a reboot. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index a12b58e52..5a8346957 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -136,11 +136,21 @@ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 const OTHER_NAMESPACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 /** - * A pass removes nothing more once it has been on record for this long, and + * A pass removes nothing more this long after it set out to go on record, and * leaves the rest to the next: an ordinary one takes milliseconds. */ const PASS_GOOD_FOR_MS = 2 * 1000 +/** + * Both clocks. Whoever cannot ask after a pass goes by the age of its record, + * which is wall-clock time; the other stands still while the host is suspended + * (a laptop asleep, a virtual machine paused), and this one can be set back. + */ +const bothClocks = (): readonly [number, number] => [ + performance.now(), + Date.now(), +] + /** * A pass that cannot be asked after (another PID namespace's) counts as under * way until its record is this old: well past {@link PASS_GOOD_FOR_MS}. @@ -1715,6 +1725,8 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { return [] } const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) + // From before its record is made, whose age others go by. + const since = bothClocks() try { // Off the record it removes nothing on a manifest's word. What is left to // remove is what this process could record nowhere, which no wrap elsewhere @@ -1724,6 +1736,7 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { putPassOnRecord(writable) ? writable : [], own, unrecordedToo, + since, ) } finally { takePassOffRecord() @@ -1731,16 +1744,17 @@ function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { } /** - * The rest of {@link collect}, for as long as the pass is on record: `own` are - * the claims it has made, `writable` the directories it can claim in. + * The rest of {@link collect}, for as long as the pass is on record, which it + * has been `since`: `own` are the claims it has made, `writable` the directories + * it can claim in. */ function removeWhatIsFinished( dirs: readonly string[], writable: readonly string[], own: ReadonlySet, unrecordedToo: boolean, + since: readonly [number, number], ): string[] { - const since = performance.now() const reading = readManifests(dirs) if (reading.inDoubt !== undefined) { logForDebugging( @@ -1827,14 +1841,6 @@ function removeWhatIsFinished( const removed: string[] = [] const notRemoved = new Set() for (const candidate of childrenFirst(candidates.keys())) { - // To a process that cannot ask after this one the pass is over by its age. - if (performance.now() - since > PASS_GOOD_FOR_MS) { - logForDebugging( - '[Sandbox Linux] The clean-up of mount points has taken too long - leaving the rest to the next', - { level: 'warn' }, - ) - return removed - } if ( performance.now() - listedAt >= goodFor && !newcomersKeepWhatTheyName() @@ -1844,6 +1850,15 @@ function removeWhatIsFinished( if (kept.has(candidate)) { continue } + // To a process that cannot ask after this one the pass is over by its age. + // The last thing before a removal, after all that may have taken time. + if (bothClocks().some((now, i) => now - since[i]! > PASS_GOOD_FOR_MS)) { + logForDebugging( + '[Sandbox Linux] The clean-up of mount points has taken too long - leaving the rest to the next', + { level: 'warn' }, + ) + return removed + } const outcome = candidates.get(candidate) ? removeMountSource(candidate) : removeMountPoint(candidate) diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 6c8a113d6..3a6d07d38 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -386,31 +386,93 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(passes()).toEqual([]) }) - it('leaves the rest to the next pass once it has been on record for two seconds', () => { - const second = join(AREA, 'second.lock') - leftover(LOCK) - leftover(second) - manifestOfADeadProcess([LOCK, second]) - const unlink = fs.unlinkSync - const now = performance.now.bind(performance) - let late = 0 - const spies = [ - spyOn(performance, 'now').mockImplementation(() => now() + late), - spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { - unlink(file) - if (file === second) late = 2_001 - }) as never), - ] - try { - // Children first, which among siblings is the later name first. - expect(collectMountPoints()).toEqual([second]) - } finally { - spies.forEach(spy => spy.mockRestore()) + /** Has that clock go on by `ms` whenever `heldUpFor` is called. */ + function aClockThatIsHeldUp(clock: typeof performance | typeof Date): { + heldUpFor(ms: number): void + mockRestore(): void + } { + const now = clock.now.bind(clock) + let lost = 0 + const spy = spyOn(clock, 'now').mockImplementation(() => now() + lost) + return { + heldUpFor: ms => { + lost += ms + }, + mockRestore: () => spy.mockRestore(), } - expect(existsSync(LOCK)).toBe(true) - expect(passes()).toEqual([]) - expect(collectMountPoints()).toEqual([LOCK]) - }) + } + // The wall clock alone goes on while the host is suspended, and others go by + // it; it alone can be set back. + const CLOCKS = [ + ['the monotonic clock', (): typeof performance => performance], + ['the wall clock', (): typeof Date => Date], + ] as const + + it.each(CLOCKS)( + 'leaves the rest to the next pass once it has been on record for two seconds, by %s', + (_, which) => { + const second = join(AREA, 'second.lock') + leftover(LOCK) + leftover(second) + manifestOfADeadProcess([LOCK, second]) + const unlink = fs.unlinkSync + const clock = aClockThatIsHeldUp(which()) + const spy = spyOn(fs, 'unlinkSync').mockImplementation((( + file: string, + ) => { + unlink(file) + if (file === second) clock.heldUpFor(2_001) + }) as never) + try { + // Children first, which among siblings is the later name first. + expect(collectMountPoints()).toEqual([second]) + } finally { + spy.mockRestore() + clock.mockRestore() + } + expect(existsSync(LOCK)).toBe(true) + expect(passes()).toEqual([]) + expect(collectMountPoints()).toEqual([LOCK]) + }, + ) + + it.each(CLOCKS)( + 'counts the time it takes to go on record, and to list the directories before a removal, by %s', + (_, which) => { + for (const heldUpIn of ['openSync', 'readdirSync'] as const) { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const clock = aClockThatIsHeldUp(which()) + const real = fs[heldUpIn] as (...args: unknown[]) => unknown + let listings = 0 + const spy = spyOn(fs, heldUpIn).mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + const result = real(file, ...rest) + if ( + heldUpIn === 'openSync' + ? String(file).endsWith('.pass') + : // The reading it decides on, then the one before the removal. + file === DIR && + (result as string[]).some(name => name.endsWith('.pass')) && + ++listings === 2 + ) { + clock.heldUpFor(2_001) + } + return result + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + clock.mockRestore() + } + if (heldUpIn === 'readdirSync') expect(listings).toBe(2) + expect(collectMountPoints()).toEqual([LOCK]) + } + }, + ) // ---- which passes are under way -------------------------------------------- From 46657e8a44803ca7b171cb19c25b3dcdec084104 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:32:49 +0000 Subject: [PATCH 102/141] test: drop cases that assert nothing or what another asserts, and fold two pairs One spied on Atomics.wait, which the source has not called since the lock went. Eight repeated a case beside them or in another suite. Two pairs that differed in one value are tables now. The errno matrix keeps one that lasts and one that passes, which is all the code tells apart. Three wall-clock assertions added nothing to the kill after six seconds. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-liveness.test.ts | 103 ++++--------------- test/sandbox/mount-point-manifests.test.ts | 110 +++------------------ test/sandbox/mount-point-passes.test.ts | 17 ---- 3 files changed, 37 insertions(+), 193 deletions(-) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 402bd7866..9f1a397e2 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -404,68 +404,34 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) - it.if(BWRAP_CAN_NAMESPACE)( - 'cannot be disowned from inside the sandbox: the manifests are read-only there', - async () => { + it.if(BWRAP_CAN_NAMESPACE).each([ + ['names a manifest of its own', true], + // The manifests in the directory are then other sandboxes'. + ['names none, having no absent deny path', false], + ] as const)( + 'cannot be disowned from inside a sandbox that %s: the manifests are read-only there', + async (_, namesOne) => { // This caller allows writes to the manifest directory itself, so only - // the read-only bind the wrap emits last keeps the command out of it. - const directory = dirname(manifestOf(await wrap('true'))) - const before = readdirSync(directory).filter(n => n.endsWith('.json')) - expect(before.length).toBeGreaterThan(0) - - const attack = [ - `rm -f ${directory}/*.json; echo rm=$?`, - `echo mine > ${directory}/planted.json; echo plant=$?`, - `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, - ].join('; ') - const result = spawnSync( - await wrapCommandWithSandboxLinux({ - command: attack, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { - allowOnly: [AREA, directory], - denyWithinAllow: [LOCK], - }, - }), - { shell: true, encoding: 'utf8', timeout: 60000 }, - ) - const said = `${result.stdout}${result.stderr}` - expect(said).toMatch(/rm=[1-9]/) - expect(said).toMatch(/plant=[1-9]/) - expect(said).toMatch(/rewrite=[1-9]/) - expect(existsSync(join(directory, 'planted.json'))).toBe(false) - for (const name of before) { - expect(existsSync(join(directory, name))).toBe(true) - expect(readFileSync(join(directory, name), 'utf8')).toContain('"paths"') - } - }, - 90000, - ) - - it.if(BWRAP_CAN_NAMESPACE)( - 'are read-only in a sandbox that names no manifest of its own', - async () => { - // A wrap with no absent deny path has no manifest, and the manifests in - // the directory are then other sandboxes'. A collect on the host believes - // them, so the command must be able neither to write nor to delete one. + // the read-only bind the wrap emits last keeps the command out of it. A + // collect on the host believes what is in there. const directory = dirname(manifestOf(await wrap('true'))) const before = readdirSync(directory).filter(n => n.endsWith('.json')) expect(before.length).toBeGreaterThan(0) - const attack = [ - `rm -f ${directory}/*.json; echo rm=$?`, - `echo mine > ${directory}/planted.json; echo plant=$?`, - `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, - ].join('; ') const command = await wrapCommandWithSandboxLinux({ - command: attack, + command: [ + `rm -f ${directory}/*.json; echo rm=$?`, + `echo mine > ${directory}/planted.json; echo plant=$?`, + `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, + ].join('; '), needsNetworkRestriction: false, readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA, directory], denyWithinAllow: [] }, + writeConfig: { + allowOnly: [AREA, directory], + denyWithinAllow: namesOne ? [LOCK] : [], + }, }) - // The point of the case: this wrap relies on no mount point. - expect(recordedBy(command)).toBeUndefined() + expect(recordedBy(command) !== undefined).toBe(namesOne) const result = spawnSync(command, { shell: true, @@ -860,8 +826,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // ---- two commands of one process in flight ---- // - // A call to clean up cannot tell which command it is for, so while a wrap is - // outstanding nothing this process made may go: the command that has not + // A call to clean up that does not say which command it is for lets go of + // those that have run and ended, and of no other: the command that has not // started still needs its manifest and its mount point. it.if(BWRAP_CAN_NAMESPACE)( @@ -1270,33 +1236,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(recordedBy(second)).toBeUndefined() }) - it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { - // Absent when the wrap asked for its shape, there when it asked whether - // anything is: another sandbox started on the same path in between. - const exists = fs.existsSync - let made = false - const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { - if (file === LOCK && !made) { - made = true - expect(exists(LOCK)).toBe(false) - manifestNaming([LOCK], process.pid) - writeFileSync(LOCK, '') - chmodSync(LOCK, 0o444) - } - return exists(file as string) - }) as never) - let command: string - try { - command = await wrap('true') - } finally { - spy.mockRestore() - } - expect(made).toBe(true) - expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) - expect(command).not.toContain(`--ro-bind ${LOCK} ${LOCK}`) - expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) - }) - it("is named by a wrap that took it for the caller's own file, another sandbox having made it again meanwhile", async () => { // No manifest named it when the wrap read them: the one that had was // collected. Not named, it would go with the sandbox that made it again. diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index ab320f41b..fcf212b1c 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -220,7 +220,7 @@ describe.if(isLinux)('The mount point manifests', () => { env?: Record first?: string } = {}, - ): { status: number | null; ms: number; stdout: string } { + ): { status: number | null; stdout: string } { const script = join(BASE, `child-${Math.random().toString(16).slice(2)}.ts`) writeFileSync( script, @@ -237,19 +237,17 @@ describe.if(isLinux)('The mount point manifests', () => { for (const [name, value] of Object.entries(env)) { if (value === undefined) delete env[name] } - const began = Date.now() const run = spawnSync(argv[0]!, argv.slice(1), { env: env as Record, encoding: 'utf8', + // A read that blocks is killed, and has no status. timeout: 6000, killSignal: 'SIGKILL', }) - return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + return { status: run.status, stdout: run.stdout } } const COLLECT = `m.collectMountPoints()` - // Well under the time a read that blocks would hold a child up for. - const AT_ONCE_MS = 1500 const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( @@ -360,9 +358,12 @@ describe.if(isLinux)('The mount point manifests', () => { ) }) - it.if(NOT_ROOT)( - 'keeps the manifest of a mount point it could not remove, for a pass that can', - () => { + it.if(NOT_ROOT).each([ + ["another process's", (p: string): string => manifestOfADeadProcess([p])], + ['its own', (p: string): string => publish([p])], + ] as const)( + 'keeps the manifest of a mount point it could not remove, for a pass that can: %s', + (_, manifestNaming) => { // Seen read-only from here, as from inside a sandbox that may not write // the directory it is in. The manifest is all that says the file is a // mount point, so it must stay while the file does. @@ -370,10 +371,10 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(readOnly) const inside = join(readOnly, 'config.lock') leftover(inside) - const manifest = manifestOfADeadProcess([inside]) + const manifest = manifestNaming(inside) chmodSync(readOnly, 0o555) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(inside)).toBe(true) // Under its claim, which any later pass takes up. expect(claims()).toEqual([finishedOf(manifest)]) @@ -381,7 +382,8 @@ describe.if(isLinux)('The mount point manifests', () => { } finally { chmodSync(readOnly, 0o755) } - expect(collectMountPoints()).toEqual([inside]) + // Whatever the next pass is told to release. + expect(collectMountPoints('none')).toEqual([inside]) expect(readdirSync(DIR)).toEqual([]) }, ) @@ -392,28 +394,6 @@ describe.if(isLinux)('The mount point manifests', () => { // removed what it names: after a pass that turned back, or a removal that was // refused, a later pass still has it to go by. - it.if(NOT_ROOT)( - 'keeps the manifest of a wrap of its own whose mount point it could not remove, for a pass that can', - () => { - const readOnly = join(BASE, 'read-only-from-here') - mkdirSync(readOnly) - const inside = join(readOnly, 'config.lock') - leftover(inside) - const manifest = publish([inside]) - chmodSync(readOnly, 0o555) - try { - expect(collectMountPoints('all')).toEqual([]) - expect(existsSync(inside)).toBe(true) - expect(claims()).toEqual([finishedOf(manifest)]) - } finally { - chmodSync(readOnly, 0o755) - } - // Whatever the next pass is told to release. - expect(collectMountPoints('none')).toEqual([inside]) - expect(readdirSync(DIR)).toEqual([]) - }, - ) - it('keeps the manifest of a wrap of its own when the pass turns back half way, for the next', () => { leftover(X) const manifest = publish([X]) @@ -859,28 +839,6 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(Y)).toBe(true) }) - it('leaves a claim on a manifest written in another PID namespace', () => { - leftover(X) - const claim = claimed(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) - expect(collectMountPoints()).toEqual([]) - expect(existsSync(claim)).toBe(true) - expect(existsSync(X)).toBe(true) - }) - - it('never blocks the thread it runs on', () => { - // A clean-up runs on its caller's one thread, after every command. - leftover(X) - claimed(manifestOfADeadProcess([X])) - manifestOfADeadProcess([X]) - const wait = spyOn(Atomics, 'wait') - try { - expect(collectMountPoints()).toEqual([X]) - expect(wait).not.toHaveBeenCalled() - } finally { - wait.mockRestore() - } - }) - // ---- what cannot be told is live ---- // // Only a process that is gone says its sandbox has ended. A sandboxed command @@ -937,7 +895,9 @@ describe.if(isLinux)('The mount point manifests', () => { const NOTHING_REMOVED = { removed: [], held: [expect.any(String)] } - for (const code of ['EACCES', 'EMFILE', 'ENFILE', 'ENOMEM', 'EIO']) { + // One that lasts and one that passes: the code takes every errno but "not + // there" alike. + for (const code of ['EACCES', 'EMFILE']) { it(`removes nothing when it cannot ask after the process on a record: ${code}`, () => { leftover(X) const gone = { pid: deadPid(), start: '1' } @@ -1051,7 +1011,6 @@ describe.if(isLinux)('The mount point manifests', () => { `console.log(JSON.stringify({ held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, ) expect(looked.status).toBe(0) - expect(looked.ms).toBeLessThan(AT_ONCE_MS) expect(JSON.parse(looked.stdout)).toEqual({ held: [X], removed: [] }) expect(existsSync(manifest)).toBe(true) }, 15000) @@ -1154,7 +1113,6 @@ describe.if(isLinux)('The mount point manifests', () => { `console.log(JSON.stringify({ named: [...m.namedMountPoints().paths], held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, ) expect(looked.status).toBe(0) - expect(looked.ms).toBeLessThan(AT_ONCE_MS) expect(JSON.parse(looked.stdout)).toEqual({ named: [X], held: [], @@ -1543,18 +1501,6 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) - it('is live when written in another PID namespace, whatever process is on its record', () => { - // The pid on the record was given in that namespace too. - leftover(X) - const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { - pid: deadPid(), - start: '1', - }) - expect(collectMountPoints()).toEqual([]) - expect(existsSync(X)).toBe(true) - expect(existsSync(manifest)).toBe(true) - }) - it('is live for seven days when written in another PID namespace, or in none it records, and finished then', () => { // Nothing else ends one whose namespace is gone. Counted from the later of // the manifest and its record, which is when a sandbox last started. @@ -1643,13 +1589,6 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(DIR)).toEqual([basename(claim)]) }) - it('does not say so of a claim with a process on its record either', () => { - leftover(X) - const claim = claimed(started(manifestOfADeadProcess([X]), thisProcess())) - collectMountPoints() - expect(claims()).toEqual([basename(claim)]) - }) - it('says so of a claim another pass left, and collects it', () => { leftover(X) const manifest = manifestOfADeadProcess([X]) @@ -2792,7 +2731,6 @@ describe.if(isLinux)('The mount point manifests', () => { ].join('\n'), ) expect(looked.status).toBe(0) - expect(looked.ms).toBeLessThan(AT_ONCE_MS) return JSON.parse(looked.stdout) } // It names nothing that can be read, and may name anything. @@ -3137,22 +3075,6 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(run)).toEqual([]) }) - it('holds nothing, and makes nothing, where the platform is not Linux', () => { - leftover(X) - publishMountPointManifest([X], []) - const before = readdirSync(DIR).sort() - const elsewhere = inAChild( - `console.log(JSON.stringify([...m.liveMountPoints()]))`, - { - first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, - }, - ) - expect(elsewhere.status).toBe(0) - expect(JSON.parse(elsewhere.stdout)).toEqual([]) - expect(readdirSync(DIR).sort()).toEqual(before) - expect(readdirSync(TMP)).toEqual([]) - }) - // ---- all of it is bubblewrap's, which is Linux's ----------------------- it('does nothing at all where the platform is not Linux', () => { diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 3a6d07d38..bbc10a7e8 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -886,17 +886,6 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { 60000, ) - it('keeps what a command that has run relied on while the process that wrapped it runs, to every other process', () => { - leftover(LOCK) - const manifest = started(manifestOfADeadProcess([LOCK], anotherProcess()), { - pid: deadPid(), - start: '1', - }) - expect([...(liveMountPoints() ?? [])]).toEqual([LOCK]) - expect(collectMountPoints()).toEqual([]) - expect(existsSync(manifest)).toBe(true) - }) - it.each(['first', 'last'] as const)( 'asks after every run of a command line that has been run thousands of times: the %s may be the one that lasts', which => { @@ -929,12 +918,6 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(existsSync(LOCK)).toBe(true) }) - it('takes it once that process is gone', () => { - leftover(LOCK) - started(manifestOfADeadProcess([LOCK]), { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([LOCK]) - }) - /** The manifest this process publishes for a wrap that names `paths`. */ function publish(paths: string[], commandKey?: string): string { const manifest = publishMountPointManifest(paths, [], commandKey) From cd437dfa2b9ed79230083027c87c44e4d20aa942 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:34:07 +0000 Subject: [PATCH 103/141] linux: a flag that was never read as set, an explicit release, named options directoryFailureLogged could not be true where it was tested. collectMountPoints defaulted to releasing every manifest of the process, for the tests' sake; no caller in src relied on it. isOurPrivateDirectory took three booleans in a row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 33 ++-- test/sandbox/mount-point-manifests.test.ts | 192 +++++++++++---------- test/sandbox/mount-point-passes.test.ts | 48 +++--- 3 files changed, 135 insertions(+), 138 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 5a8346957..5dd194713 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -285,7 +285,6 @@ export type OwnManifestRelease = let manifestDirectory: string | undefined // Whether that is the last resort below, which no other process knows of. let manifestDirectoryIsPrivate = false -let directoryFailureLogged = false // Set once the last resort has been tried and found wanting, so a process on a // temp dir that keeps no modes does not make a fresh directory on every call. let manifestDirectoryUnavailable = false @@ -437,9 +436,7 @@ function olderThan(file: string, ms: number, absent = false): boolean { */ function isOurPrivateDirectory( dir: string, - orThrow = false, - toWrite = true, - alone = true, + { orThrow = false, toWrite = true, alone = true } = {}, ): boolean { try { const stat = fs.lstatSync(dir) @@ -543,7 +540,11 @@ function fixedManifestDirectoryNames( const runtimeDir = path.join(root, 'run/user', String(uid)) return [ path.join(root, 'var/tmp', `srt-mount-points-${uid}`), - ...(isOurPrivateDirectory(runtimeDir, orThrow, false, toWrite) + ...(isOurPrivateDirectory(runtimeDir, { + orThrow, + toWrite: false, + alone: toWrite, + }) ? [path.join(runtimeDir, 'srt-mount-points')] : []), path.join(root, 'tmp', `srt-mount-points-${uid}`), @@ -587,14 +588,14 @@ function sharedManifestDirectoryNames( */ function believedManifestDirectories(): string[] { const names = fixedManifestDirectoryNames(true, true).some(dir => - isOurPrivateDirectory(dir, true), + isOurPrivateDirectory(dir, { orThrow: true }), ) ? fixedManifestDirectoryNames(true) : sharedManifestDirectoryNames(true) if (manifestDirectory !== undefined) names.push(manifestDirectory) const byIdentity = new Map() for (const dir of names) { - if (!isOurPrivateDirectory(dir, true, false)) continue + if (!isOurPrivateDirectory(dir, { orThrow: true, toWrite: false })) continue let identity: string try { // As big integers: a number does not hold every inode number apart. @@ -665,13 +666,10 @@ function ensureManifestDirectory(): string | undefined { return private_ } catch (e) { manifestDirectoryUnavailable = true - if (!directoryFailureLogged) { - directoryFailureLogged = true - logForDebugging( - `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are kept in memory and removed at its own clean-up`, - { level: 'warn' }, - ) - } + logForDebugging( + `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are kept in memory and removed at its own clean-up`, + { level: 'warn' }, + ) return undefined } } @@ -704,7 +702,7 @@ export function mountPointManifestDirectories(recording = false): string[] { // is not "not there": left unbound on that, it is believed again a moment // later, by this process and by every other. const isToBeBound = (dir: string): boolean => - isOurPrivateDirectory(dir, true, false, false) + isOurPrivateDirectory(dir, { orThrow: true, toWrite: false, alone: false }) for (const candidate of sharedManifestDirectoryNames(true)) { makeOurPrivateDirectory(candidate) if (isToBeBound(candidate)) { @@ -1567,9 +1565,7 @@ export function holdsMountPointManifest(file: string): boolean { * at any time, any number of times. Returns the mount points removed. Does * nothing anywhere but on Linux. */ -export function collectMountPoints( - release: OwnManifestRelease = 'all', -): string[] { +export function collectMountPoints(release: OwnManifestRelease): string[] { if (!onLinux()) { return [] } @@ -1916,5 +1912,4 @@ export function forgetMountPointManifestDirectory(): void { manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false - directoryFailureLogged = false } diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index fcf212b1c..e5910cd04 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -83,7 +83,7 @@ describe.if(isLinux)('The mount point manifests', () => { } rmSync(file, { recursive: true, force: true }) } - collectMountPoints() + collectMountPoints('all') rmSync(BASE, { recursive: true, force: true }) }) @@ -247,7 +247,7 @@ describe.if(isLinux)('The mount point manifests', () => { return { status: run.status, stdout: run.stdout } } - const COLLECT = `m.collectMountPoints()` + const COLLECT = `m.collectMountPoints('all')` const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( @@ -302,7 +302,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('removes what a finished manifest names once nothing else names it', () => { leftover(X) started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(existsSync(X)).toBe(false) // The manifest, its record and the claim on it went with it. expect(readdirSync(DIR)).toEqual([]) @@ -321,7 +321,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) let removed: string[] try { - removed = collectMountPoints() + removed = collectMountPoints('all') } finally { pass.restore() } @@ -439,7 +439,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) as never) let removed: string[] try { - removed = collectMountPoints() + removed = collectMountPoints('all') } finally { spy.mockRestore() } @@ -463,7 +463,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) as never) let removed: string[] try { - removed = collectMountPoints() + removed = collectMountPoints('all') } finally { spy.mockRestore() } @@ -496,10 +496,10 @@ describe.if(isLinux)('The mount point manifests', () => { const manifest = manifestOfADeadProcess([X], { created: now - 499 }) const clock = spyOn(Date, 'now').mockReturnValue(now) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(manifest)).toBe(true) clock.mockReturnValue(now + 1) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { clock.mockRestore() } @@ -588,7 +588,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const later = String(Number(thisProcess().start) + 1) started(manifestOfADeadProcess([X]), { pid: process.pid, start: later }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) }) it('is live while any of the runs on its record lasts', () => { @@ -596,7 +596,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const gone = { pid: deadPid(), start: '1' } started(manifestOfADeadProcess([X]), gone, thisProcess(), gone) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) }) @@ -605,7 +605,7 @@ describe.if(isLinux)('The mount point manifests', () => { // records itself, and bubblewrap then finds no manifest to bind. const left = join(DIR, '4242-0123456789abcdef.started') writeFileSync(left, statLine({ pid: deadPid(), start: '1' })) - collectMountPoints() + collectMountPoints('all') expect(existsSync(left)).toBe(false) }) @@ -637,7 +637,7 @@ describe.if(isLinux)('The mount point manifests', () => { seen.push({ manifest: existsSync(manifest), claims: claims() }), ) try { - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { removal.restore() } @@ -653,7 +653,7 @@ describe.if(isLinux)('The mount point manifests', () => { const manifest = manifestOfADeadProcess([X]) const pass = duringTheNextPass(() => started(manifest, thisProcess())) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { pass.restore() } @@ -678,14 +678,14 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(Y) const finished = manifestOfADeadProcess([X, Y]) const live = started(manifestOfADeadProcess([X]), thisProcess()) - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) expect(existsSync(X)).toBe(true) expect(existsSync(finished)).toBe(false) expect(existsSync(live)).toBe(true) // It goes once that sandbox has ended too. started(live, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) }) it('keeps a path that another manifest names when a command starts under that one during the pass', () => { @@ -696,7 +696,7 @@ describe.if(isLinux)('The mount point manifests', () => { const other = manifestOfADeadProcess([X]) const pass = duringTheNextPass(() => started(other, thisProcess())) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { pass.restore() } @@ -724,7 +724,7 @@ describe.if(isLinux)('The mount point manifests', () => { rename(from, to) }) as never) try { - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) } finally { spy.mockRestore() } @@ -746,7 +746,7 @@ describe.if(isLinux)('The mount point manifests', () => { manifestOfADeadProcess([X, Y]) expect(named()).toEqual([X, Y].sort()) expect(live()).toEqual([]) - expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) expect(existsSync(claim)).toBe(false) expect(readdirSync(DIR)).toEqual([]) }) @@ -755,7 +755,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const claim = claimed(started(manifestOfADeadProcess([X]), thisProcess())) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) expect(claims()).toEqual([basename(claim)]) }) @@ -773,7 +773,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) try { // X stays: the manifest is at its own name by the time X's turn comes. - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) } finally { removal.restore() } @@ -805,7 +805,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(named()).toEqual([X]) expect(claim).toBeDefined() expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } @@ -832,7 +832,7 @@ describe.if(isLinux)('The mount point manifests', () => { // What it names cannot be listed. expect(live()).toBeUndefined() expect(named()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } @@ -887,7 +887,7 @@ describe.if(isLinux)('The mount point manifests', () => { held: string[] | undefined } { try { - return { held: live(), removed: collectMountPoints() } + return { held: live(), removed: collectMountPoints('all') } } finally { injected.restore() } @@ -1008,7 +1008,7 @@ describe.if(isLinux)('The mount point manifests', () => { // In a process of its own: opening a FIFO to read waits for a writer, on // the one thread there is. const looked = inAChild( - `console.log(JSON.stringify({ held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + `console.log(JSON.stringify({ held: [...m.liveMountPoints()], removed: m.collectMountPoints('all') }))`, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual({ held: [X], removed: [] }) @@ -1098,7 +1098,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([]) expect(named()).toEqual([X]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(DIR)).toEqual([basename(young)]) }) @@ -1110,7 +1110,7 @@ describe.if(isLinux)('The mount point manifests', () => { const orphan = join(DIR, '4242-0123456789abcdef.started') plant(orphan) const looked = inAChild( - `console.log(JSON.stringify({ named: [...m.namedMountPoints().paths], held: [...m.liveMountPoints()], removed: m.collectMountPoints() }))`, + `console.log(JSON.stringify({ named: [...m.namedMountPoints().paths], held: [...m.liveMountPoints()], removed: m.collectMountPoints('all') }))`, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual({ @@ -1207,7 +1207,7 @@ describe.if(isLinux)('The mount point manifests', () => { // However old it is. const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) utimesSync(record, hoursAgo, hoursAgo) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) expect(existsSync(record)).toBe(true) }) @@ -1259,12 +1259,12 @@ describe.if(isLinux)('The mount point manifests', () => { }) as never) try { expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() forgetMountPointManifestDirectory() } - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) }) for (const [what, then] of [ @@ -1292,14 +1292,14 @@ describe.if(isLinux)('The mount point manifests', () => { injected ??= then() }) as never) try { - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) } finally { spy.mockRestore() injected?.restore() } expect(existsSync(X)).toBe(true) expect(claims()).toEqual([finishedOf(manifest)]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) }) } @@ -1313,11 +1313,11 @@ describe.if(isLinux)('The mount point manifests', () => { chmodSync(X, mode) const manifest = started(manifestOfADeadProcess([X]), thisProcess()) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) started(manifest, { pid: deadPid(), start: '1' }) expect(live()).toEqual([]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(existsSync(X)).toBe(false) expect(readdirSync(DIR)).toEqual([]) }) @@ -1352,7 +1352,9 @@ describe.if(isLinux)('The mount point manifests', () => { return order === 'as listed' ? names : names.reverse() }) as never) try { - expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + expect(collectMountPoints('all')).toEqual( + [...inside].reverse().concat(Q), + ) } finally { spy.mockRestore() } @@ -1366,11 +1368,11 @@ describe.if(isLinux)('The mount point manifests', () => { const { Q, inside } = nested() const outer = manifestOfADeadProcess([Q]) const inner = started(manifestOfADeadProcess(inside), thisProcess()) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(claims()).toEqual([finishedOf(outer)]) started(inner, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + expect(collectMountPoints('all')).toEqual([...inside].reverse().concat(Q)) expect(readdirSync(DIR)).toEqual([]) }) @@ -1382,12 +1384,12 @@ describe.if(isLinux)('The mount point manifests', () => { manifestOfADeadProcess(inside) chmodSync(Q, 0o555) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(claims()).toContain(finishedOf(outer)) } finally { chmodSync(Q, 0o755) } - expect(collectMountPoints()).toEqual([...inside].reverse().concat(Q)) + expect(collectMountPoints('all')).toEqual([...inside].reverse().concat(Q)) expect(readdirSync(DIR)).toEqual([]) }, ) @@ -1400,9 +1402,9 @@ describe.if(isLinux)('The mount point manifests', () => { manifestOfADeadProcess([], { sources: [source] }), thisProcess(), ) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) started(live, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([source]) + expect(collectMountPoints('all')).toEqual([source]) }) it('leaves what a finished manifest names when it no longer looks like a mount point', () => { @@ -1419,7 +1421,7 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(inUse) writeFileSync(join(inUse, 'file'), '') const manifest = manifestOfADeadProcess([written, linked, pointer, inUse]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) for (const kept of [written, linked, pointer, inUse]) { expect(existsSync(kept)).toBe(true) } @@ -1451,7 +1453,7 @@ describe.if(isLinux)('The mount point manifests', () => { return stat }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } @@ -1465,7 +1467,7 @@ describe.if(isLinux)('The mount point manifests', () => { for (const file of [...old, young]) writeFileSync(file, '{}') const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) for (const file of old) utimesSync(file, hoursAgo, hoursAgo) - collectMountPoints() + collectMountPoints('all') for (const file of old) expect(existsSync(file)).toBe(false) // This one may be on its way into place. expect(existsSync(young)).toBe(true) @@ -1495,7 +1497,7 @@ describe.if(isLinux)('The mount point manifests', () => { (_what, outcome, fields) => { leftover(X) const manifest = manifestOfADeadProcess([X], fields) - collectMountPoints() + collectMountPoints('all') expect(existsSync(X)).toBe(outcome === 'kept') expect(existsSync(manifest)).toBe(outcome === 'kept') }, @@ -1518,17 +1520,17 @@ describe.if(isLinux)('The mount point manifests', () => { aged(manifest, written) aged(recordOf(manifest), recorded) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } aged(recordOf(manifest), 7 * DAYS + 1) expect(live()).toEqual([]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(DIR)).toEqual([]) // With no record, from the manifest alone. leftover(X) aged(manifestOfADeadProcess([X], { ns }), 7 * DAYS + 1) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } }) @@ -1550,7 +1552,7 @@ describe.if(isLinux)('The mount point manifests', () => { // A wrap still takes both for mount points, which they are. expect(named()).toEqual([X, Y].sort()) expect(live()).toEqual([]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) // What it names alone may be another file in that namespace's mounts. expect(existsSync(Y)).toBe(true) expect(readdirSync(DIR)).toEqual([basename(theirs)]) @@ -1561,7 +1563,7 @@ describe.if(isLinux)('The mount point manifests', () => { finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) const open = spyOn(fs, 'openSync') try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect( open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), ).toEqual([]) @@ -1574,9 +1576,9 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const theirs = finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) aged(theirs, 7 * 24 * 60 - 1) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) aged(theirs, 7 * 24 * 60 + 1) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(DIR)).toEqual([]) }) @@ -1584,7 +1586,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const claim = claimed(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) manifestOfADeadProcess([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) expect(readdirSync(DIR)).toEqual([basename(claim)]) }) @@ -1596,7 +1598,7 @@ describe.if(isLinux)('The mount point manifests', () => { const seen: string[][] = [] const removal = atTheRemovalOf(X, () => seen.push(claims())) try { - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { removal.restore() } @@ -1612,7 +1614,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) const theirs = finished(manifest) aged(recordOf(manifest)) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(readdirSync(DIR).sort()).toEqual( [basename(theirs), basename(recordOf(manifest))].sort(), ) @@ -1627,7 +1629,7 @@ describe.if(isLinux)('The mount point manifests', () => { const seen: string[][] = [] const removal = atTheRemovalOf(X, () => seen.push(claims())) try { - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { removal.restore() } @@ -1640,7 +1642,7 @@ describe.if(isLinux)('The mount point manifests', () => { const theirs = finished(started(manifestOfADeadProcess([X]), thisProcess())) manifestOfADeadProcess([X]) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) expect(claims()).toEqual([basename(theirs)]) }) @@ -1661,13 +1663,13 @@ describe.if(isLinux)('The mount point manifests', () => { rename(from, to) }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } expect(existsSync(X)).toBe(true) expect(named()).toEqual([X]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(DIR)).toEqual([]) expect(existsSync(manifest)).toBe(false) }) @@ -1725,7 +1727,7 @@ describe.if(isLinux)('The mount point manifests', () => { ` if (met === 1) { met = 2; ${meet('read')} }`, ` return unlink(p)`, `})`, - `m.collectMountPoints()`, + `m.collectMountPoints('all')`, ].join('\n'), ) return file @@ -1777,7 +1779,7 @@ describe.if(isLinux)('The mount point manifests', () => { thisProcess(), ) expect(live()).toEqual([]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(DIR)).toEqual([]) } } finally { @@ -1801,7 +1803,7 @@ describe.if(isLinux)('The mount point manifests', () => { const kind = onAFileSystemOf(type) try { expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) } finally { kind.restore() @@ -1846,17 +1848,17 @@ describe.if(isLinux)('The mount point manifests', () => { try { const manifest = left() expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) bothAged(manifest, 6 * 24 * 60) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) bothAged(manifest, 7 * 24 * 60 + 1) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) // Where only this kernel reaches: at once, as ever. spies.shift()!.restore() spies.push(onAFileSystemOf(0xef53)) left() - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { spies.forEach(spy => spy.restore()) forgetMountPointManifestDirectory() @@ -1874,9 +1876,9 @@ describe.if(isLinux)('The mount point manifests', () => { const kind = onAFileSystemOf(type) try { expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) started(manifest, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { kind.restore() } @@ -1907,7 +1909,7 @@ describe.if(isLinux)('The mount point manifests', () => { ) try { expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(readFileSync(publish([X]), 'utf8')).not.toContain('"boot"') } finally { failing.restore() @@ -2132,12 +2134,12 @@ describe.if(isLinux)('The mount point manifests', () => { for (const place of order) { expect(recordedIn()).toBe(place) expect(lstatSync(place).mode & 0o777).toBe(0o700) - collectMountPoints() + collectMountPoints('all') taken([place]) } expect(order).not.toContain(recordedIn()) } finally { - collectMountPoints() + collectMountPoints('all') order.forEach(place => rmSync(place)) mkdirSync(DIR, { mode: 0o700 }) } @@ -2160,7 +2162,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(dirname(publish([Y]))).toBe(PLACES[0]!) expect(named()).toEqual([X, Y].sort()) expect(live()).toEqual([Y]) - expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) expect(readdirSync(PLACES[target]!)).toEqual([]) // Both names are kept out of a command's reach all the same. expect(mountPointManifestDirectories()).toEqual( @@ -2203,7 +2205,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(dirname(publish([Y]))).toBe(PLACES[2]!) rmSync(dirname(PLACES[0]!), { recursive: true }) symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) expect(readdirSync(PLACES[2]!)).toEqual([]) }) @@ -2220,10 +2222,10 @@ describe.if(isLinux)('The mount point manifests', () => { expect(dirname(publish([Y]))).toBe(PLACES[2]!) expect(named()).toEqual([X, Y].sort()) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) started(relied, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(PLACES[1]!)).toEqual([]) // Made, too, so that the command cannot. @@ -2258,7 +2260,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(dirname(publish([Y]))).toBe(PLACES[2]!) expect(mountPointManifestDirectories()).not.toContain(PLACES[1]) expect(named()).toEqual([Y]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spoiled?.restore() } @@ -2288,7 +2290,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(dirname(publish([Y]))).toBe(PLACES[next]!) expect(mountPointManifestDirectories()).not.toContain(PLACES[i]) expect(named()).toEqual([Y]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { squatted?.restore() } @@ -2316,10 +2318,10 @@ describe.if(isLinux)('The mount point manifests', () => { PLACES.forEach(each => at(each, () => manifestOfADeadProcess([X]))) expect(named()).toEqual([X]) expect(live()).toEqual([X]) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) started(relied, { pid: deadPid(), start: '1' }) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) expect(PLACES.flatMap(each => readdirSync(each))).toEqual([]) } }) @@ -2334,7 +2336,7 @@ describe.if(isLinux)('The mount point manifests', () => { const junk = join(NAMED[1]!, '4242-00.json') writeFileSync(junk, '{}') // A clean-up makes the first of the three before it looks. - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(named()).toEqual([]) expect(live()).toEqual([]) expect(planted.filter(existsSync)).toEqual(planted) @@ -2362,10 +2364,10 @@ describe.if(isLinux)('The mount point manifests', () => { writeFileSync(junk, '{}') expect(named()).toBeUndefined() expect(live()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(PLACES.flatMap(each => at(each, claims))).toEqual([]) rmSync(junk) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } }) @@ -2390,7 +2392,7 @@ describe.if(isLinux)('The mount point manifests', () => { })) as never) try { expect(live()).toEqual([Y]) - expect(collectMountPoints()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) } finally { spy.mockRestore() } @@ -2408,11 +2410,11 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(named()).toBeUndefined() expect(live()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { look.restore() } - expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) }) it('are not left unbound when one of them cannot be looked at: no command is handed out', async () => { @@ -2454,7 +2456,7 @@ describe.if(isLinux)('The mount point manifests', () => { ) }) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { pass.restore() } @@ -2475,7 +2477,7 @@ describe.if(isLinux)('The mount point manifests', () => { ), ) try { - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) } finally { removal.restore() } @@ -2501,7 +2503,7 @@ describe.if(isLinux)('The mount point manifests', () => { return readdir(...args) }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } @@ -2533,7 +2535,7 @@ describe.if(isLinux)('The mount point manifests', () => { [ `m.setMountPointManifestPlacesForTesting(${JSON.stringify(join(BASE, 'root'))})`, `const named = [...m.namedMountPoints().paths].sort()`, - `const removed = m.collectMountPoints()`, + `const removed = m.collectMountPoints('all')`, `console.log(JSON.stringify({ named, removed, file: m.publishMountPointManifest(['/nonexistent/x'], []).file }))`, ].join('\n'), { @@ -2727,7 +2729,7 @@ describe.if(isLinux)('The mount point manifests', () => { const looked = inAChild( [ `const list = set => (set === undefined ? 'cannot tell' : [...set])`, - `console.log(JSON.stringify({ named: list(m.namedMountPoints()?.paths), held: list(m.liveMountPoints()), removed: m.collectMountPoints() }))`, + `console.log(JSON.stringify({ named: list(m.namedMountPoints()?.paths), held: list(m.liveMountPoints()), removed: m.collectMountPoints('all') }))`, ].join('\n'), ) expect(looked.status).toBe(0) @@ -2768,7 +2770,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(named()).toBeUndefined() expect(liveMountPoints()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) expect(existsSync(Y)).toBe(true) }, @@ -2783,13 +2785,13 @@ describe.if(isLinux)('The mount point manifests', () => { const theirs = asSomebodyElses(junk) try { expect(named()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) // An hour, not less and not much more. aged(junk, 59) expect(named()).toBeUndefined() - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) aged(junk, 61) - expect(collectMountPoints()).toEqual([Y]) + expect(collectMountPoints('all')).toEqual([Y]) } finally { theirs.restore() } @@ -2822,7 +2824,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(X)).toBe(true) expect(existsSync(manifest)).toBe(true) - expect(collectMountPoints().sort()).toEqual([X, directory].sort()) + expect(collectMountPoints('all').sort()).toEqual([X, directory].sort()) expect(live()).toEqual([]) }) @@ -3089,7 +3091,7 @@ describe.if(isLinux)('The mount point manifests', () => { `const published = m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null`, `const live = [...m.liveMountPoints()]`, `const directories = m.mountPointManifestDirectories()`, - `const collected = m.collectMountPoints()`, + `const collected = m.collectMountPoints('all')`, `u.cleanupBwrapMountPoints()`, `u.cleanupBwrapMountPoints({ force: true })`, `console.log(JSON.stringify({ published, live, directories, collected }))`, diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index bbc10a7e8..ebbd339d5 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -248,7 +248,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { }) as never), ] try { - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) } finally { spies.forEach(spy => spy.mockRestore()) } @@ -272,7 +272,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { unlink(file) }) as never) try { - collectMountPoints() + collectMountPoints('all') } finally { spy.mockRestore() } @@ -286,7 +286,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { manifestOfADeadProcess([LOCK], anotherProcess()) const open = spyOn(fs, 'openSync') try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect( open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), ).toEqual([]) @@ -309,7 +309,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { return (open as (...args: unknown[]) => number)(file, ...rest) }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() } @@ -318,7 +318,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(readdirSync(DIR)).toEqual([ basename(manifest).replace(/json$/, 'finished'), ]) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) }) it('still removes what this process could record nowhere, on a disk that takes neither a manifest nor a record', () => { @@ -349,7 +349,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { try { expect(publishMountPointManifest([LOCK], [])).toBeUndefined() leftover(LOCK) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) } finally { spies.forEach(spy => spy.mockRestore()) } @@ -361,7 +361,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { writeFileSync(join(DIR, '1-0123456789abcdef.json'), '{"version":"?"}') const open = spyOn(fs, 'openSync') try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect( open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), ).toEqual([]) @@ -379,7 +379,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { return now() }) try { - expect(() => collectMountPoints()).toThrow('a bug') + expect(() => collectMountPoints('all')).toThrow('a bug') } finally { spy.mockRestore() } @@ -425,14 +425,14 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { }) as never) try { // Children first, which among siblings is the later name first. - expect(collectMountPoints()).toEqual([second]) + expect(collectMountPoints('all')).toEqual([second]) } finally { spy.mockRestore() clock.mockRestore() } expect(existsSync(LOCK)).toBe(true) expect(passes()).toEqual([]) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) }, ) @@ -463,13 +463,13 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { return result }) as never) try { - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) } finally { spy.mockRestore() clock.mockRestore() } if (heldUpIn === 'readdirSync') expect(listings).toBe(2) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) } }, ) @@ -513,7 +513,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { ])('is over when %s, and the next pass drops its record', (_what, plant) => { const pass = plant() expect(passesUnderWay()).toEqual([]) - collectMountPoints() + collectMountPoints('all') expect(existsSync(pass)).toBe(false) }) @@ -541,16 +541,16 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(passesUnderWay()).toEqual([pass]) aged(pass, 59_000) expect(passesUnderWay()).toEqual([pass]) - collectMountPoints() + collectMountPoints('all') expect(existsSync(pass)).toBe(true) aged(pass, 61_000) expect(passesUnderWay()).toEqual([]) // Its record is not this process's to drop: its own may be stopped half // way, and those who can ask after it still wait for it. - collectMountPoints() + collectMountPoints('all') expect(existsSync(pass)).toBe(true) aged(pass, 8 * 24 * 60 * 60 * 1000) - collectMountPoints() + collectMountPoints('all') expect(existsSync(pass)).toBe(false) }, ) @@ -568,13 +568,13 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { unlink(file) }) as never) try { - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) } finally { spy.mockRestore() } expect(passes()).toHaveLength(1) expect(passesUnderWay()).toEqual([]) - collectMountPoints() + collectMountPoints('all') expect(passes()).toEqual([]) }) @@ -791,7 +791,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { ` }`, ` unlink(file)`, `})`, - `m.collectMountPoints()`, + `m.collectMountPoints('all')`, ]), ], { stdio: 'ignore' }, @@ -849,7 +849,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { [ script('cleaner.ts', [ `const m = await import(${MODULE})`, - `m.collectMountPoints()`, + `m.collectMountPoints('all')`, ]), ], { encoding: 'utf8', timeout: 30000 }, @@ -898,9 +898,9 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { ? [anotherProcess(), ...gone] : [...gone, anotherProcess()]), ) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) started(manifest, ...gone) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) }, ) @@ -914,7 +914,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { record, line.repeat(Math.ceil((4 * 1024 * 1024 + 1) / line.length)), ) - expect(collectMountPoints()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) expect(existsSync(LOCK)).toBe(true) }) @@ -980,7 +980,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { const pass = passOf(exited) expect(passesUnderWay()).toEqual([]) expect([...(liveMountPoints() ?? [])]).toEqual([]) - expect(collectMountPoints()).toEqual([LOCK]) + expect(collectMountPoints('all')).toEqual([LOCK]) expect(existsSync(pass)).toBe(false) // Still there to be asked after: it is what it says that counts. expect(existsSync(`/proc/${pid}`)).toBe(true) From c278205bc5f5fcf4094a77f7e3a53bc07f300324 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:39:37 +0000 Subject: [PATCH 104/141] test: the four mount point suites share their fixtures Each declared its own manifest writer (five copies), dead pid, stat parser (six), placeholder, started record, wait loops (eight), wrap and run, and the liveness suite spelled the wrap of a child process out eleven times. One module, test/helpers/mount-point-fixtures.ts, holds them now. No case changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/helpers/mount-point-fixtures.ts | 181 ++++++++++++ test/helpers/private-manifest-directory.ts | 8 +- test/sandbox/mount-point-liveness.test.ts | 308 ++++----------------- test/sandbox/mount-point-manifests.test.ts | 106 +------ test/sandbox/mount-point-passes.test.ts | 115 ++------ test/sandbox/stale-mount-point.test.ts | 129 ++------- 6 files changed, 299 insertions(+), 548 deletions(-) create mode 100644 test/helpers/mount-point-fixtures.ts diff --git a/test/helpers/mount-point-fixtures.ts b/test/helpers/mount-point-fixtures.ts new file mode 100644 index 000000000..7e2a256a8 --- /dev/null +++ b/test/helpers/mount-point-fixtures.ts @@ -0,0 +1,181 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { manifestOf } from './bwrap-argv.js' + +/** A process as a manifest, a started record or a pass names one. */ +export type Process = { pid: number; start: string } + +/** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ +export function leftover(p: string): void { + writeFileSync(p, '') + chmodSync(p, 0o444) +} + +/** A pid nothing in this PID namespace has. */ +export function deadPid(): number { + for (let pid = 4194000; pid > 1; pid--) { + if (!existsSync(`/proc/${pid}`)) return pid + } + throw new Error('no free pid') +} + +/** Field 22 of a /proc/PID/stat line: when that process started. */ +export function startOf(stat: string): string | undefined { + return stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19] +} + +export function processAt(pid: number): Process { + return { pid, start: startOf(readFileSync(`/proc/${pid}/stat`, 'utf8'))! } +} + +/** This process, which is running. */ +export const thisProcess = (): Process => processAt(process.pid) + +/** One that is gone. */ +export const aDeadProcess = (): Process => ({ pid: deadPid(), start: '1' }) + +/** + * The manifest of a wrap that named `paths`, in `dir`, which is made: written a + * minute ago, so long past its grace, by a process that is gone. What a killed + * process leaves. `fields` overrides what it says, and one that is undefined is + * left out. + */ +export function writeManifest( + dir: string, + paths: string[], + fields: Record = {}, +): string { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + const body: Record = { + version: 1, + ...aDeadProcess(), + ns: readlinkSync('/proc/self/ns/pid'), + created: Date.now() - 60_000, + paths, + sources: [], + ...fields, + } + for (const [key, value] of Object.entries(body)) { + if (value === undefined) delete body[key] + } + const file = join( + dir, + `${String(body['pid'])}-${Math.random().toString(16).slice(2).padEnd(16, '0')}.json`, + ) + writeFileSync(file, JSON.stringify(body), { mode: 0o600 }) + return file +} + +/** Where the started record of `manifest` is. */ +export const recordOf = (manifest: string): string => + manifest.replace(/\.json$/, '.started') + +/** + * The line the wrapper shell records for a process, with a name that holds what + * a careless reading would trip over. + */ +export function statLine(who: Process): string { + const after = ['S', ...Array(18).fill('0'), who.start, '0', '0'] + return `${who.pid} (a) b (c) ${after.join(' ')}\n` +} + +/** Puts `manifest` on record as started by those processes, in that order. */ +export function started(manifest: string, ...by: Process[]): string { + writeFileSync(recordOf(manifest), by.map(statLine).join(''), { mode: 0o600 }) + return manifest +} + +/** The mount points the wrap named in its manifest, none without one. */ +export function namedBy(command: string): string[] { + const file = manifestOf(command) + return file === undefined + ? [] + : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function until( + what: string, + holds: () => boolean, + timeoutMs = 30000, +): Promise { + const deadline = Date.now() + timeoutMs + while (!holds()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await sleep(10) + } +} + +/** Until `file` is there: how a command in a sandbox says how far it has got. */ +export const waitFor = (file: string, timeoutMs?: number): Promise => + until(file, () => existsSync(file), timeoutMs) + +/** `command`, allowed to write `area` but for `deny`. */ +export function wrapIn( + area: string, + command: string, + deny: string[], +): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [area], denyWithinAllow: deny }, + }) +} + +/** + * The line by which a process of its own, which has imported the library, does + * the like: what it is handed is `wrapped`. + */ +export const wrapLine = ( + allowOnly: string[], + command: string, + deny: string[], +): string => + `const wrapped = await wrapCommandWithSandboxLinux(${JSON.stringify({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow: deny }, + })})` + +export function run(command: string): { + status: number | null + said: string + stdout: string +} { + const r = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + return { status: r.status, said: `${r.stdout}${r.stderr}`, stdout: r.stdout } +} + +/** Writes a script of those lines to `dir`, for a process of its own. */ +export function writeScript( + dir: string, + name: string, + lines: string[], +): string { + const file = join(dir, name) + writeFileSync(file, lines.join('\n')) + return file +} diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 7357e9341..8ee398342 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -14,15 +14,9 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' import './isolated/places.js' +import { startOf } from './mount-point-fixtures.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' -/** Field 22 of a /proc/PID/stat line: when that process started. */ -const startOf = (stat: string): string | undefined => - stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19] - /** * Kills every bubblewrap that is still on a started record in `dir`, and its * sandbox with it: a test that failed half way must not leave one polling. Then diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 9f1a397e2..1a402e6bc 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -34,6 +34,19 @@ import { STEP_SHELL, } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + namedBy, + processAt, + run, + sleep, + startOf, + until, + waitFor, + wrapIn, + wrapLine, + writeManifest, + writeScript as writeScriptIn, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { inPrivateNamespace, @@ -80,14 +93,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { rmSync(BASE, { recursive: true, force: true }) }) - async function wrap(command: string, denyPaths = [LOCK]): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: denyPaths }, - }) - } + const wrap = (command: string, denyPaths = [LOCK]): Promise => + wrapIn(AREA, command, denyPaths) /** Waits for the sandbox to report it is up, then tries the denied write. */ function heldCommand(): string { @@ -105,37 +112,15 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `import { writeFileSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, `const out = ${JSON.stringify(out)}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(command)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, + wrapLine([AREA], command, [LOCK]), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `writeFileSync(out, String(r.stdout ?? '') + String(r.stderr ?? ''))`, ...after, ].join('\n') } - function writeScript(name: string, source: string): string { - const file = join(BASE, name) - writeFileSync(file, source) - return file - } - - async function waitFor(file: string, timeoutMs = 30000): Promise { - const deadline = Date.now() + timeoutMs - while (!existsSync(file)) { - if (Date.now() > deadline) { - throw new Error(`timed out waiting for ${file}`) - } - await new Promise(resolve => setTimeout(resolve, 25)) - } - } - - function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) - } + const writeScript = (name: string, source: string): string => + writeScriptIn(BASE, name, [source]) /** What is at `file`, or '' where nothing is: a mount point holds nothing. */ function contentAt(file: string): string { @@ -155,42 +140,13 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return file! } - /** - * The manifest of a wrap that named `paths`, written a minute ago by - * `writer`: by default a process that is gone, which makes it a finished one. - */ - function manifestNaming(paths: string[], writer?: number): string { - const dir = runtime.manifestDir() - mkdirSync(dir, { recursive: true, mode: 0o700 }) - chmodSync(dir, 0o700) - let pid = 4194000 - let start = '1' - if (writer === undefined) { - while (existsSync(`/proc/${pid}`)) pid-- - } else { - const stat = readFileSync(`/proc/${writer}/stat`, 'utf8') - pid = writer - start = stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19]! - } - const file = join(dir, `${pid}-0123456789abcdef.json`) - writeFileSync( - file, - JSON.stringify({ - version: 1, - pid, - start, - ns: readlinkSync('/proc/self/ns/pid'), - created: Date.now() - 60_000, - paths, - sources: [], - }), - { mode: 0o600 }, + /** One that `writer` wrote: by default a process that is gone. */ + const manifestNaming = (paths: string[], writer?: number): string => + writeManifest( + runtime.manifestDir(), + paths, + writer === undefined ? {} : processAt(writer), ) - return file - } it.if(BWRAP_CAN_NAMESPACE)( 'survives another process cleaning up in the same directory, and the deny holds', @@ -469,12 +425,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(attack)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(TMP)}, ${JSON.stringify(AREA)}], denyWithinAllow: [] },`, - `})`, + wrapLine([TMP, AREA], attack, []), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `console.log(String(r.stdout ?? '') + String(r.stderr ?? ''))`, ].join('\n'), @@ -569,14 +520,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) }) - /** Field 22 of a /proc/PID/stat line: when that process started. */ - function startOf(stat: string): string | undefined { - return stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19] - } - it.if(BWRAP_CAN_NAMESPACE)( 'puts bubblewrap itself on record, each time it is run', async () => { @@ -1281,12 +1224,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `import { spawnSync } from 'node:child_process'`, `import { readdirSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: 'true',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: ${JSON.stringify(deny)} },`, - `})`, + wrapLine([AREA], 'true', deny), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `console.log(JSON.stringify({ wrapped, status: r.status, said: String(r.stdout ?? '') + String(r.stderr ?? ''), made: readdirSync(${JSON.stringify(TMP)}) }))`, ].join('\n'), @@ -1349,12 +1287,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `import { spawnSync } from 'node:child_process'`, `import { existsSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(`echo pwned > ${LOCK}; echo rc=$?`)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, + wrapLine([AREA], `echo pwned > ${LOCK}; echo rc=$?`, [LOCK]), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `const made = existsSync(${JSON.stringify(LOCK)})`, after, @@ -1488,24 +1421,6 @@ describe.if(isLinux)( rmSync(BASE, { recursive: true, force: true }) }) - function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) - } - - async function waitFor( - what: string, - done: () => boolean, - timeoutMs = 30000, - ): Promise { - const deadline = Date.now() + timeoutMs - while (!done()) { - if (Date.now() > deadline) { - throw new Error(`timed out waiting for ${what}`) - } - await sleep(25) - } - } - /** "pid state ppid" of every process this one can see. */ function processes(): { pid: number; state: string; ppid: number }[] { const found: { pid: number; state: string; ppid: number }[] = [] @@ -1552,7 +1467,7 @@ describe.if(isLinux)( // Gone already. } } - await waitFor( + await until( `the sandbox under ${root} to be gone`, () => !processes().some( @@ -1579,12 +1494,7 @@ describe.if(isLinux)( `import { spawn } from 'node:child_process'`, `import { existsSync, writeFileSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(heldCommand())},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, + wrapLine([AREA], heldCommand(), [LOCK]), // "; exit" so that no shell execs bubblewrap in its own place: the // shell has to stay, as the parent --die-with-parent watches. `const child = spawn('/bin/sh', ['-c', wrapped + '\\nexit $?'], { detached: true, stdio: 'ignore' })`, @@ -1614,12 +1524,7 @@ describe.if(isLinux)( return [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: 'true',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, + wrapLine([AREA], 'true', [LOCK]), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `cleanupBwrapMountPoints()`, `process.exit(r.status ?? 1)`, @@ -1679,7 +1584,7 @@ describe.if(isLinux)( const { created } = JSON.parse(readFileSync(manifest!, 'utf8')) as { created: number } - await waitFor( + await until( 'the manifest to be past its grace', () => Date.now() - created > 750, ) @@ -1692,7 +1597,7 @@ describe.if(isLinux)( onTheHost: string }> { writeFileSync(GO, '') - await waitFor( + await until( 'the sandbox to say what its write returned', () => existsSync(RESULT) && readFileSync(RESULT, 'utf8').includes('\n'), ) @@ -1824,7 +1729,7 @@ describe.if(isLinux)( await killTree(info.launcher) launcher = undefined // With the sandbox gone the same collector takes it away. - await waitFor( + await until( 'the collector to take the mount point away', () => !existsSync(LOCK), ) @@ -1873,37 +1778,7 @@ describe.if(isLinux)( }) const wrap = (command: string, deny: string[]): Promise => - wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: deny }, - }) - - const run = ( - command: string, - ): { status: number | null; said: string; stdout: string } => { - const r = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 60000, - }) - return { - status: r.status, - said: `${r.stdout}${r.stderr}`, - stdout: r.stdout, - } - } - - async function waitFor(file: string, timeoutMs = 30000): Promise { - const deadline = Date.now() + timeoutMs - while (!existsSync(file)) { - if (Date.now() > deadline) { - throw new Error(`timed out waiting for ${file}`) - } - await new Promise(resolve => setTimeout(resolve, 25)) - } - } + wrapIn(AREA, command, deny) /** * Another process: denies LEAF, holds its sandbox until `go` is there, @@ -1916,12 +1791,11 @@ describe.if(isLinux)( [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(`echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LEAF)}] },`, - `})`, + wrapLine( + [AREA], + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`, + [LEAF], + ), `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `cleanupBwrapMountPoints()`, ].join('\n'), @@ -2044,36 +1918,8 @@ describe.if(isLinux)( // directory in the meantime has to name it for itself. /** What a process killed a minute ago left in the manifest directory. */ - function manifestOfAKilledProcess(paths: string[]): string { - const dir = runtime.manifestDir() - mkdirSync(dir, { recursive: true, mode: 0o700 }) - chmodSync(dir, 0o700) - let pid = 4194000 - while (existsSync(`/proc/${pid}`)) pid-- - const file = join(dir, `${pid}-0123456789abcdef.json`) - writeFileSync( - file, - JSON.stringify({ - version: 1, - pid, - start: '1', - ns: readlinkSync('/proc/self/ns/pid'), - created: Date.now() - 60_000, - paths, - sources: [], - }), - { mode: 0o600 }, - ) - return file - } - - /** The mount points the wrap named in its manifest, none without one. */ - function namedBy(command: string): string[] { - const file = recordedBy(command) - return file === undefined - ? [] - : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths - } + const manifestOfAKilledProcess = (paths: string[]): string => + writeManifest(runtime.manifestDir(), paths) it('is named by a wrap that denies it, and stays until that wrap is cleaned up after', async () => { mkdirSync(DIRECTORY) @@ -2414,12 +2260,7 @@ describe.if(isLinux)( file, [ `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `await wrapCommandWithSandboxLinux({`, - ` command: 'true',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(DIRECTORY)}] },`, - `})`, + wrapLine([AREA], 'true', [DIRECTORY]), ].join('\n'), ) const exited = spawnSync(process.execPath, [file], { @@ -2551,23 +2392,15 @@ describe.if(isLinux)( async () => { // Bound onto itself and not named, it would go at the next clean-up in // any process, from under the running sandbox. - const script = (name: string, lines: string[]): string => { - const file = join(BASE, name) - writeFileSync(file, lines.join('\n')) - return file - } + const script = (name: string, lines: string[]): string => + writeScriptIn(BASE, name, lines) const killed = spawnSync( process.execPath, [ script('killed.ts', [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: 'true',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LEAF)}] },`, - `})`, + wrapLine([AREA], 'true', [LEAF]), `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `process.kill(process.pid, 'SIGKILL')`, ]), @@ -2959,12 +2792,7 @@ describe.if(isLinux)( `import { spawnSync } from 'node:child_process'`, `import { writeFileSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(command)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(HOME)}], denyWithinAllow: [${JSON.stringify(GUARD)}] },`, - `})`, + wrapLine([HOME], command, [GUARD]), `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `writeFileSync(${JSON.stringify(out)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, ].join('\n'), @@ -3266,21 +3094,8 @@ describe.if(isLinux)( rmSync(BASE, { recursive: true, force: true }) }) - function writeScript(name: string, lines: string[]): string { - const file = join(BASE, name) - writeFileSync(file, lines.join('\n')) - return file - } - - async function waitFor(file: string, timeoutMs = 30000): Promise { - const deadline = Date.now() + timeoutMs - while (!existsSync(file)) { - if (Date.now() > deadline) { - throw new Error(`timed out waiting for ${file}`) - } - await new Promise(resolve => setTimeout(resolve, 25)) - } - } + const writeScript = (name: string, lines: string[]): string => + writeScriptIn(BASE, name, lines) it.if(CAN)( 'survives a cleanup run where neither its writer nor the process on its record can be seen', @@ -3293,12 +3108,7 @@ describe.if(isLinux)( `import { spawnSync } from 'node:child_process'`, `import { writeFileSync } from 'node:fs'`, `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, - `const wrapped = await wrapCommandWithSandboxLinux({`, - ` command: ${JSON.stringify(command)},`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, + wrapLine([AREA], command, [LOCK]), `writeFileSync(${JSON.stringify(WRAPPED)}, wrapped)`, `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, `writeFileSync(${JSON.stringify(OUT)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, @@ -3441,14 +3251,6 @@ describe.if(isLinux)('The manifest directories at their real names', () => { } } - async function until(file: string): Promise { - const deadline = Date.now() + 30000 - while (!existsSync(file)) { - if (Date.now() > deadline) throw new Error(`${file} never came`) - await new Promise(resolve => setTimeout(resolve, 25)) - } - } - it.if(BWRAP_CAN_NAMESPACE)( 'can be neither made, written, moved aside, removed nor replaced from inside a sandbox that may write all around them', async () => { @@ -3501,7 +3303,7 @@ describe.if(isLinux)('The manifest directories at their real names', () => { ].join('; '), [AREA, `/run/user/${UID}`], ) - await until(join(AREA, 'up')) + await waitFor(join(AREA, 'up')) expect( readdirSync(join(ROOT, `/run/user/${UID}/srt-mount-points`)), ).toHaveLength(2) @@ -3537,16 +3339,16 @@ describe.if(isLinux)('The manifest directories at their real names', () => { // The second wrap finds the mount point of the first. Unless it reads // that one's manifest it takes it for the user's own file and names it // nowhere, and the first process's clean-up takes it from under it. - const waitFor = (name: string): string => + const held = (name: string): string => `echo up > ${AREA}/up-${name}; while [ ! -e ${AREA}/end-${name} ]; do sleep 0.05; done` - const first = running('first', waitFor('first'), [AREA]) - await until(join(AREA, 'up-first')) + const first = running('first', held('first'), [AREA]) + await waitFor(join(AREA, 'up-first')) const second = running( 'second', - `${waitFor('second')}; echo pwned > ${LOCK}; echo rc=$?`, + `${held('second')}; echo pwned > ${LOCK}; echo rc=$?`, [AREA], ) - await until(join(AREA, 'up-second')) + await waitFor(join(AREA, 'up-second')) writeFileSync(join(AREA, 'end-first'), '') await first.said expect(lstatSync(LOCK).size).toBe(0) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index e5910cd04..0605a7862 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -35,6 +35,16 @@ import { } from '../../src/sandbox/bwrap-mount-manifests.js' import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + deadPid, + leftover, + recordOf, + started, + startOf, + statLine, + thisProcess, + writeManifest, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { makePlaces, @@ -87,87 +97,11 @@ describe.if(isLinux)('The mount point manifests', () => { rmSync(BASE, { recursive: true, force: true }) }) - /** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ - function leftover(p: string): void { - writeFileSync(p, '') - chmodSync(p, 0o444) - } - - /** A pid nothing in this PID namespace has. */ - function deadPid(): number { - for (let pid = 4194000; pid > 1; pid--) { - if (!existsSync(`/proc/${pid}`)) return pid - } - throw new Error('no free pid') - } - - /** - * The manifest of a wrap whose process is gone and which is long past its - * grace: what a killed process leaves. `fields` overrides what it says. - */ - function manifestOfADeadProcess( + /** What a killed process leaves in DIR. `fields` overrides what it says. */ + const manifestOfADeadProcess = ( paths: string[], fields: Record = {}, - ): string { - const pid = deadPid() - const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) - const body: Record = { - version: 1, - pid, - start: '1', - ns: OWN_NAMESPACE, - created: Date.now() - 60_000, - paths, - sources: [], - ...fields, - } - for (const [key, value] of Object.entries(body)) { - if (value === undefined) delete body[key] - } - writeFileSync(file, JSON.stringify(body), { mode: 0o600 }) - return file - } - - /** Field 22 of a /proc/PID/stat line: when that process started. */ - function startOf(stat: string): string { - return stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19]! - } - - /** This process, which is running, as a manifest or a record names one. */ - function thisProcess(): { pid: number; start: string } { - return { - pid: process.pid, - start: startOf(readFileSync('/proc/self/stat', 'utf8')), - } - } - - /** - * The line the wrapper shell records for a process, with a name that holds - * what a careless reading would trip over. - */ - function statLine(who: { pid: number; start: string }): string { - const after = ['S', ...Array(18).fill('0'), who.start, '0', '0'] - return `${who.pid} (a) b (c) ${after.join(' ')}\n` - } - - /** Where the started record of `manifest` is. */ - function recordOf(manifest: string): string { - return manifest.replace(/\.json$/, '.started') - } - - /** Puts `manifest` on record as started by those processes, in that order. */ - function started( - manifest: string, - ...by: { pid: number; start: string }[] - ): string { - writeFileSync(recordOf(manifest), by.map(statLine).join(''), { - mode: 0o600, - }) - return manifest - } + ): string => writeManifest(DIR, paths, fields) /** The manifest this process publishes for a wrap that names `paths`. */ function publish(paths: string[], sources: string[] = []): string { @@ -3053,19 +2987,7 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(theirs, { mode: 0o700 }) chmodSync(theirs, 0o700) leftover(X) - const manifest = manifestOfADeadProcess([X], { - pid: process.pid, - start: readFileSync('/proc/self/stat', 'utf8') - .split(') ') - .pop()! - .split(' ')[19], - }) - writeFileSync( - join(theirs, '4242-0123456789abcdef.json'), - readFileSync(manifest, 'utf8'), - { mode: 0o600 }, - ) - rmSync(manifest) + writeManifest(theirs, [X], thisProcess()) const run = join(BASE, 'run') mkdirSync(run, { mode: 0o700 }) const looked = inAChild( diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index ebbd339d5..70972f6f4 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -30,6 +30,19 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { manifestOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + deadPid, + leftover, + type Process, + processAt, + run, + sleep, + started, + until, + wrapIn, + writeManifest, + writeScript, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -86,34 +99,6 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { rmSync(BASE, { recursive: true, force: true }) }) - /** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ - function leftover(p: string): void { - writeFileSync(p, '') - chmodSync(p, 0o444) - } - - /** A pid nothing in this PID namespace has. */ - function deadPid(): number { - for (let pid = 4194000; pid > 1; pid--) { - if (!existsSync(`/proc/${pid}`)) return pid - } - throw new Error('no free pid') - } - - type Process = { pid: number; start: string } - - /** That process as a manifest, a record or a pass names one. */ - function processAt(pid: number): Process { - const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') - return { - pid, - start: stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19]!, - } - } - /** Another process of this user, which runs until the test is over. */ function anotherProcess(): Process { const other = spawn('sleep', ['600'], { stdio: 'ignore' }) @@ -121,38 +106,9 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { return processAt(other.pid!) } - /** What a killed process leaves: its manifest, long past its grace. */ - function manifestOfADeadProcess(paths: string[], writer?: Process): string { - const { pid, start } = writer ?? { pid: deadPid(), start: '1' } - const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) - writeFileSync( - file, - JSON.stringify({ - version: 1, - pid, - start, - ns: OWN_NAMESPACE, - boot: OWN_BOOT, - created: Date.now() - 60_000, - paths, - sources: [], - }), - { mode: 0o600 }, - ) - return file - } - - /** Puts `manifest` on record as started by those processes. */ - function started(manifest: string, ...by: Process[]): string { - const line = (who: Process): string => - `${who.pid} (bwrap) ${['S', ...Array(18).fill('0'), who.start, '0'].join(' ')}\n` - writeFileSync( - manifest.replace(/\.json$/, '.started'), - by.map(line).join(''), - { mode: 0o600 }, - ) - return manifest - } + /** What a killed process leaves in DIR, or one that `writer` wrote. */ + const manifestOfADeadProcess = (paths: string[], writer?: Process): string => + writeManifest(DIR, paths, { boot: OWN_BOOT, ...writer }) /** Puts a pass of `who` on record, as the library names one. */ function passOf( @@ -178,42 +134,11 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { const passes = (): string[] => readdirSync(DIR).filter(name => name.endsWith('.pass')) - function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) - } - - async function until(what: string, holds: () => boolean): Promise { - const deadline = Date.now() + 30000 - while (!holds()) { - if (Date.now() > deadline) - throw new Error(`timed out waiting for ${what}`) - await sleep(10) - } - } + const wrap = (command = 'true', deny = [LOCK]): Promise => + wrapIn(AREA, command, deny) - function wrap(command = 'true', deny = [LOCK]): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: deny }, - }) - } - - function run(command: string): { status: number | null; said: string } { - const r = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 30000, - }) - return { status: r.status, said: `${r.stdout}${r.stderr}` } - } - - function script(name: string, lines: string[]): string { - const file = join(BASE, name) - writeFileSync(file, lines.join('\n')) - return file - } + const script = (name: string, lines: string[]): string => + writeScript(BASE, name, lines) // ---- the pass ------------------------------------------------------------ diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 61bdc5f4e..1cc71188b 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -7,9 +7,7 @@ import { lstatSync, mkdirSync, mkdtempSync, - readFileSync, readdirSync, - readlinkSync, realpathSync, rmSync, rmdirSync, @@ -23,8 +21,17 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' +import { + leftover, + namedBy, + type Process, + thisProcess, + wrapIn, + wrapLine, + writeManifest, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' -import { countMounts, lastMountAt, manifestOf } from '../helpers/bwrap-argv.js' +import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -83,87 +90,19 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { rmSync(BASE, { recursive: true, force: true }) }) - async function wrap(denyPaths: string[], command = 'true'): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: denyPaths }, - }) - } - - /** - * What bwrap's ensure_file(dest, 0444) leaves on the host, and what - * somebody's own empty read-only file looks like. - */ - function plantEmptyReadOnlyFile(p: string): void { - writeFileSync(p, '') - chmodSync(p, 0o444) - } - - /** - * The manifest of a wrap that named `paths`, written a minute ago by `pid`, - * by default a process that is gone. Returns where it is written. - */ - function manifestNaming( - paths: string[], - writer: { pid: number; start: string } = { pid: deadPid(), start: '1' }, - ): string { - const dir = runtime.manifestDir() - mkdirSync(dir, { recursive: true, mode: 0o700 }) - chmodSync(dir, 0o700) - const file = join( - dir, - `${writer.pid}-${Math.random().toString(16).slice(2).padEnd(16, '0')}.json`, - ) - writeFileSync( - file, - JSON.stringify({ - version: 1, - ...writer, - ns: readlinkSync('/proc/self/ns/pid'), - created: Date.now() - 60_000, - paths, - sources: [], - }), - { mode: 0o600 }, - ) - return file - } - - /** A pid nothing in this PID namespace has. */ - function deadPid(): number { - let pid = 4194000 - while (existsSync(`/proc/${pid}`)) pid-- - return pid - } + const wrap = (denyPaths: string[], command = 'true'): Promise => + wrapIn(AREA, command, denyPaths) - /** This process, as a manifest names its writer: running, so it is live. */ - function thisProcess(): { pid: number; start: string } { - const stat = readFileSync('/proc/self/stat', 'utf8') - return { - pid: process.pid, - start: stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(' ')[19]!, - } - } + /** One that `writer` wrote, by default a process that is gone. */ + const manifestNaming = (paths: string[], writer?: Process): string => + writeManifest(runtime.manifestDir(), paths, writer) /** What a killed process leaves: the file, and the manifest that names it. */ function plantLeftover(p: string): string { - plantEmptyReadOnlyFile(p) + leftover(p) return manifestNaming([p]) } - /** The mount points the wrap named in its manifest, none without one. */ - function namedBy(command: string): string[] { - const file = manifestOf(command) - return file === undefined - ? [] - : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths - } - // 0444 is what bubblewrap makes from 0.5.0, 0666 less the umask before. it.each([0o444, 0o644, 0o666, 0o600])( 'is covered with /dev/null like an absent path, named, and removed after the command, whatever its mode: %o', @@ -213,7 +152,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ 'an empty read-only file with a second link', (p: string) => { - plantEmptyReadOnlyFile(p) + leftover(p) linkSync(p, `${p}.other-name`) }, ], @@ -256,7 +195,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it.each([0o444, 0o644])( "is the caller's own where no manifest names it: bound onto itself, named by the wrap nowhere, and never removed (mode %o)", async mode => { - plantEmptyReadOnlyFile(LOCK) + leftover(LOCK) chmodSync(LOCK, mode) // A manifest that names something else changes nothing about it. manifestNaming([join(GIT_DIR, 'other.lock')]) @@ -278,7 +217,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it("is the caller's own at a mandatory deny path as well, beside the mount points the wrap makes for the others", async () => { const project = join(AREA, 'repo') const own = join(project, '.mcp.json') - plantEmptyReadOnlyFile(own) + leftover(own) const cwd = process.cwd() process.chdir(project) let command: string @@ -305,8 +244,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { async () => { const project = join(AREA, 'repo') const mandatory = join(project, '.mcp.json') - plantEmptyReadOnlyFile(mandatory) - plantEmptyReadOnlyFile(LOCK) + leftover(mandatory) + leftover(LOCK) const before = [mandatory, LOCK].map(p => lstatSync(p).ino) const attempts = [mandatory, LOCK] @@ -361,7 +300,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { if (file === LOCK && !made) { made = true manifestNaming([LOCK], thisProcess()) - plantEmptyReadOnlyFile(LOCK) + leftover(LOCK) } return exists(file as string) }) as never) @@ -409,7 +348,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { // ---- an empty read-only file that a live manifest names ---------------- it('stays, covered and named, for as long as the manifest of a running sandbox names it, and goes once none does', async () => { - plantEmptyReadOnlyFile(LOCK) + leftover(LOCK) // Of another wrap, whose writer is running: live. const live = manifestNaming([LOCK], thisProcess()) @@ -432,7 +371,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it('leaves an empty read-only file alone where no sandbox could have made it: outside every allowed write path', async () => { const outsideFile = join(OUTSIDE, 'config.lock') - plantEmptyReadOnlyFile(outsideFile) + leftover(outsideFile) const command = await wrap([outsideFile]) @@ -483,13 +422,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( join(import.meta.dir, '../helpers/isolated/library.ts'), )}`, - `const command = await wrapCommandWithSandboxLinux({`, - ` command: 'true',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, - `const r = spawnSync(command, { shell: true, timeout: 15000 })`, + wrapLine([AREA], 'true', [LOCK]), + `const r = spawnSync(wrapped, { shell: true, timeout: 15000 })`, `if (r.status !== 0) process.exit(3)`, `process.kill(process.pid, 'SIGKILL')`, ].join('\n'), @@ -569,17 +503,10 @@ describe.if(isLinux)( rmSync(BASE, { recursive: true, force: true }) }) - async function wrap( + const wrap = ( denyPaths: string[] = [], command = 'echo hello', - ): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: denyPaths }, - }) - } + ): Promise => wrapIn(AREA, command, denyPaths) /** * Like wrap(), but with the temp dir the sources are minted under writable From 255a220d768f0c1996cbc672a5a08fdff30bf2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:46:56 +0000 Subject: [PATCH 105/141] linux: the protocol is told once in the source The module's header retold what collect() states as invariants and the README tells in full; it now says what each file in the directory is and where its rules are. liveMountPoints() repeated its README section. One invariant of the wrap stood above the publishing and is about the binds. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 106 +++++++-------------------- src/sandbox/linux-sandbox-utils.ts | 26 +++---- 2 files changed, 36 insertions(+), 96 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 5dd194713..34a163480 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -6,71 +6,33 @@ * a sandbox is bound over it detaches the mount inside that sandbox, and the * denied path can then be created on the host. So a mount point may be removed * only when no running sandbox relies on it, and no process can decide that by - * counting for itself. + * counting for itself. README.md, "Write denies on paths that do not exist yet", + * tells it in full. + * What is kept in a per-user directory, and where its rules are: * - * The sandbox vouches for itself. Each wrap writes a manifest, `.json`, into - * a per-user directory, naming its mount points and every mount point of - * another sandbox that its own mounts lie in or bind from. The command line - * it hands out is a shell that appends its own /proc/self/stat line to - * `.started` and then execs bubblewrap: the process on record is bubblewrap, - * recorded before it has made a mount, and the wrap always passes - * --die-with-parent, so the sandbox dies with it. A manifest is live while a - * process with a recorded pid and start time exists, and while the process that - * wrote it exists and has not let go of it (the command line may be run again - * until then), which it does by claiming it. Any process can ask after both, so - * cleanup is a garbage collect any process may run at any time. - * - * bubblewrap binds the manifest before it makes a mount point, and a pass - * claims a finished manifest, renaming it to `.claimed`, before it removes - * what that names. A command started after the claim fails having made nothing; - * one that got past it has its record on disk, which the pass reads after the - * claim. Whatever cannot be read or asked (a manifest, a record, /proc) counts - * as live: only "no such process", one that has exited and a different start - * time end a sandbox. - * - * A pass and a wrap each say what they are about before they look at what the - * other has said. The pass puts itself on record, `.pass`, and then - * reads the manifests it decides on; the wrap publishes its manifest and then - * lists the passes, and waits for those under way to end before it looks at the - * host again and hands its command out. So either the pass has read the - * manifest and keeps what it names, or the wrap has seen the pass and no - * sandbox starts until that has removed what it set out to, however long it is - * held up between its reading and a removal (see {@link collect}). + * - `.json`, a manifest: what a wrap relies on, written before its command + * is handed out and bound by bubblewrap before it makes anything ({@link + * publishMountPointManifest}). + * - `.started`, its record: the command line appends its own + * /proc/self/stat line and then execs bubblewrap, so the sandbox vouches for + * itself ({@link recordVouches}, {@link isLive}). + * - `.claimed` and `.finished`, the names a clean-up gives a manifest + * before it removes what that names, and `.pass`, the clean-up's own + * record, which wraps wait for ({@link collect}, which holds the invariants). * + * Whatever cannot be read or asked (a manifest, a record, /proc) counts as live. * A reading in doubt refuses every wrap of this user that restricts writes, so - * NO LASTING STATE OF THE DIRECTORY REFUSES WRAPS FOR EVER. Every source of doubt - * is either a condition of this process that clears by itself (EMFILE, ENFILE, - * ENOMEM, EIO on a read), which the age of a file never ends, or a property of - * what is at a name, which has an end: an hour, or the process on its record. - * No sandboxed command can write the directory (see below), so what is there - * and should not be comes from an accident on the host. - * - * Two processes protect each other only when each reads what the other writes, - * and a mount point whose manifest is lost stays for good. So the directories - * follow from the user id, not the environment, and the first survives a logout - * and a reboot (see {@link sharedManifestDirectoryNames}). + * NO LASTING STATE OF THE DIRECTORY REFUSES WRAPS FOR EVER, AND NONE KEEPS WHAT + * IS LEFT FOR EVER: every source of doubt is a condition of this process that + * clears by itself, or a property of what is at a name, which has an end (an + * hour, seven days, the process on a record, the boot). * * INVARIANT: all of this rests on the sandboxed command, which has the same * uid, being unable to write OR CREATE a directory a pass believes: one it made - * itself would pass every check of owner and mode. So every wrap that restricts - * writes makes every one of them, binds it read-only and pins what lies above - * it inside a write root, before it hands the command out (see {@link + * itself would pass every check of owner and mode (see {@link * mountPointManifestDirectories}). Not covered: a sandbox this library did not * start; a name, or one above it, that is a link inside a write root; a parent * that is not there, where the command may make it. - * - * /proc/PID is relative to a PID namespace, so a manifest records the namespace - * that wrote it and only a process in that namespace judges it; to any other it - * is live, for {@link OTHER_NAMESPACE_MAX_AGE_MS}. It records the boot too: no - * process of an earlier one runs, where only this kernel reaches the directory. - * NO LASTING STATE KEEPS WHAT IS LEFT FOR EVER. - * - * So a pass that can judge says what it found, for those that cannot: it renames - * the claim of a manifest with no process left to `.finished`, before the - * reading it decides on, and they keep nothing on the word of a manifest at that - * name. Else two passes that cannot judge each other's manifests would each keep - * a path because the other's names it and drop their own, and nothing would name - * the path any more (see {@link collect}). */ import { randomBytes } from 'node:crypto' @@ -1353,31 +1315,17 @@ function isEmptyMountPoint(p: string): boolean { /** * The mount points a sandbox that may still be running relies on, as the * manifests say at the moment of the call. For a caller that removes paths of - * its own accord after a command: removing a mount point from under a running - * sandbox lifts the deny there. - * - * `undefined` when it cannot tell: a manifest directory, its listing, a - * manifest or a record without a manifest cannot be read or accounted for, so a - * sandbox may be running on paths that cannot be listed. The caller must then - * skip every removal of an empty file or an empty directory. It does not throw. - * - * A snapshot that may be out of date by the time the caller acts. Its only safe - * use is to SKIP a removal; a path not being in it never means the path is free - * to write. - * - * A path is in the set only when both hold: + * its own accord after a command: removing one from under a running sandbox + * lifts the deny there. README.md, "Removing a path yourself", has the contract. * - * - a manifest names it that is live by the clean-up's rule, or this process - * could record it nowhere; - * - what is at the path now is still an empty placeholder, or cannot be looked - * at: an empty regular file of any mode with one link, or an empty directory, - * and the user's. So a stale or forged manifest never makes a caller spare a - * file with content. + * `undefined` when it cannot tell: the caller must then skip every removal of an + * empty file or an empty directory. It does not throw. A snapshot, whose only + * safe use is to SKIP a removal. * - * Paths are as the wraps recorded them: absolute, links above the last - * component resolved. It answers for the directories this process believes - * (see {@link believedManifestDirectories}), so a sandbox whose process keeps - * manifests elsewhere is not seen. Makes nothing on the host. Empty off Linux. + * A path is in the set only while a live manifest names it (or this process + * could record it nowhere) AND what is there is still an empty placeholder of + * the user's, or cannot be looked at: a stale or forged manifest never makes a + * caller spare a file with content. Makes nothing on the host. Empty off Linux. */ export function liveMountPoints(): ReadonlySet | undefined { const spared = new Set() diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index ee9f91c5f..dbbea6934 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3739,20 +3739,6 @@ async function generateFilesystemArgs( // a reading taken after the last look at the host above. A directory it left // out (the one a placeholder is nested in, a pin) goes with its maker, and the // command is then refused its start or, once the pin is mounted, loses it. - // - // INVARIANT: a manifest directory must never be writable from inside a - // sandbox. A command that could delete a manifest could make a running - // sandbox's mount points look unused, and one that could write a manifest - // could name any path for a collect on the host to remove. This holds for - // every wrap that restricts writes, whether or not it names a manifest, and - // for every directory some process of this user keeps manifests in, so each - // is bound read-only (see mountPointManifestDirectories) with what lies above - // it pinned. Emitted after every allow and deny bind, like the two stores - // below. - // - // Each is bound where it really is, not at the name it was worked out as: - // bubblewrap makes a bind's destination by name inside the new root, and a - // link with an absolute target on the way leads nowhere from there. const names = [...mountPoints, ...mountPointsReliedOn()] const manifest = publishMountPointManifest( names, @@ -3829,9 +3815,15 @@ async function generateFilesystemArgs( if (!holds) { return undefined } - // A read deny that covers a manifest directory keeps the command out of it - // already. Bound on top of that, it could be read after all: other projects' - // paths, the pids of their sandboxes. + // INVARIANT: no manifest directory is writable from inside a sandbox, whether + // or not the wrap names a manifest (see mountPointManifestDirectories). Each + // is bound read-only after every allow and deny bind, like the two stores + // below, and where it really is: bubblewrap makes a bind's destination by name + // inside the new root, and a link with an absolute target leads nowhere there. + // + // A read deny that covers one keeps the command out of it already. Bound on + // top of that, it could be read after all: other projects' paths, the pids of + // their sandboxes. const isInReach = (dir: string): boolean => !isHiddenByTmpfs(dir, 'writes and reads') let started: string | undefined From 35d5852a4bd1041e0a3070cba79f6340445bcfcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:04:59 +0000 Subject: [PATCH 106/141] linux: say what the listing before each removal is for Its comment gave only the lesser reason, that it spares a waiting wrap a second plan, which reads as if the record of a clean-up had made it redundant. It has not, for a clean-up that no wrap can see and for a directory made since. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 34a163480..57447e077 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -1601,11 +1601,15 @@ const childrenFirst = (paths: Iterable): string[] => * starts under that manifest until the pass is over, for however long the pass * is held up between its reading and a removal. * - * The directories, worked out afresh, are still listed again before every + * INVARIANT: the directories, worked out afresh, are listed again before every * removal (in a pass of over {@link LISTS_BEFORE_EACH_REMOVAL_UP_TO} * candidates, whenever the listing is {@link LISTING_GOOD_FOR_MS} old), and - * what has come to its name keeps what it names: the wrap that waits then finds - * what it planned on and need not plan again. + * what has come to its name keeps what it names. The record above does not make + * this redundant. A wrap sees no record of a pass that could go on none, or only + * in a directory the wrap does not list (one inside another sandbox, which reads + * the shared places and cannot write them); and a pass is on no record in a + * directory that was made after it worked out where to go. Where the wrap does + * wait, it then finds what it planned on and need not plan again. * * A manifest is all that names its mount points, so its claim is dropped last, * and stays, for a later pass, when the pass turns back, a removal is refused, From 016902bd63d85cba34007c8b0957b8c52c8ade3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:39:30 +0000 Subject: [PATCH 107/141] test: the two zombie cases no longer race the shell that makes the zombie A shell may wait for a child that ends before it execs: in 8 of 300 runs of `true & echo $!; exec sleep 600` the child was gone and no zombie, and the case then failed on ENOENT from /proc. The child now ends once the shell has become the program that never waits. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-passes.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 70972f6f4..de464c2fc 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -885,16 +885,29 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { // ---- a process that has exited and is not waited for ------------------------- + /** Whether the shell started as `pid` has become the `sleep` it execs. */ + const isSleep = (pid: number): boolean => + readFileSync(`/proc/${pid}/comm`, 'utf8') === 'sleep\n' + it('takes a process that has exited for gone, though nothing has asked after it yet', async () => { // The shell starts a child and becomes a program that never waits for one. - const parent = spawn('sh', ['-c', 'true & echo $!; exec sleep 600'], { - stdio: ['ignore', 'pipe', 'ignore'], - }) + // The child ends only then: a shell may wait for one that ends before. + const go = join(BASE, 'go') + const parent = spawn( + 'sh', + [ + '-c', + `(while [ ! -e ${go} ]; do sleep 0.02; done) & echo $!; exec sleep 600`, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ) others.push(parent) let said = '' parent.stdout.on('data', chunk => (said += String(chunk))) await until('the pid of the child', () => said.includes('\n')) const pid = Number(said) + await until('the shell to be gone', () => isSleep(parent.pid!)) + writeFileSync(go, '') await until('the child to have exited', () => / Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')), ) @@ -931,6 +944,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) + await until('the shell to be gone', () => isSleep(parent.pid!)) process.kill(bwrap, 'SIGKILL') await until('bubblewrap to have exited', () => / Z /.test(readFileSync(`/proc/${bwrap}/stat`, 'utf8')), From f2f10a2fc419fa9b176d07c8b64f374d7dc022ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:04:45 +0000 Subject: [PATCH 108/141] linux: a plan reads the manifests before it makes anything on the host On a 9p drive the plan makes the mount point itself. A wrap that was refused because the manifests could not be read had made it by then, and the clean-up at exit, in the same doubt, removes nothing: it stayed, with nothing to name it. The cases that hold the tracking of what a plan has made now raise the doubt as the mount point is made, and cover one that is made as a directory. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/linux-sandbox-utils.ts | 4 + .../linux-unmakeable-mount-point.test.ts | 111 ++++++++++++------ test/sandbox/mount-point-liveness.test.ts | 29 +++-- 4 files changed, 103 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index d48252c4b..6791e2f93 100644 --- a/README.md +++ b/README.md @@ -1128,7 +1128,7 @@ Filesystem restrictions are enforced at the OS level: **Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), which has to know `local`, as dash, ash and mksh do, and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and those of processes that are gone or have let go of them, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. -- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made before the wrap's manifest is published, so a process killed between the two leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. +- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made once the manifests have been read, so a wrap that is refused because they cannot be (`mount_points_unreadable`) has made nothing, and before the wrap's own manifest is published: a wrap that comes to nothing in between has it removed by the process's next clean-up or at its exit, and a process killed in between leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. - **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. While one of them cannot be looked at (an error of the moment, not "not there"), no command that restricts writes is handed out (`mount_points_unreadable`): left unbound, it would be believed again a moment later. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index dbbea6934..88900c86b 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3063,6 +3063,10 @@ async function generateFilesystemArgs( // tools that expect a directory there. Whatever the order of denyPaths. const dest = findFirstNonExistentComponent(absentPath) decidedMountPoints.add(dest) + // The manifests are read before anything is made on the host (see + // makeableMountPoint). A plan refused for what they cannot tell would + // leave it there, and a clean-up in the same doubt removes nothing. + if (named === undefined) whatIsNamedNow() const kind = makeableMountPoint( dest, denyPathAncestors.has(dest) diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index 4c49e4482..0c794f2ec 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -454,7 +454,6 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) fs.chmodSync(join(BASE, 'run'), 0o700) const junk = join(manifests, '1-junk.json') - fs.writeFileSync(junk, '{}') const child = spawnSync( process.execPath, [ @@ -462,6 +461,11 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { `const fs = require('node:fs') const statfs = fs.statfsSync fs.statfsSync = (...args) => ({ ...statfs(...args), type: ${V9FS_MAGIC} }) + const open = fs.openSync + fs.openSync = (p, ...rest) => { + if (String(p).endsWith('/policy.json')) fs.writeFileSync(${JSON.stringify(junk)}, '{}') + return open(p, ...rest) + } const { wrapCommandWithSandboxLinux } = await import('./test/helpers/isolated/library.ts') const refused = await wrapCommandWithSandboxLinux({ command: 'true', @@ -481,44 +485,81 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { expect(listing()).toEqual(['own.empty']) }) - it.each(['a forced clean-up', 'the next wrap and its clean-up'])( - 'has what it made removed by %s when the plan comes to nothing', - async by => { - const absent = join(DIR, 'policy.json') - const own = join(DIR, 'own.empty') - fs.writeFileSync(own, '') - // What is no manifest puts the reading in doubt that the caller's empty - // file asks for, once the mount point has been made. - const manifests = join( - process.env['XDG_RUNTIME_DIR']!, - 'srt-mount-points', + describe.each([ + ['file', 'policy.json', 'policy.json', 'openSync'], + ['directory', 'absent/policy.json', 'absent', 'mkdirSync'], + ] as const)( + 'when the plan comes to nothing, the %s it made', + (_kind, denied, made, makes) => { + it.each(['a forced clean-up', 'the next wrap and its clean-up'])( + 'is removed by %s', + async by => { + const absent = join(DIR, denied) + const own = join(DIR, 'own.empty') + fs.writeFileSync(own, '') + // What is no manifest puts the readings in doubt, from the moment + // the mount point is made: the plan has read the manifests by then. + const manifests = join( + process.env['XDG_RUNTIME_DIR']!, + 'srt-mount-points', + ) + fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + stub(makes, p => { + if (names(p, join(DIR, made))) { + fs.writeFileSync(join(manifests, '1-junk.json'), '{}') + } + return undefined + }) + try { + const refused = await wrap([absent, own]).then( + () => undefined, + (error: unknown) => error, + ) + expect((refused as { code?: string }).code).toBe( + 'mount_points_unreadable', + ) + } finally { + fs.rmSync(join(manifests, '1-junk.json')) + } + expect(listing()).toEqual([made, 'own.empty'].sort()) + if (by === 'a forced clean-up') { + cleanupBwrapMountPoints({ force: true }) + } else { + expect(mountsAt(await wrap([absent, own]), absent)).toEqual([ + `--ro-bind /dev/null ${absent}`, + ]) + cleanupBwrapMountPoints() + } + expect(listing()).toEqual(['own.empty']) + }, ) - fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) - fs.writeFileSync(join(manifests, '1-junk.json'), '{}') - try { - const refused = await wrap([absent, own]).then( - () => undefined, - (error: unknown) => error, - ) - expect((refused as { code?: string }).code).toBe( - 'mount_points_unreadable', - ) - } finally { - fs.rmSync(join(manifests, '1-junk.json')) - } - expect(listing()).toEqual(['own.empty', 'policy.json']) - if (by === 'a forced clean-up') { - cleanupBwrapMountPoints({ force: true }) - } else { - expect(mountsAt(await wrap([absent, own]), absent)).toEqual([ - `--ro-bind /dev/null ${absent}`, - ]) - cleanupBwrapMountPoints() - } - expect(listing()).toEqual(['own.empty']) }, ) + it('makes nothing while the manifests cannot be read, when no clean-up would remove it either', async () => { + const manifests = join( + process.env['XDG_RUNTIME_DIR']!, + 'srt-mount-points', + ) + fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + fs.writeFileSync(join(manifests, '1-junk.json'), '{}') + try { + const refused = await wrap([ + join(DIR, 'policy.json'), + join(DIR, 'absent', 'policy.json'), + ]).then( + () => undefined, + (error: unknown) => error, + ) + expect((refused as { code?: string }).code).toBe( + 'mount_points_unreadable', + ) + expect(listing()).toEqual([]) + } finally { + fs.rmSync(join(manifests, '1-junk.json')) + } + }) + it('covers a file with a directory in a folder that takes subfolders and no files', async () => { const denied = join(DIR, 'policy.json') refuse('openSync', denied) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 1a402e6bc..0b5642d41 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2217,7 +2217,8 @@ describe.if(isLinux)( it('plans again when its manifest went to a directory settled on since the pins', async () => { // Nothing is pinned above that one. Both shared names are taken as the - // wrap reads the manifests, so it publishes in a directory of its own. + // wrap reads the manifests again, with the pins made, so it publishes in + // a directory of its own. const shared = [ runtime.manifestDir(), join(tmpdir(), `srt-mount-points-${process.getuid!()}`), @@ -2228,7 +2229,7 @@ describe.if(isLinux)( ...rest: unknown[] ) => { const names = (readdir as (...args: unknown[]) => unknown)(dir, ...rest) - if (dir === shared[1]) { + if (pinned && dir === shared[1]) { for (const name of shared) { fs.rmdirSync(name) writeFileSync(name, '') @@ -2236,6 +2237,8 @@ describe.if(isLinux)( } return names }) as never) + let pinned = false + const pinning = beforeThePins(() => (pinned = true)) let published = 0 const publishing = atEachPublish(() => published++) try { @@ -2247,6 +2250,7 @@ describe.if(isLinux)( expect(readdirSync(own)).toEqual([basename(recordedBy(command)!)]) } finally { publishing.restore() + pinning.restore() spy.mockRestore() shared.forEach(name => rmSync(name)) } @@ -2290,12 +2294,23 @@ describe.if(isLinux)( expect(readdirSync(runtime.manifestDir())).toEqual([]) expect(emptySources()).toEqual([]) - // Before it publishes: what it made goes once a clean-up can tell. + // Before it publishes: what it made goes once a clean-up can tell. The + // manifests are read first, so the doubt comes as that is made. const junk = join(runtime.manifestDir(), '4242-00.json') - writeFileSync(junk, 'no manifest') - expect(await wrap('true', [LEAF]).catch((e: Error) => e.name)).toBe( - 'LinuxSandboxProfileError', - ) + const mkdtemp = fs.mkdtempSync + const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( + ...args: unknown[] + ) => { + writeFileSync(junk, 'no manifest') + return (mkdtemp as (...args: unknown[]) => unknown)(...args) + }) as never) + try { + expect(await wrap('true', [LEAF]).catch((e: Error) => e.name)).toBe( + 'LinuxSandboxProfileError', + ) + } finally { + spy.mockRestore() + } expect(emptySources()).toHaveLength(1) rmSync(junk) cleanupBwrapMountPoints() From 81468f98ba5763e0ce5d568a9692ef71b5d8cba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:04:46 +0000 Subject: [PATCH 109/141] linux: what a wrap could record nowhere is kept while another of its process is cleaned up after A wrap whose manifest could not be written (a full disk) keeps its mount points in memory. A second wrap that denied the same path named it in a manifest, and the clean-up after that second command removed it, under the sandbox of the first. What is kept in memory is now kept by every pass but the one that releases everything, and a published manifest takes out of memory only what a plan had made itself. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 42 +++++++++++++++------- test/sandbox/mount-point-manifests.test.ts | 19 ++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 57447e077..e513bf43b 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -202,13 +202,18 @@ const ownManifests = new Map< /** * The mount points, and the directories they bind from, that no manifest names: * of wraps that could record them nowhere, and what a plan has made on the host - * itself until its manifest is published, which takes it out of here. No other - * process knows of them, so they are this process's to remove once none of its - * wraps is outstanding: in a pass like any other where there is a directory by - * then, so that a path another process's manifest names is kept, and on its own - * word where there is none. + * itself (`madeByAPlan`, which are among `paths`) until a manifest is published + * that names it, which takes it out of here. No other process knows of them, so + * they are this process's to remove once none of its wraps is outstanding, and + * kept until then: in a pass like any other where there is a directory by then, + * so that a path another process's manifest names is kept, and on its own word + * where there is none. */ -const unrecorded = { paths: new Set(), sources: new Set() } +const unrecorded = { + paths: new Set(), + sources: new Set(), + madeByAPlan: new Set(), +} /** Has `source` removed at this process's own clean-up if no manifest keeps it. */ export function keepTrackOfMountSource(source: string): void { @@ -221,6 +226,7 @@ export function keepTrackOfMountSource(source: string): void { */ export function keepTrackOfMountPoint(mountPoint: string): void { unrecorded.paths.add(mountPoint) + unrecorded.madeByAPlan.add(mountPoint) } /** @@ -1055,7 +1061,13 @@ export function publishMountPointManifest( fs.writeFileSync(temporary, text, { mode: 0o600 }) fs.renameSync(temporary, file) ownManifests.set(file, { commandKey, over: false }) - for (const mountPoint of mountPoints) unrecorded.paths.delete(mountPoint) + // Not what an earlier wrap could record nowhere and this one names too: + // that wrap's sandbox may outlast this manifest. + for (const mountPoint of mountPoints) { + if (unrecorded.madeByAPlan.delete(mountPoint)) { + unrecorded.paths.delete(mountPoint) + } + } return { dir, file, started: `${id}${STARTED_SUFFIX}` } } catch (e) { try { @@ -1069,7 +1081,10 @@ export function publishMountPointManifest( ) } } - for (const mountPoint of mountPoints) unrecorded.paths.add(mountPoint) + for (const mountPoint of mountPoints) { + unrecorded.paths.add(mountPoint) + unrecorded.madeByAPlan.delete(mountPoint) + } for (const source of sources) unrecorded.sources.add(source) return undefined } @@ -1550,8 +1565,7 @@ export function collectMountPoints(release: OwnManifestRelease): string[] { for (const source of unrecorded.sources) { if (removeMountSource(source) === 'removed') removed.push(source) } - unrecorded.paths.clear() - unrecorded.sources.clear() + Object.values(unrecorded).forEach(named => named.clear()) } return removed } @@ -1713,11 +1727,15 @@ function removeWhatIsFinished( } const kept = new Set() - const keep = (manifest: Manifest): void => { + const keep = ( + manifest: Record<'paths' | 'sources', Iterable>, + ): void => { for (const named of [...manifest.paths, ...manifest.sources]) { kept.add(named) } } + // A wrap of this process is outstanding, and may be the one that relies on it. + if (!unrecordedToo) keep(unrecorded) const finished: Manifest[] = [] for (const manifest of reading.manifests) { const asked = canBeAskedAfter(manifest) @@ -1821,7 +1839,7 @@ function removeWhatIsFinished( } } if (unrecordedToo) { - for (const named of [unrecorded.paths, unrecorded.sources]) { + for (const named of Object.values(unrecorded)) { for (const one of named) if (!notRemoved.has(one)) named.delete(one) } } diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 0605a7862..d96685e60 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -2551,6 +2551,25 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(X)).toBe(true) }) + it.each([ + ['left to bubblewrap to make', () => {}], + ['had made itself', () => keepTrackOfMountPoint(X)], + ])( + 'keeps what one wrap %s and could not record, while another wrap that names it too is cleaned up after', + (_, planned) => { + // The sandbox of the first may be running over it, and only this process + // knows: the manifest of the second is not all that names it. + planned() + leftover(X) + unrecorded([X]) + publishMountPointManifest([X], [], 'the second') + expect(collectMountPoints({ commandKey: 'the second' })).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(named()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) + }, + ) + it('publishes no manifest that it would not read', () => { const paths = (count: number): string[] => Array.from({ length: count }, (_, i) => From 2753345ce349d88fed9126e52bea5acb9a754c8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:04:46 +0000 Subject: [PATCH 110/141] linux: a clean-up is waited for by its record's own name, and a place with two names is bound once A wrap matched the clean-ups it waits for by their full paths. Where two of the places are one directory, which name that goes by can differ from one listing to the next; the record then seemed gone, and the command was handed out with the clean-up still under way. The same two names were both bound, at one destination. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 6 ++++-- src/sandbox/linux-sandbox-utils.ts | 10 ++++----- test/sandbox/mount-point-manifests.test.ts | 24 ++++++++++++++++++++++ test/sandbox/mount-point-passes.test.ts | 11 ++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index e513bf43b..97a315054 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -1509,8 +1509,10 @@ export async function passesToEnd( } await new Promise(resolve => setTimeout(resolve, pause)) signal?.throwIfAborted() - const underWay = new Set(passesUnderWay()) - left = left.filter(pass => underWay.has(pass)) + // By its own name, which no other has: a directory that has two may be + // listed by the other one this time. + const underWay = new Set(passesUnderWay().map(pass => path.basename(pass))) + left = left.filter(pass => underWay.has(path.basename(pass))) } } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 88900c86b..ebb6b275d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3856,11 +3856,11 @@ async function generateFilesystemArgs( // The others need only be kept out of the command's reach, so one that has // gone by the time the command starts - a temp dir whose parent was // replaced - is not an error. - for (const manifestDir of manifestDirs) { - const dir = canonicalForm(manifestDir) - if (manifestDir !== manifest?.dir && isInReach(dir)) { - args.push('--ro-bind-try', dir, dir) - } + // Each where it really is, once: two of the names may lead to one place. + const others = new Set(manifestDirs.map(canonicalForm)) + if (manifest !== undefined) others.delete(canonicalForm(manifest.dir)) + for (const dir of others) { + if (isInReach(dir)) args.push('--ro-bind-try', dir, dir) } } diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index d96685e60..7adba9251 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -34,6 +34,7 @@ import { setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { deadPid, @@ -2105,6 +2106,29 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) + it.each([ + ['names a mount point', ['absent.lock']], + ['names none', []], + ])( + 'are bound once where two of the names lead to one place, by a wrap that %s', + async (_, denied) => { + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + forgetMountPointManifestDirectory() + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [BASE], + denyWithinAllow: denied.map(name => join(BASE, name)), + }, + }) + expect( + mountsOf(command).filter(mount => mount.endsWith(` ${PLACES[2]!}`)), + ).toHaveLength(1) + }, + ) + it('goes by the name it can be written at where the other is read-only, as a read-only bind of it is', () => { rmSync(dirname(PLACES[0]!), { recursive: true }) symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index de464c2fc..03d1e3b11 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -566,6 +566,17 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { expect(passesUnderWay()).toEqual([later]) }) + it('is waited for by whichever name its directory is listed', async () => { + // Two of the places may be one directory, and which of the names that goes + // by may differ from one listing to the next. + const listedAs = join( + BASE, + 'the-other-name', + basename(passOf(anotherProcess())), + ) + expect(await passesToEnd([listedAs], undefined, 200)).toBe(listedAs) + }) + // ---- the wrap ---------------------------------------------------------------- it('holds back the command of a wrap that names a mount point, until it is over', async () => { From 2dbbf55d5c0ec93291ce63d90cd7f2d4df1facf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:04:46 +0000 Subject: [PATCH 111/141] test: three lines that no case held Each could be deleted with every suite green: the guard of liveMountPoints() off Linux, whose only case went with the duplicates; two of the three looks at a manifest directory that now throw; and the clean-up at exit of a process whose only wrap made the directory its mount points bind from and then came to nothing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-liveness.test.ts | 40 ++++++++++++++++++++++ test/sandbox/mount-point-manifests.test.ts | 31 ++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 0b5642d41..44240335a 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -1323,6 +1323,46 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + it('leaves no directory that its mount points bind from, when all the process did was a wrap that came to nothing', () => { + // Refused for a doubt that comes up once the directory is made. No plan + // held, which is what else has the clean-up at exit set up. + const TMP = join(BASE, 'tmp-of-its-own') + mkdirSync(TMP) + mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + const junk = JSON.stringify(join(runtime.manifestDir(), '4242-00.json')) + const isOne = (name: string): boolean => name.startsWith('claude-empty-') + const ended = spawnSync( + process.execPath, + [ + writeScript( + 'comes-to-nothing.ts', + [ + `const fs = require('node:fs')`, + `const mkdtemp = fs.mkdtempSync`, + `fs.mkdtempSync = (prefix, ...rest) => {`, + ` if (String(prefix).includes('claude-empty-')) fs.writeFileSync(${junk}, '{}')`, + ` return mkdtemp(prefix, ...rest)`, + `}`, + `const { wrapCommandWithSandboxLinux } = await import(${LIBRARY})`, + `const refused = await (async () => {`, + wrapLine([AREA], 'true', [join(AREA, 'absent', 'config.lock')]), + `})().then(() => undefined, error => error)`, + `console.log(refused?.code, fs.readdirSync(${JSON.stringify(TMP)}).filter(${String(isOne)}).length)`, + `fs.rmSync(${junk})`, + ].join('\n'), + ), + ], + { + env: { ...process.env, TMPDIR: TMP }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, + ) + expect(ended.stdout).toBe('mount_points_unreadable 1\n') + expect(readdirSync(TMP).filter(isOne)).toEqual([]) + }) + it('lets go of the mount points of a wrap that produced no command', async () => { // Left where a process killed before its cleanup would leave it, with the // manifest it would leave, so that both processes below name it. diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 7adba9251..760476289 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -31,6 +31,7 @@ import { namedMountPoints, type NamedMountPoints, publishMountPointManifest, + removePrivateManifestDirectory, setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' @@ -2401,6 +2402,31 @@ describe.if(isLinux)('The mount point manifests', () => { expect(mountPointManifestDirectories()).toContain(NAMED[1]!) }) + it.each([ + ['/run/user/UID, which one of them is in', () => dirname(PLACES[1]!)], + [ + 'the one only this process knows', + () => { + taken([...PLACES, ...NAMED]) + return recordedIn() + }, + ], + ])('cannot be told while %s cannot be looked at', (_, which) => { + try { + const look = failingCall('lstatSync', which(), 'EIO') + try { + expect(() => mountPointManifestDirectories()).toThrow('EIO') + } finally { + look.restore() + } + } finally { + collectMountPoints('all') + removePrivateManifestDirectory() + rmSync(DIR, { recursive: true, force: true }) + mkdirSync(DIR, { mode: 0o700 }) + } + }) + it('reads every directory again once it has claimed, not only where it claimed', () => { // Published in another directory since the first reading, and claimed // there by another pass that its sandbox got past. @@ -3046,9 +3072,12 @@ describe.if(isLinux)('The mount point manifests', () => { it('does nothing at all where the platform is not Linux', () => { // Every clean-up after a command, on every platform, comes through here. - // Off Linux it must make nothing. + // Off Linux it must make nothing. One manifest to collect, one to hold by. + const Y = join(BASE, 'second.lock') leftover(X) + leftover(Y) manifestOfADeadProcess([X]) + writeManifest(DIR, [Y], thisProcess()) const before = readdirSync(DIR).sort() const elsewhere = inAChild( [ From de39d0d6411f0abd21b6a53ea5031a40b9c87fcc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:04:47 +0000 Subject: [PATCH 112/141] test: a process that is gone and a record's name come from the fixtures everywhere The one was still spelled out 32 times beside the fixture for it, which nothing used, and the other twice. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-manifests.test.ts | 89 ++++++++++------------ test/sandbox/mount-point-passes.test.ts | 25 +++--- 2 files changed, 49 insertions(+), 65 deletions(-) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 760476289..632492e2b 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -38,6 +38,7 @@ import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-uti import { mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { + aDeadProcess, deadPid, leftover, recordOf, @@ -237,7 +238,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('removes what a finished manifest names once nothing else names it', () => { leftover(X) - started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + started(manifestOfADeadProcess([X]), aDeadProcess()) expect(collectMountPoints('all')).toEqual([X]) expect(existsSync(X)).toBe(false) // The manifest, its record and the claim on it went with it. @@ -498,10 +499,10 @@ describe.if(isLinux)('The mount point manifests', () => { it('is live while the process that wrote it runs, though no process on its record does: its command line may be run again', () => { leftover(X) - const manifest = started(manifestOfADeadProcess([X], thisProcess()), { - pid: deadPid(), - start: '1', - }) + const manifest = started( + manifestOfADeadProcess([X], thisProcess()), + aDeadProcess(), + ) expect(live()).toEqual([X]) expect(collectMountPoints('none')).toEqual([]) expect(existsSync(manifest)).toBe(true) @@ -512,7 +513,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = started( manifestOfADeadProcess([X], { created: Date.now() }), - { pid: deadPid(), start: '1' }, + aDeadProcess(), ) expect(live()).toEqual([]) expect(collectMountPoints('none')).toEqual([X]) @@ -530,7 +531,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('is live while any of the runs on its record lasts', () => { // The same command line run again, before the first run is over or after. leftover(X) - const gone = { pid: deadPid(), start: '1' } + const gone = aDeadProcess() started(manifestOfADeadProcess([X]), gone, thisProcess(), gone) expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) @@ -540,7 +541,7 @@ describe.if(isLinux)('The mount point manifests', () => { // What a command line run after its manifest was collected leaves: it // records itself, and bubblewrap then finds no manifest to bind. const left = join(DIR, '4242-0123456789abcdef.started') - writeFileSync(left, statLine({ pid: deadPid(), start: '1' })) + writeFileSync(left, statLine(aDeadProcess())) collectMountPoints('all') expect(existsSync(left)).toBe(false) }) @@ -620,7 +621,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(live)).toBe(true) // It goes once that sandbox has ended too. - started(live, { pid: deadPid(), start: '1' }) + started(live, aDeadProcess()) expect(collectMountPoints('all')).toEqual([X]) }) @@ -836,7 +837,7 @@ describe.if(isLinux)('The mount point manifests', () => { for (const code of ['EACCES', 'EMFILE']) { it(`removes nothing when it cannot ask after the process on a record: ${code}`, () => { leftover(X) - const gone = { pid: deadPid(), start: '1' } + const gone = aDeadProcess() started(manifestOfADeadProcess([X]), gone) expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( NOTHING_REMOVED, @@ -846,7 +847,7 @@ describe.if(isLinux)('The mount point manifests', () => { it(`removes nothing when it cannot ask after the writer of a manifest with no record: ${code}`, () => { leftover(X) - const gone = { pid: deadPid(), start: '1' } + const gone = aDeadProcess() manifestOfADeadProcess([X], gone) expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( NOTHING_REMOVED, @@ -876,10 +877,7 @@ describe.if(isLinux)('The mount point manifests', () => { it(`keeps what a manifest names when its record cannot be read: ${code} at the ${at}`, () => { leftover(X) - const manifest = started(manifestOfADeadProcess([X]), { - pid: deadPid(), - start: '1', - }) + const manifest = started(manifestOfADeadProcess([X]), aDeadProcess()) expect(withThat(failing(recordOf(manifest), code, at))).toEqual( NOTHING_REMOVED, ) @@ -906,7 +904,7 @@ describe.if(isLinux)('The mount point manifests', () => { ) }) - const gone = (): string => statLine({ pid: deadPid(), start: '1' }) + const gone = (): string => statLine(aDeadProcess()) const notARecord: [string, (record: string) => void][] = [ ['cut short of the start time', r => write(r, `${gone().slice(0, 30)}\n`)], ['not the line of a process', r => write(r, 'ended\n')], @@ -1018,7 +1016,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('is not held up by a record with no whole line and no manifest, and drops it past the grace', () => { // Nothing names a path, so it protects none. leftover(X) - started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + started(manifestOfADeadProcess([X]), aDeadProcess()) const [young, old, older] = ['4242', '4243', '4244'].map(pid => join(DIR, `${pid}-0123456789abcdef.started`), ) as [string, string, string] @@ -1124,10 +1122,7 @@ describe.if(isLinux)('The mount point manifests', () => { it("keeps what a manifest names while its record is somebody else's", () => { leftover(X) - const manifest = started(manifestOfADeadProcess([X]), { - pid: deadPid(), - start: '1', - }) + const manifest = started(manifestOfADeadProcess([X]), aDeadProcess()) const theirs = asSomebodyElses(recordOf(manifest)) expect(withThat(theirs)).toEqual(NOTHING_REMOVED) expect(existsSync(X)).toBe(true) @@ -1187,7 +1182,7 @@ describe.if(isLinux)('The mount point manifests', () => { // is a condition of this process, which the age of a file does not end. leftover(X) const manifest = manifestOfADeadProcess([X]) - aged(started(manifest, { pid: deadPid(), start: '1' }), 30 * 24 * 60) + aged(started(manifest, aDeadProcess()), 30 * 24 * 60) aged(recordOf(manifest), 30 * 24 * 60) forgetMountPointManifestDirectory() const spy = spyOn(fs, 'readlinkSync').mockImplementation((() => { @@ -1251,7 +1246,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([X]) expect(collectMountPoints('all')).toEqual([]) - started(manifest, { pid: deadPid(), start: '1' }) + started(manifest, aDeadProcess()) expect(live()).toEqual([]) expect(collectMountPoints('all')).toEqual([X]) expect(existsSync(X)).toBe(false) @@ -1307,7 +1302,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(collectMountPoints('all')).toEqual([]) expect(claims()).toEqual([finishedOf(outer)]) - started(inner, { pid: deadPid(), start: '1' }) + started(inner, aDeadProcess()) expect(collectMountPoints('all')).toEqual([...inside].reverse().concat(Q)) expect(readdirSync(DIR)).toEqual([]) }) @@ -1339,7 +1334,7 @@ describe.if(isLinux)('The mount point manifests', () => { thisProcess(), ) expect(collectMountPoints('all')).toEqual([]) - started(live, { pid: deadPid(), start: '1' }) + started(live, aDeadProcess()) expect(collectMountPoints('all')).toEqual([source]) }) @@ -1445,10 +1440,10 @@ describe.if(isLinux)('The mount point manifests', () => { const DAYS = 24 * 60 for (const ns of ['pid:[1]', undefined]) { leftover(X) - const manifest = started(manifestOfADeadProcess([X], { ns }), { - pid: deadPid(), - start: '1', - }) + const manifest = started( + manifestOfADeadProcess([X], { ns }), + aDeadProcess(), + ) for (const [written, recorded] of [ [7 * DAYS - 1, 7 * DAYS + 1], [7 * DAYS + 1, 7 * DAYS - 1], @@ -1544,10 +1539,10 @@ describe.if(isLinux)('The mount point manifests', () => { it("takes the record beside a manifest that is finished by its name for that one's", () => { leftover(X) - const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { - pid: deadPid(), - start: '1', - }) + const manifest = started( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + aDeadProcess(), + ) const theirs = finished(manifest) aged(recordOf(manifest)) expect(collectMountPoints('all')).toEqual([]) @@ -1614,10 +1609,10 @@ describe.if(isLinux)('The mount point manifests', () => { // Renamed while the directory was being listed. Taken for a record without // a manifest it would be dropped, and a wrap would take X for the caller's. leftover(X) - const manifest = started(manifestOfADeadProcess([X], { ns: 'pid:[1]' }), { - pid: deadPid(), - start: '1', - }) + const manifest = started( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + aDeadProcess(), + ) const theirs = finished(manifest) for (const look of [named, () => collectMountPoints('none')]) { const listing = missedByTheNextListing(basename(theirs)) @@ -1767,10 +1762,7 @@ describe.if(isLinux)('The mount point manifests', () => { it(`is live for seven days on a file system another kernel may reach when it ${what}, though of this PID namespace by name and with nobody on its record here`, () => { const left = (): string => { leftover(X) - return started(manifestOfADeadProcess([X], fields), { - pid: deadPid(), - start: '1', - }) + return started(manifestOfADeadProcess([X], fields), aDeadProcess()) } const bothAged = (manifest: string, minutes: number): void => { aged(manifest, minutes) @@ -1813,7 +1805,7 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(live()).toEqual([X]) expect(collectMountPoints('all')).toEqual([]) - started(manifest, { pid: deadPid(), start: '1' }) + started(manifest, aDeadProcess()) expect(collectMountPoints('all')).toEqual([X]) } finally { kind.restore() @@ -2183,7 +2175,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([X]) expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) - started(relied, { pid: deadPid(), start: '1' }) + started(relied, aDeadProcess()) expect(collectMountPoints('all')).toEqual([X]) expect(readdirSync(PLACES[1]!)).toEqual([]) @@ -2279,7 +2271,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(live()).toEqual([X]) expect(collectMountPoints('all')).toEqual([]) expect(existsSync(X)).toBe(true) - started(relied, { pid: deadPid(), start: '1' }) + started(relied, aDeadProcess()) expect(collectMountPoints('all')).toEqual([X]) expect(PLACES.flatMap(each => readdirSync(each))).toEqual([]) } @@ -2887,7 +2879,7 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = manifestOfADeadProcess([X]) expect(live()).toEqual([]) - started(manifest, { pid: deadPid(), start: '1' }) + started(manifest, aDeadProcess()) expect(live()).toEqual([]) started(manifest, thisProcess()) expect(live()).toEqual([X]) @@ -3010,14 +3002,11 @@ describe.if(isLinux)('The mount point manifests', () => { it('is one listing of the directory, and changes nothing in it', () => { leftover(X) publishMountPointManifest([X], []) - started(manifestOfADeadProcess([join(BASE, 'other.lock')]), { - pid: deadPid(), - start: '1', - }) + started(manifestOfADeadProcess([join(BASE, 'other.lock')]), aDeadProcess()) // What a pass would take away: a record whose manifest and process are gone. writeFileSync( join(DIR, '4242-0123456789abcdef.started'), - statLine({ pid: deadPid(), start: '1' }), + statLine(aDeadProcess()), ) const before = readdirSync(DIR).sort() diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts index 03d1e3b11..4124a423b 100644 --- a/test/sandbox/mount-point-passes.test.ts +++ b/test/sandbox/mount-point-passes.test.ts @@ -31,10 +31,11 @@ import { import { manifestOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { - deadPid, + aDeadProcess, leftover, type Process, processAt, + recordOf, run, sleep, started, @@ -417,10 +418,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { }) it.each([ - [ - 'its process is gone', - (): string => passOf({ pid: deadPid(), start: '1' }), - ], + ['its process is gone', (): string => passOf(aDeadProcess())], [ 'its pid is a later process', (): string => { @@ -684,7 +682,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { // One that has run, whose caller has not cleaned up after it and may run it // again. const kept = await wrap() - started(manifestOf(kept)!, { pid: deadPid(), start: '1' }) + started(manifestOf(kept)!, aDeadProcess()) const gaveUp = new AbortController() gaveUp.abort(new Error('gave up')) expect( @@ -826,7 +824,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { 'asks after every run of a command line that has been run thousands of times: the %s may be the one that lasts', which => { leftover(LOCK) - const gone = Array(3000).fill({ pid: deadPid(), start: '1' }) + const gone = Array(3000).fill(aDeadProcess()) const manifest = manifestOfADeadProcess([LOCK]) started( manifest, @@ -843,8 +841,8 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { it('takes a record too long to be read to vouch, like any that cannot be read', () => { leftover(LOCK) const manifest = manifestOfADeadProcess([LOCK]) - started(manifest, { pid: deadPid(), start: '1' }) - const record = manifest.replace(/\.json$/, '.started') + started(manifest, aDeadProcess()) + const record = recordOf(manifest) const line = fs.readFileSync(record, 'utf8') fs.writeFileSync( record, @@ -868,7 +866,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { name => join(AREA, name), ) as [string, string, string] for (const mountPoint of [ended, running, notStarted]) leftover(mountPoint) - started(publish([ended]), { pid: deadPid(), start: '1' }) + started(publish([ended]), aDeadProcess()) started(publish([running]), anotherProcess()) publish([notStarted]) @@ -885,7 +883,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { join(AREA, name), ) as [string, string, string] for (const mountPoint of [ended, named, other]) leftover(mountPoint) - started(publish([ended], 'a'), { pid: deadPid(), start: '1' }) + started(publish([ended], 'a'), aDeadProcess()) publish([named], 'b') publish([other], 'c') expect(collectMountPoints({ commandKey: 'b' }).sort()).toEqual( @@ -947,10 +945,7 @@ describe.if(isLinux)('A clean-up of mount points, on record', () => { }) others.push(parent) await until('the sandbox to be up', () => existsSync(up)) - const record = readFileSync( - manifestOf(command)!.replace(/\.json$/, '.started'), - 'utf8', - ) + const record = readFileSync(recordOf(manifestOf(command)!), 'utf8') const bwrap = Number(/^\d+/.exec(record)![0]) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) From 3276893b3d6f11b365e1ff5ed6242bd67709cc71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:28:02 +0000 Subject: [PATCH 113/141] test: the cases on open descriptors leave out the runtime's sockets They took the number of open descriptors after a first, small wrap as their baseline. The runtime closes the two sockets of a child that has ended, which is ripgrep here, on a thread of its own: in 9 of 60 runs one or both were still open at that moment, and every count after it came out too low. One of the three cases is on main as it was here. Sockets are no longer counted, nor what has been closed since the listing. 180 runs of a case without a failure; a profile left open at either place that closes one still fails them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/linux-bwrap-args-file.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index a843ce121..d7ba51524 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -225,6 +225,16 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { message: error.message, })) const argsPathOf = wrapped => wrapped.match(/' srt-args (\\S+) /)?.[1] + // But for the sockets: the runtime closes those of a child that has ended + // (ripgrep) in its own time, on another thread. + const openFds = () => fs.readdirSync('/proc/self/fd').filter(fd => { + try { + return !fs.readlinkSync('/proc/self/fd/' + fd).startsWith('socket:') + } catch { + // Closed since the listing, as the listing's own is. + return false + } + }).length ${body} ` // A file, not `-e`: the script names every fixture path, and would not @@ -453,7 +463,6 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { it('holds one fd per pending profile and gives them all back at cleanup, a refused wrap included', () => { const seen = isolated(` - const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. await wrap(small) const baseline = openFds() @@ -483,7 +492,6 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // A profile belongs to one wrap and nothing maps it back to a command, so // none is closed while any wrap of this process is outstanding. const seen = isolated(` - const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. await wrap(small) cleanupBwrapMountPoints() From fcd46bc56e8ea2494c7e8fd950a6f79faa73c1c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:01:48 +0000 Subject: [PATCH 114/141] linux: the manifest directories are made before a plan reads them Since a plan reads the manifests first, it read them before it had made the places that follow from the user id. Until one of those can be written, what the environment names is believed. So the first wrap of a user - was refused for what is no manifest in a directory that no process believes a moment later; - took the user's own empty directory for a mount point on the word of a stale or forged manifest there, named it in its own manifest, which every process believes, and had it removed by the next clean-up. A wrap with an empty file at a denied path read that early before, and had both. The places are now made at the head of the plan. Where the wrap's own manifest goes is settled before the pins, as it was. In the tests: two hooks that went by the first call of a kind now go by the call they mean, the suite on unmakeable mount points has a manifest directory of its own, a wrap in doubt from the start is shown to make no directory to bind from either, and the README says no more than is so. What a wrap was refused with is asked by one helper. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/linux-sandbox-utils.ts | 34 ++++++---- test/helpers/mount-point-fixtures.ts | 16 ++++- .../linux-unmakeable-mount-point.test.ts | 68 +++++++------------ test/sandbox/mount-point-liveness.test.ts | 32 ++++----- test/sandbox/mount-point-manifests.test.ts | 27 +++++++- 6 files changed, 103 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 6791e2f93..029ac9c87 100644 --- a/README.md +++ b/README.md @@ -1128,7 +1128,7 @@ Filesystem restrictions are enforced at the OS level: **Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), which has to know `local`, as dash, ash and mksh do, and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and those of processes that are gone or have let go of them, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. -- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made once the manifests have been read, so a wrap that is refused because they cannot be (`mount_points_unreadable`) has made nothing, and before the wrap's own manifest is published: a wrap that comes to nothing in between has it removed by the process's next clean-up or at its exit, and a process killed in between leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. +- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made once the manifests have been read, so a wrap that finds them unreadable from the start makes nothing, and before the wrap's own manifest is published: a wrap that comes to nothing in between has it removed by the first clean-up with none of the process's commands outstanding, or at its exit, and a process killed in between leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. - **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. While one of them cannot be looked at (an error of the moment, not "not there"), no command that restricts writes is handed out (`mount_points_unreadable`): left unbound, it would be believed again a moment later. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. - **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index ebb6b275d..cf3b1efce 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2258,6 +2258,19 @@ async function generateFilesystemArgs( // The mount points on the host this wrap relies on: placeholders bwrap makes // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] + // Where the manifests are kept, each made if it can be. + let manifestDirs: string[] = [] + const makeManifestDirs = (recording: boolean): void => { + try { + manifestDirs = mountPointManifestDirectories(recording) + } catch (err) { + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, + errorText(err), + ) + } + } // What the manifests name, as of the last time it was asked. let named: NamedMountPoints | undefined const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { @@ -2795,6 +2808,11 @@ async function generateFilesystemArgs( // against it on both sides of the await above, the only one here. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + // Before any reading of them. What the environment names is believed only + // while none of the others can be written, and what this wrap found named + // there its own manifest would carry to where every process believes it. + makeManifestDirs(false) + // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and // bwrap's ensure_file() falls through to creat() on a read-only mount. @@ -3530,19 +3548,9 @@ async function generateFilesystemArgs( // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. - let manifestDirs: string[] = [] - if (writeConfig !== undefined) { - try { - manifestDirs = mountPointManifestDirectories( - mountPoints.length > 0 || emptySource !== undefined, - ) - } catch (err) { - throw new LinuxSandboxProfileError( - 'mount_points_unreadable', - `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, - errorText(err), - ) - } + // With a manifest to come, where it goes is settled now, to be pinned too. + if (mountPoints.length > 0 || emptySource !== undefined) { + makeManifestDirs(true) } const pinArgs = ancestorPinArgs( [ diff --git a/test/helpers/mount-point-fixtures.ts b/test/helpers/mount-point-fixtures.ts index 7e2a256a8..486cf8c1b 100644 --- a/test/helpers/mount-point-fixtures.ts +++ b/test/helpers/mount-point-fixtures.ts @@ -1,3 +1,4 @@ +import { expect } from 'bun:test' import { spawnSync } from 'node:child_process' import { chmodSync, @@ -8,7 +9,10 @@ import { writeFileSync, } from 'node:fs' import { join } from 'node:path' -import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { + LinuxSandboxProfileError, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' import { manifestOf } from './bwrap-argv.js' /** A process as a manifest, a started record or a pass names one. */ @@ -99,6 +103,16 @@ export function started(manifest: string, ...by: Process[]): string { } /** The mount points the wrap named in its manifest, none without one. */ +/** The code the wrap was refused with. */ +export async function refusal(wrapping: Promise): Promise { + const thrown: unknown = await wrapping.then( + () => 'the wrap resolved', + (error: unknown) => error, + ) + expect(thrown).toBeInstanceOf(LinuxSandboxProfileError) + return (thrown as LinuxSandboxProfileError).code +} + export function namedBy(command: string): string[] { const file = manifestOf(command) return file === undefined diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index 0c794f2ec..e44ea80aa 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -10,6 +10,8 @@ import { import { isLinux } from '../helpers/platform.js' import { countMounts, mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { refusal } from '../helpers/mount-point-fixtures.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' const REPO_ROOT = join(import.meta.dir, '../..') const V9FS_MAGIC = 0x01021997 @@ -55,6 +57,9 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { let DIR: string // a directory in it, holding the absent deny paths const savedCwd = process.cwd() const spies: Array<{ mockRestore(): void }> = [] + // Empty at the start of each case: a plan reads what is in it before it looks + // at the parent, with the calls that are stood in for here. + const runtime = usePrivateManifestDirectory() const CAN_RUN = bwrapCanNamespace() // Directories of another's that this user cannot write: not root's view. @@ -354,7 +359,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('plans it when the parent is swapped once it is open', async () => { swapOnce( 'fstatSync', - () => true, + fd => real.readlinkSync(`/proc/self/fd/${String(fd)}`) === DIR, () => { fs.renameSync(DIR, join(ROOT, 'aside')) fs.symlinkSync('/usr', DIR) @@ -402,7 +407,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { } if (drive) onWindowsDrive() stub('closeSync', () => { - opened.push([]) + if (opened.length === 1) opened.push([]) return undefined }) @@ -426,6 +431,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { beforeEach(onWindowsDrive) const listing = () => fs.readdirSync(DIR).sort() + /** What is no manifest: every reading is in doubt while it is there. */ + const junk = () => join(runtime.manifestDir(), '1-junk.json') it('makes the mount point itself, and the clean-up removes it', async () => { const leaf = join(DIR, 'policy.json') @@ -497,30 +504,16 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { const absent = join(DIR, denied) const own = join(DIR, 'own.empty') fs.writeFileSync(own, '') - // What is no manifest puts the readings in doubt, from the moment - // the mount point is made: the plan has read the manifests by then. - const manifests = join( - process.env['XDG_RUNTIME_DIR']!, - 'srt-mount-points', - ) - fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + // In doubt from the moment the mount point is made: the plan has + // read the manifests by then. stub(makes, p => { - if (names(p, join(DIR, made))) { - fs.writeFileSync(join(manifests, '1-junk.json'), '{}') - } + if (names(p, join(DIR, made))) fs.writeFileSync(junk(), '{}') return undefined }) - try { - const refused = await wrap([absent, own]).then( - () => undefined, - (error: unknown) => error, - ) - expect((refused as { code?: string }).code).toBe( - 'mount_points_unreadable', - ) - } finally { - fs.rmSync(join(manifests, '1-junk.json')) - } + expect(await refusal(wrap([absent, own]))).toBe( + 'mount_points_unreadable', + ) + fs.rmSync(junk()) expect(listing()).toEqual([made, 'own.empty'].sort()) if (by === 'a forced clean-up') { cleanupBwrapMountPoints({ force: true }) @@ -537,27 +530,14 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { ) it('makes nothing while the manifests cannot be read, when no clean-up would remove it either', async () => { - const manifests = join( - process.env['XDG_RUNTIME_DIR']!, - 'srt-mount-points', - ) - fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) - fs.writeFileSync(join(manifests, '1-junk.json'), '{}') - try { - const refused = await wrap([ - join(DIR, 'policy.json'), - join(DIR, 'absent', 'policy.json'), - ]).then( - () => undefined, - (error: unknown) => error, - ) - expect((refused as { code?: string }).code).toBe( - 'mount_points_unreadable', - ) - expect(listing()).toEqual([]) - } finally { - fs.rmSync(join(manifests, '1-junk.json')) - } + fs.mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + fs.writeFileSync(junk(), '{}') + expect( + await refusal( + wrap([join(DIR, 'policy.json'), join(DIR, 'absent', 'policy.json')]), + ), + ).toBe('mount_points_unreadable') + expect(listing()).toEqual([]) }) it('covers a file with a directory in a folder that takes subfolders and no files', async () => { diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 44240335a..c1b142fdb 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -37,6 +37,7 @@ import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { namedBy, processAt, + refusal, run, sleep, startOf, @@ -1064,16 +1065,6 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // ---- what a wrap cannot tell, it does not guess ---- - /** What the wrap was refused with. */ - async function refusal(wrapping: Promise): Promise { - const thrown: unknown = await wrapping.then( - () => 'the wrap resolved', - (error: unknown) => error, - ) - expect(thrown).toBeInstanceOf(LinuxSandboxProfileError) - return (thrown as LinuxSandboxProfileError).code - } - it("refuses to wrap when it cannot read another sandbox's manifest", async () => { // Taken for the caller's own, the file would be bound onto itself and named // nowhere, and would go from under this sandbox with the one that made it. @@ -2134,15 +2125,17 @@ describe.if(isLinux)( /** * Runs `then` when the wrap has decided where its mount points go and has * yet to pin what lies above them: as it makes sure of the manifest - * directories. + * directories for the second time in a plan. The first is before it reads + * them. */ function beforeThePins(then: () => void): { restore(): void } { const mkdir = fs.mkdirSync + let times = 0 const spy = spyOn(fs, 'mkdirSync').mockImplementation((( dir: unknown, ...rest: unknown[] ) => { - if (dir === runtime.manifestDir()) then() + if (dir === runtime.manifestDir() && ++times % 2 === 0) then() return (mkdir as (...args: unknown[]) => unknown)(dir, ...rest) }) as never) return { restore: () => spy.mockRestore() } @@ -2334,9 +2327,16 @@ describe.if(isLinux)( expect(readdirSync(runtime.manifestDir())).toEqual([]) expect(emptySources()).toEqual([]) - // Before it publishes: what it made goes once a clean-up can tell. The - // manifests are read first, so the doubt comes as that is made. + // The manifests are read first: in doubt from the start, it makes nothing. const junk = join(runtime.manifestDir(), '4242-00.json') + writeFileSync(junk, 'no manifest') + expect(await refusal(wrap('true', [LEAF]))).toBe( + 'mount_points_unreadable', + ) + expect(emptySources()).toEqual([]) + rmSync(junk) + + // In doubt from when it made something: that goes once a clean-up can tell. const mkdtemp = fs.mkdtempSync const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( ...args: unknown[] @@ -2345,8 +2345,8 @@ describe.if(isLinux)( return (mkdtemp as (...args: unknown[]) => unknown)(...args) }) as never) try { - expect(await wrap('true', [LEAF]).catch((e: Error) => e.name)).toBe( - 'LinuxSandboxProfileError', + expect(await refusal(wrap('true', [LEAF]))).toBe( + 'mount_points_unreadable', ) } finally { spy.mockRestore() diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 632492e2b..6815ab001 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -34,13 +34,17 @@ import { removePrivateManifestDirectory, setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' -import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' import { mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { aDeadProcess, deadPid, leftover, + namedBy, recordOf, started, startOf, @@ -2116,6 +2120,7 @@ describe.if(isLinux)('The mount point manifests', () => { denyWithinAllow: denied.map(name => join(BASE, name)), }, }) + cleanupBwrapMountPoints({ force: true }) expect( mountsOf(command).filter(mount => mount.endsWith(` ${PLACES[2]!}`)), ).toHaveLength(1) @@ -2260,6 +2265,26 @@ describe.if(isLinux)('The mount point manifests', () => { } }) + it('are made before a wrap reads any, which believes nothing the environment names that no clean-up would', async () => { + // Forged, or left by a process that could write nowhere else: it names a + // directory of the user's own. And what is no manifest. + const own = join(BASE, 'own') + mkdirSync(own) + at(NAMED[1]!, () => manifestOfADeadProcess([own])) + writeFileSync(join(NAMED[0]!, '4242-00.json'), '{}') + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [BASE], + denyWithinAllow: [join(own, 'absent.lock')], + }, + }) + expect(namedBy(command)).toEqual([join(own, 'absent.lock')]) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(own)).toBe(true) + }) + it('are believed in each of the three, whichever this process writes in', () => { for (const place of PLACES) { leftover(X) From fdce4192f86969ad4552596be7de09ead6ae55d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:01:59 +0000 Subject: [PATCH 115/141] linux: a manifest takes over all that its plan made, and nothing another wrap could not record What is kept in memory is kept by every clean-up but the one that releases everything. Two things followed that should not have: - The directory that mount points bind from stayed in memory after a manifest named it. A clean-up after one command kept it and dropped that manifest, so from then on only this process knew of it, and killed, left it for good. A published manifest now takes it out of memory like a mount point the plan made. - A plan that made a mount point marked it as its own though an earlier wrap, which could record it nowhere, relied on it. The manifest then took it over, and the clean-up after that command removed it under the earlier sandbox. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 46 ++++++++++++---------- test/sandbox/mount-point-manifests.test.ts | 39 ++++++++++++------ 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 97a315054..013a89aef 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -202,12 +202,12 @@ const ownManifests = new Map< /** * The mount points, and the directories they bind from, that no manifest names: * of wraps that could record them nowhere, and what a plan has made on the host - * itself (`madeByAPlan`, which are among `paths`) until a manifest is published - * that names it, which takes it out of here. No other process knows of them, so - * they are this process's to remove once none of its wraps is outstanding, and - * kept until then: in a pass like any other where there is a directory by then, - * so that a path another process's manifest names is kept, and on its own word - * where there is none. + * itself (`madeByAPlan`, which are among the other two) until a manifest is + * published that names it, which takes it out of here. No other process knows of + * them, so they are this process's to remove once none of its wraps is + * outstanding, and kept until then: in a pass like any other where there is a + * directory by then, so that a path another process's manifest names is kept, + * and on its own word where there is none. */ const unrecorded = { paths: new Set(), @@ -215,18 +215,23 @@ const unrecorded = { madeByAPlan: new Set(), } -/** Has `source` removed at this process's own clean-up if no manifest keeps it. */ -export function keepTrackOfMountSource(source: string): void { - unrecorded.sources.add(source) +function keepTrackOf(made: string, among: Set): void { + // Else a wrap that could record it nowhere relies on it, whoever makes it now. + if (!among.has(made)) unrecorded.madeByAPlan.add(made) + among.add(made) } /** - * The same for a mount point that a plan has made on the host itself, from then - * until a manifest names it: the plan may come to nothing before that. + * Has what a plan has made on the host itself removed at this process's own + * clean-up, from then until a manifest names it: the plan may come to nothing + * before that. */ +export function keepTrackOfMountSource(source: string): void { + keepTrackOf(source, unrecorded.sources) +} + export function keepTrackOfMountPoint(mountPoint: string): void { - unrecorded.paths.add(mountPoint) - unrecorded.madeByAPlan.add(mountPoint) + keepTrackOf(mountPoint, unrecorded.paths) } /** @@ -1063,9 +1068,10 @@ export function publishMountPointManifest( ownManifests.set(file, { commandKey, over: false }) // Not what an earlier wrap could record nowhere and this one names too: // that wrap's sandbox may outlast this manifest. - for (const mountPoint of mountPoints) { - if (unrecorded.madeByAPlan.delete(mountPoint)) { - unrecorded.paths.delete(mountPoint) + for (const named of [...mountPoints, ...sources]) { + if (unrecorded.madeByAPlan.delete(named)) { + unrecorded.paths.delete(named) + unrecorded.sources.delete(named) } } return { dir, file, started: `${id}${STARTED_SUFFIX}` } @@ -1081,11 +1087,11 @@ export function publishMountPointManifest( ) } } - for (const mountPoint of mountPoints) { - unrecorded.paths.add(mountPoint) - unrecorded.madeByAPlan.delete(mountPoint) - } + for (const mountPoint of mountPoints) unrecorded.paths.add(mountPoint) for (const source of sources) unrecorded.sources.add(source) + for (const named of [...mountPoints, ...sources]) { + unrecorded.madeByAPlan.delete(named) + } return undefined } diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 6815ab001..b8a83b5b9 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -26,6 +26,7 @@ import { discardMountPointManifest, forgetMountPointManifestDirectory, keepTrackOfMountPoint, + keepTrackOfMountSource, liveMountPoints, mountPointManifestDirectories, namedMountPoints, @@ -1148,7 +1149,7 @@ describe.if(isLinux)('The mount point manifests', () => { }) /** Has this process fail to write its next manifest, as on a full disk. */ - function unrecorded(paths: string[]): void { + function unrecorded(paths: string[], sources: string[] = []): void { const write = fs.writeFileSync const spy = spyOn(fs, 'writeFileSync').mockImplementation((( file: unknown, @@ -1160,7 +1161,7 @@ describe.if(isLinux)('The mount point manifests', () => { return (write as (...args: unknown[]) => void)(file, ...rest) }) as never) try { - expect(publishMountPointManifest(paths, [])).toBeUndefined() + expect(publishMountPointManifest(paths, sources)).toBeUndefined() } finally { spy.mockRestore() } @@ -2619,21 +2620,30 @@ describe.if(isLinux)('The mount point manifests', () => { }) it.each([ - ['left to bubblewrap to make', () => {}], - ['had made itself', () => keepTrackOfMountPoint(X)], + ['had no part in making', undefined], + ['had made itself', 'the first'], + ['had no part in making, and the other made,', 'the second'], ])( 'keeps what one wrap %s and could not record, while another wrap that names it too is cleaned up after', - (_, planned) => { + (_, madeBy) => { // The sandbox of the first may be running over it, and only this process // knows: the manifest of the second is not all that names it. - planned() + const source = join(BASE, 'empty') + const planOf = (wrap: string): void => { + if (wrap !== madeBy) return + keepTrackOfMountPoint(X) + keepTrackOfMountSource(source) + } leftover(X) - unrecorded([X]) - publishMountPointManifest([X], [], 'the second') + mkdirSync(source, { mode: 0o700 }) + planOf('the first') + unrecorded([X], [source]) + planOf('the second') + publishMountPointManifest([X], [source], 'the second') expect(collectMountPoints({ commandKey: 'the second' })).toEqual([]) - expect(existsSync(X)).toBe(true) + expect([X, source].filter(existsSync)).toEqual([X, source]) expect(named()).toEqual([X]) - expect(collectMountPoints('all')).toEqual([X]) + expect(collectMountPoints('all').sort()).toEqual([X, source].sort()) }, ) @@ -3010,13 +3020,18 @@ describe.if(isLinux)('The mount point manifests', () => { it('names what a plan has made on the host itself from that moment, until a manifest names it', () => { const first = reads(namedMountPoints()) + const source = join(BASE, 'empty') keepTrackOfMountPoint(X) + keepTrackOfMountSource(source) leftover(X) + mkdirSync(source, { mode: 0o700 }) expect([...reads(namedMountPoints(first)).paths]).toEqual([X]) // From then on it is the manifest's, and goes with that. - publishMountPointManifest([X], [], 'the command') - expect(collectMountPoints({ commandKey: 'the command' })).toEqual([X]) + publishMountPointManifest([X], [source], 'the command') + expect(collectMountPoints({ commandKey: 'the command' }).sort()).toEqual( + [X, source].sort(), + ) // What the user puts there afterwards is the user's. writeFileSync(X, '') expect(named()).toEqual([]) From f203eb61311a36bc3de593e9ff63a59cb5dad113 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:55:24 +0000 Subject: [PATCH 116/141] linux: the manifest directories are made once in a plan Since the places are made at the head of a plan, the call before the pins did one thing more: it settled where this wrap's manifest would go, so that a directory of the process's own was pinned in the same plan. No case held that, and the wrap is right without it. A manifest that went to a directory the pins do not cover has the wrap plan again, which a case does hold, and the next plan finds the directory settled. So the second call goes, and the `recording` parameter with it. A process that can write none of the shared places plans its first recording wrap twice. Every other wrap saves the second look at the five places: with twenty manifests there, `lstat` 66 to 57, `open` 28 to 24, `access` 6 to 3. The hook of the cases that act between the deny loop and the pins counted calls of `mkdirSync`. It is keyed on the one question a plan asks there. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 9 ++---- src/sandbox/linux-sandbox-utils.ts | 34 ++++++++++------------- test/sandbox/mount-point-liveness.test.ts | 14 ++++------ 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 013a89aef..99a680f95 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -662,11 +662,9 @@ function ensureManifestDirectory(): string | undefined { * - another user has the name, in a sticky directory: only that user or root * can remove or rename it. * - * With `recording`, the directory this wrap's manifest will go to is settled - * first, so the wrap can pin what lies above it before it publishes. Throws - * where it cannot tell whether one of them is there. + * Throws where it cannot tell whether one of them is there. */ -export function mountPointManifestDirectories(recording = false): string[] { +export function mountPointManifestDirectories(): string[] { if (!onLinux()) { return [] } @@ -682,9 +680,6 @@ export function mountPointManifestDirectories(recording = false): string[] { dirs.add(candidate) } } - if (recording) { - ensureManifestDirectory() - } if (manifestDirectory !== undefined && isToBeBound(manifestDirectory)) { dirs.add(manifestDirectory) } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index cf3b1efce..17703b450 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2258,19 +2258,8 @@ async function generateFilesystemArgs( // The mount points on the host this wrap relies on: placeholders bwrap makes // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // Where the manifests are kept, each made if it can be. + // Where the manifests are kept. let manifestDirs: string[] = [] - const makeManifestDirs = (recording: boolean): void => { - try { - manifestDirs = mountPointManifestDirectories(recording) - } catch (err) { - throw new LinuxSandboxProfileError( - 'mount_points_unreadable', - `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, - errorText(err), - ) - } - } // What the manifests name, as of the last time it was asked. let named: NamedMountPoints | undefined const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { @@ -2808,10 +2797,19 @@ async function generateFilesystemArgs( // against it on both sides of the await above, the only one here. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() - // Before any reading of them. What the environment names is believed only - // while none of the others can be written, and what this wrap found named - // there its own manifest would carry to where every process believes it. - makeManifestDirs(false) + // Made before any reading of them. What the environment names is believed + // only while none of the others can be written, and what this wrap found + // named there its own manifest would carry to where every process believes + // it. + try { + manifestDirs = mountPointManifestDirectories() + } catch (err) { + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, + errorText(err), + ) + } // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and @@ -3548,10 +3546,6 @@ async function generateFilesystemArgs( // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. - // With a manifest to come, where it goes is settled now, to be pinned too. - if (mountPoints.length > 0 || emptySource !== undefined) { - makeManifestDirs(true) - } const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index c1b142fdb..f09d19380 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2124,19 +2124,17 @@ describe.if(isLinux)( /** * Runs `then` when the wrap has decided where its mount points go and has - * yet to pin what lies above them: as it makes sure of the manifest - * directories for the second time in a plan. The first is before it reads - * them. + * yet to pin what lies above them: as it asks where the manifest directory + * really is, which is pinned too. It asks once in a plan. */ function beforeThePins(then: () => void): { restore(): void } { - const mkdir = fs.mkdirSync - let times = 0 - const spy = spyOn(fs, 'mkdirSync').mockImplementation((( + const native = fs.realpathSync.native + const spy = spyOn(fs.realpathSync, 'native').mockImplementation((( dir: unknown, ...rest: unknown[] ) => { - if (dir === runtime.manifestDir() && ++times % 2 === 0) then() - return (mkdir as (...args: unknown[]) => unknown)(dir, ...rest) + if (dir === runtime.manifestDir()) then() + return (native as (...args: unknown[]) => unknown)(dir, ...rest) }) as never) return { restore: () => spy.mockRestore() } } From 8997f0d58731f4646dc8bd5cc04cf9668d7d30bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:55:32 +0000 Subject: [PATCH 117/141] linux: what mount points bind from goes with the last command Brought in by the commit before last. Once a manifest named the directory that mount points bind from, the process no longer kept it in memory. So with that manifest lost, to a cleaner of temp dirs say, no clean-up of the process removed the directory, and every later wrap went on binding from it, also after the temp dir had changed. It is this process's own: a clean-up with none of its commands outstanding takes it for removal, whatever the manifests say. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/linux-sandbox-utils.ts | 4 ++++ test/sandbox/mount-point-liveness.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 17703b450..53eea84c8 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1340,6 +1340,10 @@ function cleanUpAfter( } } + // This process's own, whatever has become of the manifests that named it. + if (release === 'all' && emptyMountSourceDir !== undefined) { + keepTrackOfMountSource(emptyMountSourceDir) + } const removed = collectMountPoints(release) if ( emptyMountSourceDir !== undefined && diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index f09d19380..e64451721 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2355,6 +2355,15 @@ describe.if(isLinux)( expect(emptySources()).toEqual([]) }) + it('has what it binds from removed with its last command, whatever has become of the manifest that named it', async () => { + const command = await wrap('true', [LEAF]) + expect(emptySources()).toHaveLength(1) + // As a cleaner of temp dirs takes it. + rmSync(recordedBy(command)!) + cleanupBwrapMountPoints() + expect(emptySources()).toEqual([]) + }) + it('gives up, with a code of its own and nothing left behind, when that happens to every plan', async () => { let published = 0 const publishing = atEachPublish(() => { From fa247d20b4680b4a44d9bce38d55c44d6ec6cdd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:55:33 +0000 Subject: [PATCH 118/141] test: junk that stays in doubt, and the refusal fixture where it was spelled out What is no JSON is in doubt for half a second only, so a case that plants it and is slow to wrap would see no refusal. `{}` is in doubt for an hour. Two more captures of a refusal use the fixture, and a doc comment is back on the function it describes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/helpers/mount-point-fixtures.ts | 2 +- test/sandbox/mount-point-liveness.test.ts | 16 +++++----------- test/sandbox/mount-point-manifests.test.ts | 21 ++++++++++----------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/test/helpers/mount-point-fixtures.ts b/test/helpers/mount-point-fixtures.ts index 486cf8c1b..3de6e0c80 100644 --- a/test/helpers/mount-point-fixtures.ts +++ b/test/helpers/mount-point-fixtures.ts @@ -102,7 +102,6 @@ export function started(manifest: string, ...by: Process[]): string { return manifest } -/** The mount points the wrap named in its manifest, none without one. */ /** The code the wrap was refused with. */ export async function refusal(wrapping: Promise): Promise { const thrown: unknown = await wrapping.then( @@ -113,6 +112,7 @@ export async function refusal(wrapping: Promise): Promise { return (thrown as LinuxSandboxProfileError).code } +/** The mount points the wrap named in its manifest, none without one. */ export function namedBy(command: string): string[] { const file = manifestOf(command) return file === undefined diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index e64451721..1645e79e1 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -21,7 +21,6 @@ import { basename, dirname, join } from 'node:path' import { liveMountPoints } from '../../src/index.js' import { cleanupBwrapMountPoints, - LinuxSandboxProfileError, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' @@ -1124,7 +1123,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const inOnePlan = opens / 3 // Which planning again does not mend where something else is amiss too. const junk = join(dirname(theirs), '4242-00.json') - writeFileSync(junk, 'no manifest') + writeFileSync(junk, '{}') expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') rmSync(junk) ;[opens, elusiveFor] = [0, inOnePlan] @@ -1136,7 +1135,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it('is not asked of a wrap that restricts no write, which has no mount point', async () => { mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) - writeFileSync(join(runtime.manifestDir(), '4242-00.json'), 'no manifest') + writeFileSync(join(runtime.manifestDir(), '4242-00.json'), '{}') expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') expect( await wrapCommandWithSandboxLinux({ @@ -2327,7 +2326,7 @@ describe.if(isLinux)( // The manifests are read first: in doubt from the start, it makes nothing. const junk = join(runtime.manifestDir(), '4242-00.json') - writeFileSync(junk, 'no manifest') + writeFileSync(junk, '{}') expect(await refusal(wrap('true', [LEAF]))).toBe( 'mount_points_unreadable', ) @@ -2339,7 +2338,7 @@ describe.if(isLinux)( const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( ...args: unknown[] ) => { - writeFileSync(junk, 'no manifest') + writeFileSync(junk, '{}') return (mkdtemp as (...args: unknown[]) => unknown)(...args) }) as never) try { @@ -2371,16 +2370,11 @@ describe.if(isLinux)( // What the mount point is bound from, as a clean-up elsewhere takes it. emptySources().forEach(source => fs.rmdirSync(source)) }) - let refused: unknown try { - refused = await wrap('true', [LEAF]).catch((error: unknown) => error) + expect(await refusal(wrap('true', [LEAF]))).toBe('mount_points_changed') } finally { publishing.restore() } - expect(refused).toBeInstanceOf(LinuxSandboxProfileError) - expect((refused as LinuxSandboxProfileError).code).toBe( - 'mount_points_changed', - ) expect(published).toBe(3) expect(readdirSync(runtime.manifestDir())).toEqual([]) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index b8a83b5b9..5409b8ca7 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -47,6 +47,7 @@ import { leftover, namedBy, recordOf, + refusal, started, startOf, statLine, @@ -2403,17 +2404,15 @@ describe.if(isLinux)('The mount point manifests', () => { try { expect(named()).toEqual([]) expect(() => mountPointManifestDirectories()).toThrow('EIO') - const refused = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - writeConfig: { allowOnly: [BASE], denyWithinAllow: [] }, - }).then( - () => undefined, - (error: unknown) => error, - ) - expect((refused as { code?: string }).code).toBe( - 'mount_points_unreadable', - ) + expect( + await refusal( + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [BASE], denyWithinAllow: [] }, + }), + ), + ).toBe('mount_points_unreadable') } finally { look.restore() } From 260e0dc27c60fff94368250db10416bb833d67db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:25:14 +0000 Subject: [PATCH 119/141] linux: where a manifest goes is settled before the pins again f203eb6 was wrong to cut that. It relied on the wrap planning again when its manifest went to a directory the pins do not cover, and that is asked of a manifest. Where none can be written, on a full disk say, there is none to ask it of: the command went out with the process's own directory, just made, neither bound nor pinned. With the temp dir in a write root the command could write a manifest there, and the host's next clean-up removed an empty file outside every write root on its word. It also had such a process plan twice after every forced clean-up, which removes that directory, and not once in its life as that message said. And a wrap given up between the two plans left the directory behind at exit. So it is settled before the pins as it was, and listed with the others. Without the second look at the shared places, which is what cost: with twenty manifests there, `lstat` 66 at fdce419, 58 now, `open` 28 and 24. The case is what was missing: neither shared name will do, and the wrap publishes once and binds the directory, with the manifest written and with the disk full, also after a forced clean-up. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 2 +- src/sandbox/linux-sandbox-utils.ts | 7 ++++ test/sandbox/mount-point-liveness.test.ts | 42 ++++++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 99a680f95..a6bd3bdfb 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -600,7 +600,7 @@ function believedManifestDirectories(): string[] { * guarantee within this process. Revalidated on every use, because a directory * swapped for a symlink between two wraps would send manifests elsewhere. */ -function ensureManifestDirectory(): string | undefined { +export function ensureManifestDirectory(): string | undefined { if ( manifestDirectory !== undefined && isOurPrivateDirectory(manifestDirectory) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 53eea84c8..49545c384 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -12,6 +12,7 @@ import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, + ensureManifestDirectory, holdsMountPointManifest, keepTrackOfMountPoint, keepTrackOfMountSource, @@ -3550,6 +3551,12 @@ async function generateFilesystemArgs( // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. + // With a manifest to come, where it goes is settled now, to be bound and + // pinned too, written or not: it may be a directory of the process's own. + if (mountPoints.length > 0 || emptySource !== undefined) { + const own = ensureManifestDirectory() + if (own !== undefined) manifestDirs.push(own) + } const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 1645e79e1..3ed3bcb7b 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2286,6 +2286,46 @@ describe.if(isLinux)( } }) + it("goes to a directory of the process's own that the same plan binds, written or not", async () => { + // Neither shared name will do, from the start. + const shared = [ + runtime.manifestDir(), + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ] + for (const name of shared) { + rmSync(name, { recursive: true, force: true }) + writeFileSync(name, '') + } + const own = (): string[] => + readdirSync(tmpdir()) + .filter(name => /^srt-mount-points-[A-Za-z0-9]{6}$/.test(name)) + .map(name => join(tmpdir(), name)) + let published = 0 + let full = false + const publishing = atEachPublish(() => { + published++ + if (full) throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + }) + try { + // The forced clean-up removes the directory, so each wrap settles anew. + for (full of [false, false, true]) { + published = 0 + const command = await wrap('true', [LEAF]) + expect(published).toBe(1) + expect(own()).toHaveLength(1) + expect(recordedBy(command) === undefined).toBe(full) + expect(lastMountAt(command, own()[0]!)).toBe( + `${full ? '--ro-bind-try' : '--ro-bind'} ${own()[0]} ${own()[0]}`, + ) + cleanupBwrapMountPoints({ force: true }) + expect(own()).toEqual([]) + } + } finally { + publishing.restore() + shared.forEach(name => rmSync(name)) + } + }) + it('is let go of when a process exits whose wrap made no mount point of its own', () => { mkdirSync(DIRECTORY) manifestOfAKilledProcess([DIRECTORY]) @@ -2729,7 +2769,7 @@ describe.if(isLinux)( it('are pinned above the directory of its own a process settles on as it records', () => { // Neither shared name will do, so the wrap makes a directory only this - // process knows, and makes it as it publishes: after the pins. + // process knows. takeTheSharedName() const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, From b936bc787ad98670367edcd228cb2cfb6c58749b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:25:14 +0000 Subject: [PATCH 120/141] Revert "linux: what mount points bind from goes with the last command" This reverts commit 8997f0d. It took the directory for removal at every clean-up with no command outstanding, for as long as the process remembered the name. So where the name outlived the directory, or a sandbox still running kept it, each later clean-up went on record and read everything, with nothing to remove, and other processes' wraps waited for it: ten records in five idle clean-ups, none before. It removed the directory from under a running sandbox whose manifest and record were both lost. And it made two cases pass by itself that hold the tracking from when the directory is made. What it mended is a case of a limit the README states: what a lost manifest named stays. For this directory less than that. The next wrap of the process that binds from it names it again, and it goes with that manifest. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/linux-sandbox-utils.ts | 4 ---- test/sandbox/mount-point-liveness.test.ts | 9 --------- 2 files changed, 13 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 49545c384..9dcb0d16b 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1341,10 +1341,6 @@ function cleanUpAfter( } } - // This process's own, whatever has become of the manifests that named it. - if (release === 'all' && emptyMountSourceDir !== undefined) { - keepTrackOfMountSource(emptyMountSourceDir) - } const removed = collectMountPoints(release) if ( emptyMountSourceDir !== undefined && diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 3ed3bcb7b..29c7a88c6 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2394,15 +2394,6 @@ describe.if(isLinux)( expect(emptySources()).toEqual([]) }) - it('has what it binds from removed with its last command, whatever has become of the manifest that named it', async () => { - const command = await wrap('true', [LEAF]) - expect(emptySources()).toHaveLength(1) - // As a cleaner of temp dirs takes it. - rmSync(recordedBy(command)!) - cleanupBwrapMountPoints() - expect(emptySources()).toEqual([]) - }) - it('gives up, with a code of its own and nothing left behind, when that happens to every plan', async () => { let published = 0 const publishing = atEachPublish(() => { From 273ea9085646d5cc813d5efe72f20fa4939a2973 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:25:14 +0000 Subject: [PATCH 121/141] test: one more case whose junk stays in doubt It asks three times whether what is no manifest is in doubt, and planted what is so for half a second. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-manifests.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 5409b8ca7..0370ccb3d 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -2953,7 +2953,7 @@ describe.if(isLinux)('The mount point manifests', () => { ], ['a manifest cannot be opened', manifest => failing(manifest, 'EMFILE')], ['a manifest cannot be read', manifest => failing(manifest, 'EIO', 'read')], - ['something is no manifest', () => planted('1-garbage.json', '{ not json')], + ['something is no manifest', () => planted('1-garbage.json', '{}')], [ 'a process runs on a record without a manifest', () => planted('1-0123456789abcdef.started', statLine(thisProcess())), From 5bb12e91c96feb5bc0d07c73db422bf52730a6d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:49:34 +0000 Subject: [PATCH 122/141] linux: a directory of the process's own does not stay when it cannot be written Older than this round: reproduced at fdce419, and the two conditions it hangs on read the same in the head that was approved. A wrap settles where its manifest goes before the pins only when it has mount points of its own. One that relies on another sandbox's alone learns that after the pins, which are among what it relies on, and settles as it publishes. Written, the manifest has the wrap plan again. Not written, on a full disk say, there is no manifest to ask that of, and the command went out with the directory, just made, neither bound nor pinned: with the temp dir in a write root the command could write a manifest there, and the host's next clean-up removed an empty file outside every write root on its word. So it is closed where the directory is made, whichever way a wrap came there: a directory only this process knows, and with nothing in it, is removed again when the manifest it was made for cannot be written. One that holds an earlier manifest stays, and was settled before this plan began, so this plan has bound it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 3 +++ test/sandbox/mount-point-liveness.test.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index a6bd3bdfb..2d0fa177c 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -1076,6 +1076,9 @@ export function publishMountPointManifest( } catch { // Never made. } + // Made just now, it may be, and bound by no command: not left to be + // believed with nothing of this process's in it. + removePrivateManifestDirectory() logForDebugging( `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are kept in memory and removed at this process's own clean-up`, { level: 'warn' }, diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 29c7a88c6..249c59f5b 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2286,7 +2286,7 @@ describe.if(isLinux)( } }) - it("goes to a directory of the process's own that the same plan binds, written or not", async () => { + it("goes to a directory of the process's own that the same plan binds, and that does not stay when it cannot be written", async () => { // Neither shared name will do, from the start. const shared = [ runtime.manifestDir(), @@ -2298,8 +2298,9 @@ describe.if(isLinux)( } const own = (): string[] => readdirSync(tmpdir()) - .filter(name => /^srt-mount-points-[A-Za-z0-9]{6}$/.test(name)) + .filter(name => name.startsWith('srt-mount-points-')) .map(name => join(tmpdir(), name)) + .filter(name => !shared.includes(name)) let published = 0 let full = false const publishing = atEachPublish(() => { @@ -2312,11 +2313,12 @@ describe.if(isLinux)( published = 0 const command = await wrap('true', [LEAF]) expect(published).toBe(1) - expect(own()).toHaveLength(1) - expect(recordedBy(command) === undefined).toBe(full) - expect(lastMountAt(command, own()[0]!)).toBe( - `${full ? '--ro-bind-try' : '--ro-bind'} ${own()[0]} ${own()[0]}`, - ) + // Believed while it is there, and a wrap that settles on it only as + // it publishes has not bound it. + expect(own()).toEqual(full ? [] : [dirname(recordedBy(command)!)]) + for (const dir of own()) { + expect(lastMountAt(command, dir)).toBe(`--ro-bind ${dir} ${dir}`) + } cleanupBwrapMountPoints({ force: true }) expect(own()).toEqual([]) } From 57a7678c4c3ebca9472fd8fa9c2608dc9be90bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:49:56 +0000 Subject: [PATCH 123/141] linux: a manifest directory is resolved as the plan makes sure of it Each is bound where it really is, and last, on top of every deny. The plan makes sure at its head that each is a directory and no link, and asked where it really is only at the pins, the whole deny loop later. A name swapped for a link to the home directory in between had that bound last, on top of a read deny under it, and the command read what was denied. Since fcd46bc for a wrap with no mount point, since f203eb6 for every wrap: before those, the look came right before the pins. Only what the environment names can be swapped, and only by a process of the user's that has not got it bound, such as a sandbox of a process with another temp dir. Now it is asked at once. A name swapped later is bound by that name, and bubblewrap refuses to mount on a link. The hook of the cases that act before the pins hung on that question. It is keyed on the same question about the directory mount points bind from, so the one case whose first plan had none has one more deny. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/linux-sandbox-utils.ts | 18 +++++----- test/sandbox/mount-point-liveness.test.ts | 41 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 9dcb0d16b..de1d58111 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2259,7 +2259,7 @@ async function generateFilesystemArgs( // The mount points on the host this wrap relies on: placeholders bwrap makes // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // Where the manifests are kept. + // Where the manifests are kept, each where it really is. let manifestDirs: string[] = [] // What the manifests name, as of the last time it was asked. let named: NamedMountPoints | undefined @@ -2801,9 +2801,9 @@ async function generateFilesystemArgs( // Made before any reading of them. What the environment names is believed // only while none of the others can be written, and what this wrap found // named there its own manifest would carry to where every process believes - // it. + // it. Resolved at once, while each is known for a directory and no link. try { - manifestDirs = mountPointManifestDirectories() + manifestDirs = mountPointManifestDirectories().map(canonicalForm) } catch (err) { throw new LinuxSandboxProfileError( 'mount_points_unreadable', @@ -3548,17 +3548,18 @@ async function generateFilesystemArgs( // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. // With a manifest to come, where it goes is settled now, to be bound and - // pinned too, written or not: it may be a directory of the process's own. + // pinned too: it may be a directory of the process's own. if (mountPoints.length > 0 || emptySource !== undefined) { const own = ensureManifestDirectory() - if (own !== undefined) manifestDirs.push(own) + if (own !== undefined) manifestDirs.push(canonicalForm(own)) } const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), - ...[...manifestDirs, maskedFileStoreDir, emptySource].flatMap(dir => + ...manifestDirs, + ...[maskedFileStoreDir, emptySource].flatMap(dir => dir === undefined ? [] : [canonicalForm(dir)], ), ], @@ -3819,7 +3820,8 @@ async function generateFilesystemArgs( [...madeIn].every(relied => isThere(relied, true)) && mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && // Else settled on since the pins were made: nothing is pinned above it. - (manifest === undefined || manifestDirs.includes(manifest.dir)) + (manifest === undefined || + manifestDirs.includes(canonicalForm(manifest.dir))) } finally { if (!holds && manifest !== undefined) { discardMountPointManifest(manifest.file) @@ -3866,7 +3868,7 @@ async function generateFilesystemArgs( // gone by the time the command starts - a temp dir whose parent was // replaced - is not an error. // Each where it really is, once: two of the names may lead to one place. - const others = new Set(manifestDirs.map(canonicalForm)) + const others = new Set(manifestDirs) if (manifest !== undefined) others.delete(canonicalForm(manifest.dir)) for (const dir of others) { if (isInReach(dir)) args.push('--ro-bind-try', dir, dir) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 249c59f5b..445ca3a23 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2123,8 +2123,9 @@ describe.if(isLinux)( /** * Runs `then` when the wrap has decided where its mount points go and has - * yet to pin what lies above them: as it asks where the manifest directory - * really is, which is pinned too. It asks once in a plan. + * yet to pin what lies above them: as it asks where the directory they + * bind from really is, which is pinned too. It asks once in a plan that has + * a directory among them. */ function beforeThePins(then: () => void): { restore(): void } { const native = fs.realpathSync.native @@ -2132,7 +2133,7 @@ describe.if(isLinux)( dir: unknown, ...rest: unknown[] ) => { - if (dir === runtime.manifestDir()) then() + if (basename(String(dir)).startsWith('claude-empty-')) then() return (native as (...args: unknown[]) => unknown)(dir, ...rest) }) as never) return { restore: () => spy.mockRestore() } @@ -2162,9 +2163,11 @@ describe.if(isLinux)( goes(published++ === 0 && !early), ) const pinning = beforeThePins(() => goes(early)) + // Absent from the first plan on, so that has a directory to bind from. + const other = join(AREA, 'proj', 'other') let command: string try { - command = await wrap('true', [spared, LEAF]) + command = await wrap('true', [spared, LEAF, join(other, 'leaf')]) } finally { pinning.restore() publishing.restore() @@ -2178,7 +2181,9 @@ describe.if(isLinux)( // Nothing is left of the plan it gave up, nor of what only that named: // found there, the file would be the caller's own from now on. expect(command).toContain(`--ro-bind /dev/null ${spared}`) - expect(namedBy(command).sort()).toEqual([spared, DIRECTORY].sort()) + expect(namedBy(command).sort()).toEqual( + [spared, DIRECTORY, other].sort(), + ) expect(readdirSync(join(AREA, 'proj'))).toEqual([]) expect(readdirSync(runtime.manifestDir())).toEqual([ basename(recordedBy(command)!), @@ -2328,6 +2333,32 @@ describe.if(isLinux)( } }) + it('is kept in directories that are bound where the plan found them, not where a link put at the name since leads', async () => { + // Bound last, that would lie on top of every read deny under it. + const shared = join(tmpdir(), `srt-mount-points-${process.getuid!()}`) + const elsewhere = join(BASE, 'elsewhere') + mkdirSync(elsewhere) + // As the wrap makes what its mount point binds from, in the deny loop. + const mkdtemp = fs.mkdtempSync + const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( + ...args: unknown[] + ) => { + fs.rmdirSync(shared) + symlinkSync(elsewhere, shared) + return (mkdtemp as (...args: unknown[]) => unknown)(...args) + }) as never) + try { + const command = await wrap('true', [LEAF]) + expect(lastMountAt(command, elsewhere)).toBeUndefined() + expect(lastMountAt(command, shared)).toBe( + `--ro-bind-try ${shared} ${shared}`, + ) + } finally { + spy.mockRestore() + rmSync(shared) + } + }) + it('is let go of when a process exits whose wrap made no mount point of its own', () => { mkdirSync(DIRECTORY) manifestOfAKilledProcess([DIRECTORY]) From 917b89ef5bade45eb9e79ec165135882d5adb949 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:49:57 +0000 Subject: [PATCH 124/141] linux: ensureManifestDirectory does nothing off Linux, and says what it asks of a wrap Exported since 260e0dc, it was the one function here that makes something on the host without asking for the platform first. Its callers did that for it until then. Its doc comment says what a caller takes on, and the one that lists what a wrap binds points to it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 9 ++++++++- test/sandbox/mount-point-manifests.test.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 2d0fa177c..ec31e7887 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -599,8 +599,14 @@ function believedManifestDirectories(): string[] { * alone, else a private directory only this process knows, which keeps the * guarantee within this process. Revalidated on every use, because a directory * swapped for a symlink between two wraps would send manifests elsewhere. + * + * What it makes is believed from then on. So a wrap with a manifest to come + * asks before it pins, and binds this one with the others. */ export function ensureManifestDirectory(): string | undefined { + if (!onLinux()) { + return undefined + } if ( manifestDirectory !== undefined && isOurPrivateDirectory(manifestDirectory) @@ -650,7 +656,8 @@ export function ensureManifestDirectory(): string | undefined { /** * Every directory a wrap must bind read-only because some process of this user * keeps manifests in it and a collect believes what it finds there: every - * shared name, whichever this process writes to, plus the one it settled on. + * shared name, whichever this process writes to, plus the one it settled on + * (for a manifest yet to come, see {@link ensureManifestDirectory}). * * Each is made if it can be, so a sandboxed command cannot make and fill it * first. One that can be neither made nor bound is not believed, and the diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 0370ccb3d..eb78fdd94 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -3113,10 +3113,11 @@ describe.if(isLinux)('The mount point manifests', () => { `const published = m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null`, `const live = [...m.liveMountPoints()]`, `const directories = m.mountPointManifestDirectories()`, + `const settled = m.ensureManifestDirectory() ?? null`, `const collected = m.collectMountPoints('all')`, `u.cleanupBwrapMountPoints()`, `u.cleanupBwrapMountPoints({ force: true })`, - `console.log(JSON.stringify({ published, live, directories, collected }))`, + `console.log(JSON.stringify({ published, live, directories, settled, collected }))`, ].join('\n'), { first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, @@ -3127,6 +3128,7 @@ describe.if(isLinux)('The mount point manifests', () => { published: null, live: [], directories: [], + settled: null, collected: [], }) expect(existsSync(X)).toBe(true) From 7a31540009121c0d60f71922b9fd0345c41917e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:49:57 +0000 Subject: [PATCH 125/141] test: the last case whose junk was in doubt for half a second Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-manifests.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index eb78fdd94..836695292 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -1211,7 +1211,7 @@ describe.if(isLinux)('The mount point manifests', () => { ], [ 'a manifest that has arrived cannot be read', - () => planted('1-garbage.json', '{ not json'), + () => planted('1-garbage.json', '{}'), ], ] as const) { it(`stops removing, and keeps its claim, once ${what}`, () => { From 5d5faf5289c20892b617cd9a213ba6a1738c5861 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:14:10 +0000 Subject: [PATCH 126/141] linux: the source goes back to fdce419 `src/` is byte for byte what it was there. That is the last head a review found free of regressions in the wrap path. Its nine findings were small, and each of my three rounds of answers to them brought in worse: - f203eb6 cut the second look at the manifest directories, and with it the settling of a directory of the process's own before the pins. On a full disk that directory went out neither bound nor pinned. - 8997f0d made every later clean-up a full pass in three states. Reverted in b936bc7 already. - 260e0dc settled again, without the look. A name swapped for a link during the deny loop was then followed, for every wrap. - 5bb12e9 removed a directory that could not be written, also one that commands already handed out bind, so that the next one made was in their reach. On a disk that takes no directory either, the process recorded nothing for the rest of its life. - 57a7678 resolved the directories at the head of the plan. One that cannot be resolved then counted as a guess against every placeholder the plan would have skipped, and the command could not start. All of it lies in some twenty lines, in states that are rare: no shared name can be used, the disk is full, or another process of the user's races the plan. CI was green on every one of these heads. What stays is tests. Junk that is in doubt for an hour and not for half a second, the refusal fixture, a hook that no longer counts calls, and two cases for what the second look does, which no case held: a directory of the process's own is bound by the plan that settles on it, written or not, and a link put at a shared name during the plan is not followed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- src/sandbox/bwrap-mount-manifests.ts | 23 +++++------ src/sandbox/linux-sandbox-utils.ts | 45 ++++++++++------------ test/sandbox/mount-point-liveness.test.ts | 39 ++++++++----------- test/sandbox/mount-point-manifests.test.ts | 4 +- 4 files changed, 47 insertions(+), 64 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index ec31e7887..013a89aef 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -599,14 +599,8 @@ function believedManifestDirectories(): string[] { * alone, else a private directory only this process knows, which keeps the * guarantee within this process. Revalidated on every use, because a directory * swapped for a symlink between two wraps would send manifests elsewhere. - * - * What it makes is believed from then on. So a wrap with a manifest to come - * asks before it pins, and binds this one with the others. */ -export function ensureManifestDirectory(): string | undefined { - if (!onLinux()) { - return undefined - } +function ensureManifestDirectory(): string | undefined { if ( manifestDirectory !== undefined && isOurPrivateDirectory(manifestDirectory) @@ -656,8 +650,7 @@ export function ensureManifestDirectory(): string | undefined { /** * Every directory a wrap must bind read-only because some process of this user * keeps manifests in it and a collect believes what it finds there: every - * shared name, whichever this process writes to, plus the one it settled on - * (for a manifest yet to come, see {@link ensureManifestDirectory}). + * shared name, whichever this process writes to, plus the one it settled on. * * Each is made if it can be, so a sandboxed command cannot make and fill it * first. One that can be neither made nor bound is not believed, and the @@ -669,9 +662,11 @@ export function ensureManifestDirectory(): string | undefined { * - another user has the name, in a sticky directory: only that user or root * can remove or rename it. * - * Throws where it cannot tell whether one of them is there. + * With `recording`, the directory this wrap's manifest will go to is settled + * first, so the wrap can pin what lies above it before it publishes. Throws + * where it cannot tell whether one of them is there. */ -export function mountPointManifestDirectories(): string[] { +export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { return [] } @@ -687,6 +682,9 @@ export function mountPointManifestDirectories(): string[] { dirs.add(candidate) } } + if (recording) { + ensureManifestDirectory() + } if (manifestDirectory !== undefined && isToBeBound(manifestDirectory)) { dirs.add(manifestDirectory) } @@ -1083,9 +1081,6 @@ export function publishMountPointManifest( } catch { // Never made. } - // Made just now, it may be, and bound by no command: not left to be - // believed with nothing of this process's in it. - removePrivateManifestDirectory() logForDebugging( `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are kept in memory and removed at this process's own clean-up`, { level: 'warn' }, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index de1d58111..cf3b1efce 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -12,7 +12,6 @@ import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { collectMountPoints, discardMountPointManifest, - ensureManifestDirectory, holdsMountPointManifest, keepTrackOfMountPoint, keepTrackOfMountSource, @@ -2259,8 +2258,19 @@ async function generateFilesystemArgs( // The mount points on the host this wrap relies on: placeholders bwrap makes // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // Where the manifests are kept, each where it really is. + // Where the manifests are kept, each made if it can be. let manifestDirs: string[] = [] + const makeManifestDirs = (recording: boolean): void => { + try { + manifestDirs = mountPointManifestDirectories(recording) + } catch (err) { + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, + errorText(err), + ) + } + } // What the manifests name, as of the last time it was asked. let named: NamedMountPoints | undefined const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { @@ -2798,19 +2808,10 @@ async function generateFilesystemArgs( // against it on both sides of the await above, the only one here. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() - // Made before any reading of them. What the environment names is believed - // only while none of the others can be written, and what this wrap found - // named there its own manifest would carry to where every process believes - // it. Resolved at once, while each is known for a directory and no link. - try { - manifestDirs = mountPointManifestDirectories().map(canonicalForm) - } catch (err) { - throw new LinuxSandboxProfileError( - 'mount_points_unreadable', - `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, - errorText(err), - ) - } + // Before any reading of them. What the environment names is believed only + // while none of the others can be written, and what this wrap found named + // there its own manifest would carry to where every process believes it. + makeManifestDirs(false) // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and @@ -3547,19 +3548,16 @@ async function generateFilesystemArgs( // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. - // With a manifest to come, where it goes is settled now, to be bound and - // pinned too: it may be a directory of the process's own. + // With a manifest to come, where it goes is settled now, to be pinned too. if (mountPoints.length > 0 || emptySource !== undefined) { - const own = ensureManifestDirectory() - if (own !== undefined) manifestDirs.push(canonicalForm(own)) + makeManifestDirs(true) } const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), - ...manifestDirs, - ...[maskedFileStoreDir, emptySource].flatMap(dir => + ...[...manifestDirs, maskedFileStoreDir, emptySource].flatMap(dir => dir === undefined ? [] : [canonicalForm(dir)], ), ], @@ -3820,8 +3818,7 @@ async function generateFilesystemArgs( [...madeIn].every(relied => isThere(relied, true)) && mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && // Else settled on since the pins were made: nothing is pinned above it. - (manifest === undefined || - manifestDirs.includes(canonicalForm(manifest.dir))) + (manifest === undefined || manifestDirs.includes(manifest.dir)) } finally { if (!holds && manifest !== undefined) { discardMountPointManifest(manifest.file) @@ -3868,7 +3865,7 @@ async function generateFilesystemArgs( // gone by the time the command starts - a temp dir whose parent was // replaced - is not an error. // Each where it really is, once: two of the names may lead to one place. - const others = new Set(manifestDirs) + const others = new Set(manifestDirs.map(canonicalForm)) if (manifest !== undefined) others.delete(canonicalForm(manifest.dir)) for (const dir of others) { if (isInReach(dir)) args.push('--ro-bind-try', dir, dir) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 445ca3a23..b1c706227 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2123,9 +2123,8 @@ describe.if(isLinux)( /** * Runs `then` when the wrap has decided where its mount points go and has - * yet to pin what lies above them: as it asks where the directory they - * bind from really is, which is pinned too. It asks once in a plan that has - * a directory among them. + * yet to pin what lies above them: as it asks where the manifest directory + * really is, which is pinned too. It asks once in a plan. */ function beforeThePins(then: () => void): { restore(): void } { const native = fs.realpathSync.native @@ -2133,7 +2132,7 @@ describe.if(isLinux)( dir: unknown, ...rest: unknown[] ) => { - if (basename(String(dir)).startsWith('claude-empty-')) then() + if (dir === runtime.manifestDir()) then() return (native as (...args: unknown[]) => unknown)(dir, ...rest) }) as never) return { restore: () => spy.mockRestore() } @@ -2163,11 +2162,9 @@ describe.if(isLinux)( goes(published++ === 0 && !early), ) const pinning = beforeThePins(() => goes(early)) - // Absent from the first plan on, so that has a directory to bind from. - const other = join(AREA, 'proj', 'other') let command: string try { - command = await wrap('true', [spared, LEAF, join(other, 'leaf')]) + command = await wrap('true', [spared, LEAF]) } finally { pinning.restore() publishing.restore() @@ -2181,9 +2178,7 @@ describe.if(isLinux)( // Nothing is left of the plan it gave up, nor of what only that named: // found there, the file would be the caller's own from now on. expect(command).toContain(`--ro-bind /dev/null ${spared}`) - expect(namedBy(command).sort()).toEqual( - [spared, DIRECTORY, other].sort(), - ) + expect(namedBy(command).sort()).toEqual([spared, DIRECTORY].sort()) expect(readdirSync(join(AREA, 'proj'))).toEqual([]) expect(readdirSync(runtime.manifestDir())).toEqual([ basename(recordedBy(command)!), @@ -2291,7 +2286,7 @@ describe.if(isLinux)( } }) - it("goes to a directory of the process's own that the same plan binds, and that does not stay when it cannot be written", async () => { + it("goes to a directory of the process's own that the same plan binds, written or not", async () => { // Neither shared name will do, from the start. const shared = [ runtime.manifestDir(), @@ -2318,12 +2313,11 @@ describe.if(isLinux)( published = 0 const command = await wrap('true', [LEAF]) expect(published).toBe(1) - // Believed while it is there, and a wrap that settles on it only as - // it publishes has not bound it. - expect(own()).toEqual(full ? [] : [dirname(recordedBy(command)!)]) - for (const dir of own()) { - expect(lastMountAt(command, dir)).toBe(`--ro-bind ${dir} ${dir}`) - } + expect(own()).toHaveLength(1) + expect(recordedBy(command) === undefined).toBe(full) + expect(lastMountAt(command, own()[0]!)).toBe( + `${full ? '--ro-bind-try' : '--ro-bind'} ${own()[0]} ${own()[0]}`, + ) cleanupBwrapMountPoints({ force: true }) expect(own()).toEqual([]) } @@ -2333,8 +2327,8 @@ describe.if(isLinux)( } }) - it('is kept in directories that are bound where the plan found them, not where a link put at the name since leads', async () => { - // Bound last, that would lie on top of every read deny under it. + it('is kept in directories that the wrap looks at again before it binds them, so a link put at a name since is not followed', async () => { + // Bound last, where the link leads would lie on top of every read deny. const shared = join(tmpdir(), `srt-mount-points-${process.getuid!()}`) const elsewhere = join(BASE, 'elsewhere') mkdirSync(elsewhere) @@ -2349,13 +2343,12 @@ describe.if(isLinux)( }) as never) try { const command = await wrap('true', [LEAF]) + expect(lstatSync(shared).isSymbolicLink()).toBe(true) expect(lastMountAt(command, elsewhere)).toBeUndefined() - expect(lastMountAt(command, shared)).toBe( - `--ro-bind-try ${shared} ${shared}`, - ) + expect(lastMountAt(command, shared)).toBeUndefined() } finally { spy.mockRestore() - rmSync(shared) + rmSync(shared, { recursive: true }) } }) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 836695292..225fe9b91 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -3113,11 +3113,10 @@ describe.if(isLinux)('The mount point manifests', () => { `const published = m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null`, `const live = [...m.liveMountPoints()]`, `const directories = m.mountPointManifestDirectories()`, - `const settled = m.ensureManifestDirectory() ?? null`, `const collected = m.collectMountPoints('all')`, `u.cleanupBwrapMountPoints()`, `u.cleanupBwrapMountPoints({ force: true })`, - `console.log(JSON.stringify({ published, live, directories, settled, collected }))`, + `console.log(JSON.stringify({ published, live, directories, collected }))`, ].join('\n'), { first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, @@ -3128,7 +3127,6 @@ describe.if(isLinux)('The mount point manifests', () => { published: null, live: [], directories: [], - settled: null, collected: [], }) expect(existsSync(X)).toBe(true) From db71e7c924ee78fa00566eb932d38d0e871b3540 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:03:35 +0000 Subject: [PATCH 127/141] test: the case of a link put at a shared name holds in any order Its swap hangs on the wrap making the directory that mount points bind from, which a wrap does only with none left from an earlier case. In file order the case before it had cleaned up, under a filter or in a random order it failed. It now removes what is left first, and acts only on that one directory being made. Three clean-ups in `finally` could throw and replace the failure they followed. Two titles said of a mount point what is true of its manifest, one claimed the second look for every wrap, one still had the directory settled on as the wrap records, and a comment said more of read denies than holds. One of three rounds in a loop held nothing the others do not. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm Co-authored-by: ronleizrowice --- test/sandbox/mount-point-liveness.test.ts | 36 +++++++++++++---------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index b1c706227..e9ba7b146 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -2282,11 +2282,11 @@ describe.if(isLinux)( publishing.restore() pinning.restore() spy.mockRestore() - shared.forEach(name => rmSync(name)) + shared.forEach(name => rmSync(name, { recursive: true, force: true })) } }) - it("goes to a directory of the process's own that the same plan binds, written or not", async () => { + it("is recorded in a directory of the process's own that the same plan binds, whether or not the manifest can be written", async () => { // Neither shared name will do, from the start. const shared = [ runtime.manifestDir(), @@ -2309,7 +2309,7 @@ describe.if(isLinux)( }) try { // The forced clean-up removes the directory, so each wrap settles anew. - for (full of [false, false, true]) { + for (full of [false, true]) { published = 0 const command = await wrap('true', [LEAF]) expect(published).toBe(1) @@ -2323,23 +2323,29 @@ describe.if(isLinux)( } } finally { publishing.restore() - shared.forEach(name => rmSync(name)) + shared.forEach(name => rmSync(name, { recursive: true, force: true })) } }) - it('is kept in directories that the wrap looks at again before it binds them, so a link put at a name since is not followed', async () => { - // Bound last, where the link leads would lie on top of every read deny. + it('is recorded among directories that the wrap naming it looks at again before it binds them, so a link put at a name since is not followed', async () => { + // Bound last, where the link leads would lie on top of the read denies + // beneath it. const shared = join(tmpdir(), `srt-mount-points-${process.getuid!()}`) const elsewhere = join(BASE, 'elsewhere') mkdirSync(elsewhere) - // As the wrap makes what its mount point binds from, in the deny loop. + // As the wrap makes what its mount point binds from, in the deny loop: + // it makes one only with none left from an earlier case. + emptySources().forEach(source => fs.rmdirSync(source)) const mkdtemp = fs.mkdtempSync const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( - ...args: unknown[] + prefix: unknown, + ...rest: unknown[] ) => { - fs.rmdirSync(shared) - symlinkSync(elsewhere, shared) - return (mkdtemp as (...args: unknown[]) => unknown)(...args) + if (String(prefix).includes('claude-empty-')) { + rmSync(shared, { recursive: true }) + symlinkSync(elsewhere, shared) + } + return (mkdtemp as (...args: unknown[]) => unknown)(prefix, ...rest) }) as never) try { const command = await wrap('true', [LEAF]) @@ -2348,7 +2354,7 @@ describe.if(isLinux)( expect(lastMountAt(command, shared)).toBeUndefined() } finally { spy.mockRestore() - rmSync(shared, { recursive: true }) + rmSync(shared, { recursive: true, force: true }) } }) @@ -2784,9 +2790,9 @@ describe.if(isLinux)( expectPinned(command, join(HOME, 'run')) }, 30000) - it('are pinned above the directory of its own a process settles on as it records', () => { - // Neither shared name will do, so the wrap makes a directory only this - // process knows. + it('are pinned above the directory of its own a process settles on to record in', () => { + // No runtime directory, and the shared name will not do, so the wrap makes + // a directory only this process knows. takeTheSharedName() const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, From 5cf37471dc409851d483a3ff3babf5a1c83ba31d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:26:22 +0000 Subject: [PATCH 128/141] linux: the mount point of a file git reads back is bound onto itself An absent `commondir` or `config.worktree` was denied by writing the file on the host and binding a copy of the same bytes, kept in a directory under the temp dir, over it. That directory was one more thing to hold: it was bound read-only only in the wraps that used it, never looked at again once made, never remade once removed, and left behind by a killed process. The file is now bound read-only onto itself and the directory is gone, with the change to the masked file store that served it. - The file is made through a descriptor of the git directory that followed no link, whose path must still be the one the plan looked at. Written by path, it went wherever a command already running had pointed the directory. - Nothing found at the name is written over. An empty `commondir` that a manifest names is no longer repaired: the git directory is denied whole for that wrap, and the file goes with the next clean-up. - A full disk or a spent quota counts as a directory that takes no file, so the git directory is denied whole and `rm` can still be run. - Every refusal of one plan is learned in that plan. Learned one at a time, three such directories used up the plans a wrap has. The tests of all this are one file now, about 900 lines shorter. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- README.md | 14 +- src/sandbox/bwrap-mount-manifests.ts | 23 +- src/sandbox/credential-mask-files.ts | 34 +- src/sandbox/linux-sandbox-utils.ts | 506 +++----- src/sandbox/mandatory-deny-paths.ts | 6 +- test/helpers/leftover-manifest.ts | 40 - test/sandbox/credential-mask-files.test.ts | 34 +- test/sandbox/deny-order-independence.test.ts | 9 +- test/sandbox/linux-bwrap-args-file.test.ts | 5 +- test/sandbox/linux-git-dir-whole.test.ts | 87 +- .../linux-git-redirect-placeholder.test.ts | 1049 +++++++++-------- test/sandbox/mandatory-deny-paths.test.ts | 650 +--------- 12 files changed, 775 insertions(+), 1682 deletions(-) delete mode 100644 test/helpers/leftover-manifest.ts diff --git a/README.md b/README.md index e1d6bdabe..1faf18d6b 100644 --- a/README.md +++ b/README.md @@ -1209,14 +1209,12 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. - On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there, so every submodule keeps its precise denies however many there are. A Seatbelt profile has no cap on entries of its own, but it travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, from the caller's own `spawn` and with no code from this library. -**The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts and once the manifest that names it is published, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. - -- The file is a mount point like any other (see "Write denies on paths that do not exist yet (Linux)" above): it is named in the wrap's manifest and removed once no running sandbox relies on it, while it still holds what was written there. One left behind by a killed process is named by the manifest that process left: the next wrap names it too, and the next cleanup of any process removes it. An empty `commondir` that a manifest names, which git refuses outright, is repaired. -- Only a manifest makes it one. A `commondir` or a `config.worktree` that no manifest names is somebody's own whatever it holds, `.` included: it is bound read-only where it is and is never removed. An empty `commondir` among those may belong to a host `git` caught between creating the file and writing it: it is covered with the placeholder for the length of the command and left exactly as found. -- For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. -- Where the git directory takes no mount point at all — one this process cannot write, such as a root-owned vendored checkout or a read-only mount — the git directory is bound read-only whole instead. That needs nothing written to the host, costs what was already unwritable, and is also what stops the command from making the directory writable and creating the file itself. That bind is then the only mount the wrap puts inside the directory: a placeholder for another absent entry of it (a `config` or a `hooks` that is not there) would need a mount point made where none can be, or made by bubblewrap where this process could never remove it, so those entries are left to the bind, which denies them with everything else in the directory. The directory's mode is asked first, and does not always tell (an immutable directory, a file system that maps owners): where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). -- A name found taken is read, and an empty one repaired, through the file that was looked at and never through its name: the git directory can be one a command already running writes, and a check of the path followed by a write to it would write wherever that command had pointed the name in between. Where the name has been given to something else meanwhile (a link, another file, a FIFO) that is left exactly as found and the wrap plans its mounts again, on what is there by then: an entry that is a link has the git directory bound read-only whole, and anything else is somebody's file, bound where it is. The cleanup likewise leaves a mount point whose name no longer leads to what was written there. -- Two processes wrapping commands in one repository share the file. Each names it in a manifest of its own, so it stays until neither has a sandbox running. It is written only after the manifest is published, so that a wrap in another process that finds the file finds the manifest that names it as well, and does not take it for somebody's own. +**The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap makes the mount point itself, once the manifest that names it is published, holding what git concludes with no file there (`.` for `commondir`, nothing for `config.worktree`), and binds it read-only onto itself. + +- The file is a mount point like any other (see "Write denies on paths that do not exist yet (Linux)" above): named in the manifest of every wrap that relies on it, and removed once no running sandbox does, while it still holds what was written there. Only a manifest makes it one. A `commondir` or a `config.worktree` that no manifest names is somebody's own whatever it holds: it is bound read-only where it is and is never removed. +- For as long as the file is there, git inside the sandbox and git on the host both read a redirect git accepts, but not what they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. +- The git directory can be one a command already running writes. So nothing found there is ever written over, and the file is made only in the directory the wrap looked at, never wherever its path leads by then. Where the name has been taken meanwhile (a link, another file, a FIFO), or the directory swapped for a link, that is left as found and the wrap plans its mounts again on what is there by then. +- Where the git directory takes no file from this process (its mode, a read-only or immutable file system, a full disk), it is bound read-only whole instead, and the command can write nothing in it (`git add`, `git commit`). The same goes for one that holds an empty `commondir` a manifest names, which is what bubblewrap makes where the file went from under a command about to start: it goes with the next clean-up. That bind is the only mount the wrap puts inside the directory, since a mount point for another absent entry could not be made there, or could not be removed again. The directory's mode is asked first and does not always tell: where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). **Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 305800c71..bf3a50d3f 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -947,17 +947,6 @@ function removeMountSource(source: string): Removal { } } -/** Whether `stat` is of an empty regular file under one name, and the user's: - * what a mount point that is a file looks like as bubblewrap makes it. */ -export function isEmptyFileOfOurs(stat: fs.Stats): boolean { - return ( - stat.isFile() && - stat.size === 0 && - stat.nlink === 1 && - stat.uid === process.getuid?.() - ) -} - /** {@link kindOfMountPoint}, throwing what the look at `p` throws. */ function shapeOf(p: string): 'file' | 'directory' | undefined { const stat = fs.lstatSync(p) @@ -967,16 +956,14 @@ function shapeOf(p: string): 'file' | 'directory' | undefined { if (stat.isDirectory()) { return 'directory' } - if (isEmptyFileOfOurs(stat)) { + if (!stat.isFile() || stat.nlink !== 1) { + return undefined + } + if (stat.size === 0) { return 'file' } const written = gitRedirectPlaceholder(p) - if ( - written === undefined || - !stat.isFile() || - stat.nlink !== 1 || - stat.size !== Buffer.byteLength(written) - ) { + if (stat.size !== written?.length) { return undefined } try { diff --git a/src/sandbox/credential-mask-files.ts b/src/sandbox/credential-mask-files.ts index b28e2a6e7..2ee8e136e 100644 --- a/src/sandbox/credential-mask-files.ts +++ b/src/sandbox/credential-mask-files.ts @@ -54,9 +54,7 @@ export interface MaskedFileBind { } /** - * Manager-owned temp dir holding the fake files. The Linux backend keeps a - * second store of its own for the placeholders its git redirect denies bind - * from, which need this same guarantee for this same reason. + * Manager-owned temp dir holding the fake files. * * INVARIANT: this directory must never be writable from inside the sandbox. * The Linux layer enforces this by emitting `--ro-bind ` @@ -69,13 +67,6 @@ export interface MaskedFileBind { export class MaskedFileStore { private dir: string | undefined private readonly byKey = new Map() - private readonly dirPrefix: string - - /** `dirPrefix` names this store's temp directory, so what it holds is - * recognisable on disk and one store's directory is never another's. */ - constructor(dirPrefix: string = MASKED_FILE_STORE_PREFIX) { - this.dirPrefix = dirPrefix - } /** * Write `sentinel` to a fake file for `key` and return its path. @@ -85,7 +76,7 @@ export class MaskedFileStore { */ write(key: string, sentinel: string): string { if (this.dir === undefined) { - this.dir = fs.mkdtempSync(join(tmpdir(), this.dirPrefix)) + this.dir = fs.mkdtempSync(join(tmpdir(), 'srt-credmask-')) } let fakePath = this.byKey.get(key) if (fakePath === undefined) { @@ -94,21 +85,12 @@ export class MaskedFileStore { } // Never follow a symlink at fakePath: a prior sandbox invocation may // have planted one (the store dir is ro-bound now, but defence in - // depth). So a fresh regular file is made beside it and moved onto the - // name, which replaces a link rather than following it. Moved, not - // unlinked and written again: a command line handed out earlier binds from - // this name, and bubblewrap does not start on a source that is not there. - // - // 0600: owner rw; the bind into the sandbox is --ro-bind so the sandboxed - // process sees it read-only regardless of the host mode. No group/other. - const fresh = `${fakePath}.${randomUUID()}` - try { - fs.writeFileSync(fresh, sentinel, { mode: 0o600, flag: 'wx' }) - fs.renameSync(fresh, fakePath) - } catch (err) { - fs.rmSync(fresh, { force: true }) - throw err - } + // depth). Unlink first so writeFileSync creates a fresh regular file. + fs.rmSync(fakePath, { force: true }) + // 0600: owner rw so the idempotent rewrite above succeeds; the bind + // into the sandbox is --ro-bind so the sandboxed process sees it + // read-only regardless of the host mode. No group/other. + fs.writeFileSync(fakePath, sentinel, { mode: 0o600 }) return fakePath } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index f11e3f425..80889bcdc 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -18,7 +18,6 @@ import { collectMountPoints, discardMountPointManifest, holdsMountPointManifest, - isEmptyFileOfOurs, keepTrackOfMountPoint, keepTrackOfMountSource, kindOfMountPoint, @@ -81,7 +80,6 @@ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, } from './sandbox-schemas.js' -import { MaskedFileStore } from './credential-mask-files.js' import { getApplySeccompBinaryPath } from './generate-seccomp-filter.js' import type { SeccompConfig } from './sandbox-config.js' @@ -327,95 +325,11 @@ function fileOnTheWay(targetPath: string): string | undefined { return undefined } -/** - * How a name is opened in a git directory a command already running may be - * writing: one that made the directory after its own wrap has nothing of it - * mounted. A link there is refused rather than followed, because it leads - * wherever that command chose, and a FIFO there is not waited on. - */ -const NO_LINK_NO_WAIT = fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK - -/** - * What an open with {@link NO_LINK_NO_WAIT} ends on when the name holds - * something other than the file last seen there: a link, nothing, or a FIFO - * nobody reads. - */ -const NAME_HOLDS_SOMETHING_ELSE = new Set([ - 'ELOOP', - 'EMLINK', - 'ENOENT', - 'ENXIO', -]) - -/** - * Write `placeholder` into the empty mount point at `dest`, which a manifest - * names, and say whether that was done. The shape is checked and the bytes - * are written through descriptors on one file, opened following no link: a - * check of the path followed by a write to the path would, in this - * unsandboxed process, truncate whatever the name had been pointed at in - * between. False when the name no longer holds that mount point, which is - * then left exactly as found; throws what stopped the write otherwise. - */ -function rewriteEmptyMountPoint(dest: string, placeholder: string): boolean { - const fds: number[] = [] - const open = (access: number): number => { - const fd = fs.openSync(dest, access | NO_LINK_NO_WAIT) - fds.push(fd) - return fd - } - try { - const readFd = open(fs.constants.O_RDONLY) - const found = fs.fstatSync(readFd) - if (!isEmptyFileOfOurs(found)) return false - let writeFd: number - try { - writeFd = open(fs.constants.O_WRONLY) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EACCES') throw err - // bubblewrap makes its mount points read-only (ensure_file(dest, - // 0444)), and the process that owns one need not be root, so the mode - // it was left with is no reason to leave the repository broken. Changed - // on the file whose shape was just checked, not on the name. - fs.fchmodSync(readFd, 0o644) - writeFd = open(fs.constants.O_WRONLY) - } - // The name may have changed hands between the two opens. The first - // descriptor is still open, so its inode number cannot have been given - // to another file meanwhile. - const opened = fs.fstatSync(writeFd) - if (opened.dev !== found.dev || opened.ino !== found.ino) return false - fs.ftruncateSync(writeFd, 0) - fs.writeSync(writeFd, placeholder, 0) - return true - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code !== undefined && NAME_HOLDS_SOMETHING_ELSE.has(code)) return false - throw err - } finally { - for (const fd of fds) fs.closeSync(fd) - } -} - -/** - * A deny destination that is one of the files git reads back - * (`GIT_REDIRECT_FILES` in src/sandbox/mandatory-deny-paths.ts says which and - * why), as the deny loop found it. - */ -type GitRedirect = { - /** What git has to read there for as long as the deny stands. */ - placeholder: string - /** - * Nothing was there, or what is there is a mount point that a manifest - * names: this wrap names it too, and sees to what it holds. Otherwise it is - * the caller's file, and only a bind lands on it. - */ - isMountPoint: boolean -} - -/** Whether `err` is a directory's refusal to have a file made in it. */ +/** Whether `err` is a directory's refusal to have a file made in it: its mode, + * an immutable or read-only one, or no room left. */ function refusesAFile(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code - return code === 'EACCES' || code === 'EPERM' || code === 'EROFS' + return ['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT'].includes(code ?? '') } /** @@ -435,101 +349,57 @@ function takesAFileFromHere(dir: string): boolean { } /** - * See to it that the mount point at `dest`, a file git reads back, holds - * `placeholder` on the HOST, and say whether it does. A mount point bubblewrap - * makes is empty, and git refuses to run at all against a `commondir` it - * cannot read, outside the sandbox as well for as long as the command runs. + * See to it that the mount point at `dest`, a file git reads back, is there on + * the HOST and holds what git reads as no redirect (see + * `gitRedirectPlaceholder`). A mount point bubblewrap makes is empty, and git + * refuses to run at all against a `commondir` it cannot read, outside the + * sandbox as well for as long as the command runs. The profile binds the file + * read-only onto itself. * * INVARIANT: called only once this wrap's manifest, which names `dest`, is - * published and the clean-ups under way then are over. A sandbox publishes its - * manifest before its mount points are made: another wrap that finds the file - * reads the manifests after it has seen it, and takes what none of them names - * for the caller's own, which it binds from itself and does not name. Written - * any earlier, the file would go with this wrap's clean-up from under that - * sandbox. A process killed from here on leaves a file a manifest names, which - * the next clean-up of any process removes. + * published and the clean-ups under way then are over. Another wrap that finds + * the file reads the manifests after it has seen it, and takes what none of + * them names for the caller's own. A process killed from here on leaves a file + * a manifest names, which the next clean-up of any process removes. * - * Absent: written exclusively, so that a file that has appeared since the plan - * looked is never truncated, with bubblewrap's own read-only mode. There and - * holding the placeholder: another wrap's, made since or covered again by this - * plan. There and empty: bubblewrap's, for a wrap whose own write came to - * nothing, and repaired through descriptors that follow no link. + * THREAT: a command already running may be writing the git directory, if it + * made it after its own wrap. So the directory is held by a descriptor that + * followed no link, and nothing there is ever written over: the file is made + * exclusively, or found as another wrap made it. * - * `changed` where the name has been given to something else meanwhile, which is - * left alone, or the directory has gone; `refused` where the directory takes no - * file from this process after all. The caller plans again either way, on what - * is there by then. Throws {@link LinuxSandboxProfileError} when the - * placeholder cannot be written at all: what is left is an empty file, which - * costs the repository every git command outside the sandbox, so the command - * is refused rather than run behind that. + * `changed` where the directory or the name is no longer what the plan saw; + * `refused` where the directory takes no file from this process after all, or + * what a killed wrap left there is no use to git. The caller plans again either + * way. Throws {@link LinuxSandboxProfileError} on anything else. */ function writeGitRedirectMountPoint( dest: string, - placeholder: string, ): 'held' | 'changed' | 'refused' { + const placeholder = gitRedirectPlaceholder(dest) ?? '' + let fd: number | undefined try { - fs.writeFileSync(dest, placeholder, { flag: 'wx', mode: 0o444 }) - logForDebugging( - `[Sandbox Linux] Wrote the mount point ${dest} holding ${JSON.stringify(placeholder)}, so git reads no redirect there while the command runs`, + fd = fs.openSync( + path.dirname(dest), + O_PATH | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, ) - return 'held' + const gitDir = `/proc/self/fd/${fd}` + // Its link differs from the path when a symlink was followed on the way. + if (fs.readlinkSync(gitDir) !== path.dirname(dest)) return 'changed' + const file = `${gitDir}/${path.basename(dest)}` + try { + fs.writeFileSync(file, placeholder, { flag: 'wx', mode: 0o444 }) + return 'held' + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + if (kindOfMountPoint(file) !== 'file') return 'changed' + return fs.lstatSync(file).size === placeholder.length ? 'held' : 'refused' } catch (err) { if (refusesAFile(err)) return 'refused' if (isAbsenceErrno(err)) return 'changed' - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { - throw placeholderUnavailable( - dest, - 'its mount point could not be made', - err, - ) - } - } - try { - // Empty, or holding what is written at that name. - if (kindOfMountPoint(dest) !== 'file') return 'changed' - if (placeholder === '' || fs.lstatSync(dest).size > 0) return 'held' - if (!rewriteEmptyMountPoint(dest, placeholder)) return 'changed' - } catch (err) { - throw placeholderUnavailable( - dest, - 'the empty mount point there could not be rewritten', - err, - ) - } - logForDebugging( - `[Sandbox Linux] Rewrote the empty mount point ${dest} to ${JSON.stringify(placeholder)}: git reads no bytes there as a fault, not as "no redirect"`, - ) - return 'held' -} - -/** - * The store's copy of `placeholder`, made once per plan: the store writes the - * file afresh on every call, which is what revalidates it, and one plan needs - * one such check however many destinations share the content. - * - * What is bound over a file git reads back is this copy, never the mount point - * itself: a host process rewriting that between the plan and the mount would - * otherwise choose what the sandbox reads. - */ -function gitRedirectStoreFile( - placeholder: string, - storeFiles: Map, -): string { - const made = storeFiles.get(placeholder) - if (made !== undefined) return made - try { - // Keyed by content, so one file serves every destination that needs it. - const file = gitRedirectStore.write(placeholder, placeholder) - storeFiles.set(placeholder, file) - // The wrap may come to nothing, or name no mount point. - registerExitCleanupHandler() - return file - } catch (err) { - throw placeholderUnavailable( - 'a file git reads back', - 'no placeholder file could be written to the temporary directory', - err, - ) + throw placeholderUnavailable(dest, 'its mount point could not be made', err) + } finally { + if (fd !== undefined) fs.closeSync(fd) } } @@ -1706,19 +1576,6 @@ function asProfileRefusal(produce: () => T): T { } } -/** Temp-directory prefix of the store below, so it is nobody else's. */ -const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' - -// The placeholders the git redirect denies bind over their mount points: one -// file per distinct content for the life of the process, rewritten on each -// use, and pinned read-only inside every sandbox that mounts one. It is the -// masked-file store's own class for the reason that store exists — a bind -// exposes the source file itself, so a command that can reach the source -// under a name it can write chooses what git reads at the denied path. -// Emptied by cleanupBwrapMountPoints({ force: true }), which reset() and the -// process-exit handler call. -const gitRedirectStore = new MaskedFileStore(GIT_REDIRECT_STORE_PREFIX) - // The source of the empty-directory mount points: at most one at a time, made // on first use, reused while a sandbox is running, and removed with the mount // points — never before, because a live bind's source must stay. @@ -2384,8 +2241,7 @@ function registerExitCleanupHandler(): void { * - with no `commandId`: none until it has been called once for every wrap * handed out, since nothing can tell which command the call is for; * - with the `commandId` its wrap was given: that command's, at once; - * - with `force`: all, because the process or session is ending. That is also - * what empties the store of git redirect placeholders, which is per process. + * - with `force`: all, because the process or session is ending. * * A command line let go of is refused its start from then on. * @@ -2458,10 +2314,6 @@ function cleanUpAfter( } } if (opts.force) { - // The placeholders outlive a batch of wraps the way the masked-file - // store's fakes do: what ends them is the session (or the process), not - // a command. A command that has not started binds from them yet. - gitRedirectStore.dispose() removePrivateManifestDirectory() } } @@ -3373,14 +3225,6 @@ type FilesystemArgs = { mandatoryDenyArgs: number } -/** The mounts for one deny list, planned, with nothing relied on yet. */ -type FilesystemPlan = { - /** How many arguments it comes to once it is published, at most. */ - words: number - /** The mount points on the host it would name. */ - mountPoints: readonly string[] -} - /** * Generate filesystem bind mount arguments for bwrap, inside `wordsAvailable` * of bubblewrap's cap on parsed words. `undefined`, with its manifest let go @@ -3402,8 +3246,8 @@ type FilesystemPlan = { * * Only the pass that is settled on is published. A pass that is degraded has * named nothing, and has made nothing on the host but where the library makes - * a mount point itself (see makeableMountPoint): what is found there of it is - * named with the mount points of the pass that is published. + * a mount point itself, which is kept track of from then on (see + * makeableMountPoint). * * `gitDirsThatRefusedAFile` is the caller's, for all the plans of one wrap: the * git directories in which a plan could not write what it had planned to. @@ -3422,10 +3266,9 @@ async function generateFilesystemArgs( gitDirsThatRefusedAFile: Set = new Set(), ): Promise { const startedIn = workingDirectory() - const leftByEarlierPasses: string[] = [] const build = ( mandatoryDenyPaths: string[], - isSettledOn: (planned: FilesystemPlan) => boolean, + isSettledOn: (words: number) => boolean, ): Promise => buildFilesystemArgs( readConfig, @@ -3434,7 +3277,6 @@ async function generateFilesystemArgs( maskedFileStoreDir, mandatoryDenyPaths, isSettledOn, - leftByEarlierPasses, gitDirsThatRefusedAFile, startedIn, abortSignal, @@ -3462,7 +3304,7 @@ async function generateFilesystemArgs( let degraded: CollapseLevel | undefined const filesystem = await build( collapsedForWrap(plan, level, writeRoots), - ({ words, mountPoints }) => { + words => { degraded = words <= wordsAvailable ? undefined @@ -3489,11 +3331,7 @@ async function generateFilesystemArgs( degraded = collapseEverything(plan) } wordsBefore = words - if (degraded === undefined) return true - leftByEarlierPasses.push( - ...mountPoints.filter(made => kindOfMountPoint(made) !== undefined), - ) - return false + return degraded === undefined }, ) if (degraded !== undefined) { @@ -3553,7 +3391,8 @@ function collapseFloor( * as {@link generateFilesystemArgs} has settled them, and the caller's own * `denyWithinAllow` entries are added to them here. * - * `isSettledOn` is asked once the mounts are planned. Until then nothing is + * `isSettledOn` is asked once the mounts are planned, with how many arguments + * they come to at most once they are published. Until then nothing is * named, and nothing is written into a repository, and where it says no that is * all: the answer is `undefined`, and the caller knows why. */ @@ -3563,8 +3402,7 @@ async function buildFilesystemArgs( maskedFileBinds: Array<{ realPath: string; fakePath: string }> | undefined, maskedFileStoreDir: string | undefined, mandatoryDenyPaths: string[], - isSettledOn: (planned: FilesystemPlan) => boolean, - leftByEarlierPasses: readonly string[], + isSettledOn: (words: number) => boolean, gitDirsThatRefusedAFile: Set, startedIn: string | undefined, abortSignal: AbortSignal | undefined, @@ -3650,15 +3488,10 @@ async function buildFilesystemArgs( // tampered with in between) would leave the binds already emitted pointing // at a directory this same call has just judged not ours. let emptySource: string | undefined - // Deny destinations that are files git reads back, as the deny loop found - // them. What each is bound from is settled at emission. - const pendingGitRedirects = new Map() - // The store file each placeholder content was written to, for this plan. - const gitRedirectStoreFiles = new Map() - // Those of them that are mount points whose bind lands, and what each has to - // hold on the host: seen to once the manifest that names them is published - // (see writeGitRedirectMountPoint). - const gitRedirectMountPoints = new Map() + // The mount points among the deny destinations that are files git reads + // back. Each is bound onto itself, and is written on the host once the + // manifest that names it is published (see writeGitRedirectMountPoint). + const gitRedirectMountPoints = new Set() // Where a mount given `p` lands: `p` fully resolved, every symlink on the // way and not one hop. One resolution per path per pass, so every predicate // below sees the same answer, and none before the mandatory-deny scan the @@ -4177,48 +4010,47 @@ async function buildFilesystemArgs( if (resolvedPath !== rawPath) { symlinkedDenySpellingDests.add(resolvedPath) } - let isDirectory = false + let isDirectory: boolean | undefined try { isDirectory = fs.statSync(resolvedPath).isDirectory() } catch { // absent (or vanished): not a read-only re-bound directory - // - // The mount point of a file git reads back is written by this process, - // which cannot write every git directory the command can: a vendored - // checkout, or one the command itself made unwritable, whose mode - // bubblewrap's user namespace overrides for its owner. Refusing every - // command in the tree over that would be a brick a sandboxed command - // can plant, and a mount point left to bubblewrap is an empty file no - // clean-up of this process could remove. So the git directory is denied - // whole, which needs nothing written to it and denies the file and - // every other way into it at once. It joins the list here, ahead of - // every stub decision. - const gitDir = path.dirname(resolvedPath) - if ( - gitRedirectPlaceholder(resolvedPath) !== undefined && - isWithinAnyAllowedWritePath(gitDir) && - !gitDirsDeniedWhole.has(gitDir) && - (gitDirsThatRefusedAFile.has(gitDir) || !takesAFileFromHere(gitDir)) - ) { - // No whole-directory deny takes the work tree (see - // withoutWorkTreeDenies), and nothing else stands in for this one. - if (coversWriteTree(gitDir, process.cwd(), allowedWritePaths)) { - throw placeholderUnavailable( - resolvedPath, - `${gitDir} takes no file from this process, and cannot be denied whole instead: it holds the working directory or a path this command may write. Making it writable, from outside the sandbox, lifts this`, - new Error('permission denied'), - ) - } - gitDirsDeniedWhole.add(gitDir) - denyPaths.push(gitDir) - logForDebugging( - `[Sandbox Linux] ${gitDir} takes no mount point for ${path.basename(resolvedPath)} from this process; denying the git directory whole instead, which needs nothing written to it`, - { level: 'warn' }, + } + // The mount point of a file git reads back is written by this process, + // which cannot write every git directory the command can: a vendored + // checkout, or one the command itself made unwritable, whose mode + // bubblewrap's user namespace overrides for its owner. Refusing every + // command in the tree over that would be a brick a sandboxed command can + // plant, and a mount point left to bubblewrap is an empty file no + // clean-up of this process could remove. So the git directory is denied + // whole, which needs nothing written to it and denies the file and every + // other way into it at once. It joins the list here, ahead of every stub + // decision. + const gitDir = path.dirname(resolvedPath) + if ( + gitRedirectPlaceholder(resolvedPath) !== undefined && + isWithinAnyAllowedWritePath(gitDir) && + !gitDirsDeniedWhole.has(gitDir) && + (gitDirsThatRefusedAFile.has(gitDir) || + (isDirectory === undefined && !takesAFileFromHere(gitDir))) + ) { + // No whole-directory deny takes the work tree (see + // withoutWorkTreeDenies), and nothing else stands in for this one. + if (coversWriteTree(gitDir, process.cwd(), allowedWritePaths)) { + throw placeholderUnavailable( + resolvedPath, + `${gitDir} takes no file from this process, and cannot be denied whole instead: it holds the working directory or a path this command may write. Making it writable, from outside the sandbox, lifts this`, + new Error('permission denied'), ) } - continue + gitDirsDeniedWhole.add(gitDir) + denyPaths.push(gitDir) + logForDebugging( + `[Sandbox Linux] ${gitDir} takes no mount point for ${path.basename(resolvedPath)} from this process; denying the git directory whole instead, which needs nothing written to it`, + { level: 'warn' }, + ) } - if (!isDirectory) { + if (isDirectory !== true) { continue } if (meetsAnyAllowedWritePath(resolvedPath)) { @@ -4474,17 +4306,16 @@ async function buildFilesystemArgs( return { dest, kind } } /** - * Has the emission settle what `dest` is bound from, where it is a file git - * reads back. Decided from the resolved path, which every spelling of one - * file shares, rather than from the deny entry: a caller's own denyWrite - * naming the same file must not miss it. What stands in the buffer until - * then is what any other file would be bound from. + * What goes on a mount point that is a file. /dev/null will not do for one + * git reads back: a bind carries nodev, so git cannot read it. Decided from + * the resolved path, which every spelling of one file shares, rather than + * from the deny entry: a caller's own denyWrite naming the same file must + * not miss it. */ - const noteGitRedirect = (dest: string, isMountPoint: boolean): void => { - const placeholder = gitRedirectPlaceholder(dest) - if (placeholder !== undefined) { - pendingGitRedirects.set(dest, { placeholder, isMountPoint }) - } + const fileMountSourceFor = (dest: string): string => { + if (gitRedirectPlaceholder(dest) === undefined) return '/dev/null' + gitRedirectMountPoints.add(dest) + return dest } /** What goes on it. The empty directory is made when first asked for. */ const mountSourceFor = ({ dest, kind }: Placeholder): string => { @@ -4494,7 +4325,7 @@ async function buildFilesystemArgs( case MountPointKind.Directory: return (emptySource ??= ensureEmptyMountSourceDir()) case MountPointKind.File: - return '/dev/null' + return fileMountSourceFor(dest) case MountPointKind.Unmakeable: // Never mounted: unmakeableDenyDests keeps it out of what is emitted. return '/dev/null' @@ -4613,9 +4444,12 @@ async function buildFilesystemArgs( whatIsNamedNow().has(normalizedPath) ) { if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { - denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath) + denyWriteArgs.push( + '--ro-bind', + fileMountSourceFor(normalizedPath), + normalizedPath, + ) denyWriteRawDests.set(normalizedPath, rawPath) - noteGitRedirect(normalizedPath, true) } decidedMountPoints.add(normalizedPath) mountPoints.push(normalizedPath) @@ -4641,8 +4475,6 @@ async function buildFilesystemArgs( // below a second spelling to test, and `dest` itself is always // tested. denyWriteRawDests.set(dest, rawPath) - // As a directory, for a deny beneath it, it is no file git reads. - if (kind === MountPointKind.File) noteGitRedirect(dest, true) logForDebugging( kind === MountPointKind.Unmakeable ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` @@ -4682,7 +4514,6 @@ async function buildFilesystemArgs( ) } } - noteGitRedirect(normalizedPath, false) denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } else { @@ -4931,23 +4762,10 @@ async function buildFilesystemArgs( // land on top of both pins and covers. // // This library's own read-only directories are seeds as well (the manifest - // directories, the fake-file store, the two placeholder sources): a + // directories, the fake-file store, the empty placeholder source): a // read-only bind protects one only while its name still leads to it, and // with the temp dir strictly inside a write root a command could otherwise // rename the temp dir aside and make the name again. - // - // So the store's copies of what git is to read are made by now, for every - // destination that may be bound from one. - // Sorted: the store names a file for when it was first asked for, and the - // mounts do not depend on the order the deny list was written in. - let gitRedirectSourceDir: string | undefined - for (const placeholder of [...pendingGitRedirects.values()] - .map(redirect => redirect.placeholder) - .sort()) { - gitRedirectSourceDir = path.dirname( - gitRedirectStoreFile(placeholder, gitRedirectStoreFiles), - ) - } // With a manifest to come, where it goes is settled now, to be pinned too. if (mountPoints.length > 0 || emptySource !== undefined) { makeManifestDirs(true) @@ -4957,12 +4775,9 @@ async function buildFilesystemArgs( ...denyWriteRawDests.keys(), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), - ...[ - ...manifestDirs, - maskedFileStoreDir, - gitRedirectSourceDir, - emptySource, - ].flatMap(dir => (dir === undefined ? [] : [canonicalForm(dir)])), + ...[...manifestDirs, maskedFileStoreDir, emptySource].flatMap(dir => + dir === undefined ? [] : [canonicalForm(dir)], + ), ], { rootIsWriteRoot: isAllowedWriteRoot('/'), @@ -5006,16 +4821,6 @@ async function buildFilesystemArgs( // Write paths already restored read-only by a dropped deny bind, so two // denies covering the same path emit one --ro-bind. const restoredReadOnlyWritePaths = new Set() - // The caller's own files that are covered, not bound from themselves. - const coveredAsFound: string[] = [] - const isEmptyFile = (file: string): boolean => { - try { - const stat = fs.lstatSync(file) - return stat.isFile() && stat.size === 0 - } catch { - return false - } - } for (let i = 0; i < denyWriteArgs.length; i += 3) { const dest = denyWriteArgs[i + 2]! if (unmakeableDenyDests.has(dest)) continue @@ -5071,28 +4876,7 @@ async function buildFilesystemArgs( } continue } - // This bind lands, so what git reads at `dest` is settled now. - const redirect = pendingGitRedirects.get(dest) - let source = denyWriteArgs[i + 1]! - if (redirect?.isMountPoint) { - // /dev/null will not do: a bind carries nodev, so git cannot read it. - source = gitRedirectStoreFile(redirect.placeholder, gitRedirectStoreFiles) - gitRedirectMountPoints.set(dest, redirect.placeholder) - } else if ( - redirect !== undefined && - redirect.placeholder !== '' && - isEmptyFile(dest) - ) { - // Empty, which git reads as a fault, and no manifest names it: a host git - // caught between creating the file and writing it owns this one. Covered - // with the placeholder for the length of the command, and left alone. - source = gitRedirectStoreFile(redirect.placeholder, gitRedirectStoreFiles) - coveredAsFound.push(dest) - logForDebugging( - `[Sandbox Linux] Binding ${JSON.stringify(redirect.placeholder)} over the empty ${dest}: no manifest names it, so it is not this wrapper's to rewrite`, - ) - } - args.push(denyWriteArgs[i]!, source, dest) + args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) if (i >= mandatoryDenyArgsStart) mandatoryDenyArgs += 3 emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit @@ -5154,7 +4938,7 @@ async function buildFilesystemArgs( // Not for a wrap that restricts no write: it has no mount point of its own, // and the manifests are not kept out of its command's reach either. const madeIn = new Set(mountPoints.map(made => path.dirname(made))) - const boundFrom = new Set(coveredAsFound) + const boundFrom = new Set() if (writeConfig !== undefined) { args.forEach((word, i) => { if (word === '--bind' || word === '--ro-bind' || word === '--tmpfs') { @@ -5185,27 +4969,17 @@ async function buildFilesystemArgs( // a reading taken after the last look at the host above. A directory it left // out (the one a placeholder is nested in, a pin) goes with its maker, and the // command is then refused its start or, once the pin is mounted, loses it. - const planned = { - // With what is still to come: the bind of the manifest, and one of each - // manifest directory and of each directory that mounts are made from. - words: - args.length + - BWRAP_WORDS_PER_MOUNT * - (1 + - manifestDirs.length + - [maskedFileStoreDir, gitRedirectSourceDir, emptySource].filter( - dir => dir !== undefined, - ).length), - mountPoints, + // + // Counted with what is still to come: the bind of the manifest, and one of + // each manifest directory and of each directory that mounts are made from. + const stillToCome = + 1 + + manifestDirs.length + + [maskedFileStoreDir, emptySource].filter(dir => dir !== undefined).length + if (!isSettledOn(args.length + BWRAP_WORDS_PER_MOUNT * stillToCome)) { + return undefined } - if (!isSettledOn(planned)) return undefined - const names = [ - ...new Set([ - ...mountPoints, - ...leftByEarlierPasses, - ...mountPointsReliedOn(), - ]), - ] + const names = [...mountPoints, ...mountPointsReliedOn()] const manifest = publishMountPointManifest( names, emptySource === undefined ? [] : [emptySource], @@ -5229,6 +5003,22 @@ async function buildFilesystemArgs( return !isAbsenceErrno(err) } } + // All of them, whatever the first says, so that the next plan of this wrap + // goes by every refusal that asking did not tell (see takesAFileFromHere). + const writeGitRedirectMountPoints = (): boolean => { + const landed = new Set(emittedDenyWriteDests) + let held = true + for (const dest of gitRedirectMountPoints) { + if (!landed.has(dest)) continue + const gitDir = path.dirname(dest) + const outcome = gitDirsThatRefusedAFile.has(gitDir) + ? 'refused' + : writeGitRedirectMountPoint(dest) + if (outcome === 'refused') gitDirsThatRefusedAFile.add(gitDir) + held &&= outcome === 'held' + } + return held + } let holds = false try { // INVARIANT: no command is handed out while a clean-up is under way that @@ -5268,21 +5058,14 @@ async function buildFilesystemArgs( // Else let go of during the wait, by a forced clean-up: no command starts // under it any more. (manifest === undefined || holdsMountPointManifest(manifest.file)) && + // Only under a manifest that holds: it writes on the host. And ahead of + // the look at what the profile binds from, which these are among. + writeGitRedirectMountPoints() && [...boundFrom].every(relied => isThere(relied, false)) && [...madeIn].every(relied => isThere(relied, true)) && mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && // Else settled on since the pins were made: nothing is pinned above it. - (manifest === undefined || manifestDirs.includes(manifest.dir)) && - // Last, and only under a manifest that holds: it writes on the host. - [...gitRedirectMountPoints].every(([dest, placeholder]) => { - const outcome = writeGitRedirectMountPoint(dest, placeholder) - // What asking did not tell (see takesAFileFromHere): the next plan of - // this wrap goes by the refusal. - if (outcome === 'refused') { - gitDirsThatRefusedAFile.add(path.dirname(dest)) - } - return outcome === 'held' - }) + (manifest === undefined || manifestDirs.includes(manifest.dir)) } finally { if (!holds && manifest !== undefined) { discardMountPointManifest(manifest.file) @@ -5349,17 +5132,6 @@ async function buildFilesystemArgs( args.push('--ro-bind', dir, dir) } - // INVARIANT, for the same reason and with the same remedy: the store of git - // redirect placeholders. A bind exposes the source file itself, so a - // command able to write it chooses what git reads at the DENIED path. The - // three mount sources here are siblings under the temp directory, each its - // own mkdtemp, so no one of these binds can cover another whatever the - // order; all three sit after every mount that could cover them. - if (gitRedirectSourceDir !== undefined) { - const dir = canonicalForm(gitRedirectSourceDir) - args.push('--ro-bind', dir, dir) - } - // INVARIANT, for the same reason: the empty directory the placeholders above // bind from must never be writable from inside the sandbox. It lives under // the system temp dir, so a caller's allowWrite or a host $TMPDIR under a diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 7535ecde6..4068502e3 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -339,10 +339,8 @@ export interface GitDirTreeDenies { * a linked worktree" answers yes for an ordinary repository while the deny * stands. No content avoids that: the alternative is git refusing to run. * - * An empty placeholder also says that an empty file at that path is a - * legitimate one to leave alone; a non-empty placeholder says the opposite, - * which is what lets the Linux backend cover or repair an empty `commondir` - * (see `writeGitRedirectMountPoint` in src/sandbox/linux-sandbox-utils.ts). + * The Linux backend writes them: see `writeGitRedirectMountPoint` in + * src/sandbox/linux-sandbox-utils.ts. */ const GIT_REDIRECT_FILES: ReadonlyArray<{ name: string diff --git a/test/helpers/leftover-manifest.ts b/test/helpers/leftover-manifest.ts deleted file mode 100644 index 451da88d1..000000000 --- a/test/helpers/leftover-manifest.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readlinkSync, - writeFileSync, -} from 'node:fs' -import { join } from 'node:path' - -/** - * What a killed process leaves beside its mount points: the manifest of a wrap - * that named `paths`, written into `dir` a minute ago by a process that is - * gone. Only a manifest makes a path a mount point, so this is what tells a - * leftover from somebody's own file. Returns where it is written. - */ -export function leaveManifestNaming(dir: string, paths: string[]): string { - // A pid nothing in this PID namespace has. - let pid = 4194000 - while (existsSync(`/proc/${pid}`)) pid-- - mkdirSync(dir, { recursive: true, mode: 0o700 }) - chmodSync(dir, 0o700) - const file = join( - dir, - `${pid}-${Math.random().toString(16).slice(2).padEnd(16, '0')}.json`, - ) - writeFileSync( - file, - JSON.stringify({ - version: 1, - pid, - start: '1', - ns: readlinkSync('/proc/self/ns/pid'), - created: Date.now() - 60_000, - paths, - sources: [], - }), - { mode: 0o600 }, - ) - return file -} diff --git a/test/sandbox/credential-mask-files.test.ts b/test/sandbox/credential-mask-files.test.ts index 033294ec2..dd330e625 100644 --- a/test/sandbox/credential-mask-files.test.ts +++ b/test/sandbox/credential-mask-files.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test' import { captured } from '../helpers/captured.js' import { spawn, spawnSync } from 'node:child_process' -import * as fs from 'node:fs' import { existsSync, lstatSync, @@ -79,8 +78,8 @@ describe('MaskedFileStore', () => { test('write does not follow a symlink planted at the fake path', () => { // Cross-invocation attack: if a prior sandbox run could write the // store dir, it could leave `ln -s 0.fake` behind and the - // next host-side write() would clobber . write() must replace - // the name, so the sentinel lands in a fresh regular file. + // next host-side write() would clobber . write() must unlink + // first so the sentinel lands in a fresh regular file. const store = new MaskedFileStore() const fake = store.write('k', 'first') const victim = join(FIXTURE_DIR, 'symlink-victim') @@ -106,35 +105,6 @@ describe('MaskedFileStore', () => { store.dispose() }) - test('write never leaves the fake path without a file', () => { - // A command line handed out earlier binds from the name, and bubblewrap - // does not start on a source that is not there. - const store = new MaskedFileStore() - const fake = store.write('k', 'first') - const seen: boolean[] = [] - const calls = ['rmSync', 'unlinkSync', 'writeFileSync', 'renameSync'] - const spies = calls.map(name => { - const real = fs[name as 'rmSync'] as (...args: unknown[]) => unknown - return spyOn(fs, name as 'rmSync').mockImplementation((( - ...args: unknown[] - ) => { - const result = real(...args) - seen.push(existsSync(fake)) - return result - }) as never) - }) - try { - store.write('k', 'second') - } finally { - for (const spy of spies) spy.mockRestore() - } - - expect(seen.length).toBeGreaterThan(0) - expect(seen).not.toContain(false) - expect(readFileSync(fake, 'utf8')).toBe('second') - store.dispose() - }) - test('dispose removes the temp dir and is idempotent', () => { const store = new MaskedFileStore() store.write('k', 'x') diff --git a/test/sandbox/deny-order-independence.test.ts b/test/sandbox/deny-order-independence.test.ts index df07beee3..8a30dbd70 100644 --- a/test/sandbox/deny-order-independence.test.ts +++ b/test/sandbox/deny-order-independence.test.ts @@ -21,17 +21,10 @@ import { mountsOf, withoutManifestName } from '../helpers/bwrap-argv.js' * component is made fresh per wrap, so its name says nothing about the plan. */ const EMPTY_MOUNT_SOURCE = /\S*claude-empty-[A-Za-z0-9]+/g -/** So is the directory that what git reads back is bound from, per session. */ -const GIT_REDIRECT_STORE = /\S*srt-gitredirect-[A-Za-z0-9]+/g - /** A profile's mounts as a multiset, for the reason mountsOf gives. */ function mountMultiset(command: string): string[] { return mountsOf(withoutManifestName(command)) - .map(mount => - mount - .replace(EMPTY_MOUNT_SOURCE, '') - .replace(GIT_REDIRECT_STORE, ''), - ) + .map(mount => mount.replace(EMPTY_MOUNT_SOURCE, '')) .sort() } diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 6c1878eec..d01d97f62 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -415,11 +415,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { const none = await shares() const [first, second] = repositories(10) // hooks, config, commondir and config.worktree, and a pin on each of the - // two directories above them. And once, not theirs by the count, the - // directory that what git reads back is bound from. + // two directories above them. const ten = await shares() expect(ten).toEqual({ - total: none.total + 10 * 18 + 3, + total: none.total + 10 * 18, mandatory: none.mandatory + 10 * 18, }) diff --git a/test/sandbox/linux-git-dir-whole.test.ts b/test/sandbox/linux-git-dir-whole.test.ts index 2d35c4fdd..f31d4a822 100644 --- a/test/sandbox/linux-git-dir-whole.test.ts +++ b/test/sandbox/linux-git-dir-whole.test.ts @@ -18,9 +18,9 @@ import { cleanupBwrapMountPoints, LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' -import { indexOfMount } from '../helpers/bwrap-argv.js' +import { indexOfMount, lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { leaveManifestNaming } from '../helpers/leftover-manifest.js' +import { writeManifest } from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' @@ -167,6 +167,22 @@ describe.if(isLinux && process.getuid?.() !== 0)( ]) }) + it('binds each from itself where nothing has to be made', async () => { + const entries = { + hooks: null, + config: '[core]\n', + commondir: '.\n', + 'config.worktree': '', + } + makeUnwritableGitDir(entries) + + expect(denyBindsOnGitDir(await wrapInProject()).sort()).toEqual( + Object.keys(entries) + .map(name => `--ro-bind ${join(gitDir, name)} ${join(gitDir, name)}`) + .sort(), + ) + }) + it('emits no placeholder inside the git directory before the bind of it', async () => { // No `hooks` either: its placeholder is buffered ahead of `commondir`, // and bubblewrap, which can write the directory where this process @@ -186,7 +202,7 @@ describe.if(isLinux && process.getuid?.() !== 0)( // the same: once the directory can be written again it goes with the // rest of this wrap's mount points. makeUnwritableGitDir({ hooks: null, commondir: '.\n' }) - leaveManifestNaming(runtime.manifestDir(), [join(gitDir, 'commondir')]) + writeManifest(runtime.manifestDir(), [join(gitDir, 'commondir')]) expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ `--ro-bind ${gitDir} ${gitDir}`, @@ -219,34 +235,45 @@ describe.if(isLinux && process.getuid?.() !== 0)( expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) }) - it('goes by a refusal that asking did not foretell', async () => { - // access(2) answers for the mode. An immutable directory, or a file - // system that maps owners, says yes there and no to the write, after the - // manifest is published. The next plan of the same wrap has to know. - mkdirSync(join(gitDir, 'hooks'), { recursive: true }) - writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') - const real = fs.writeFileSync - let refused = 0 - const write = spyOn(fs, 'writeFileSync').mockImplementation((( - ...args: Parameters - ) => { - if (String(args[0]).startsWith(`${gitDir}/`)) { - refused++ - throw Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', - }) + it.each(['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT'])( + 'goes by a refusal that asking did not foretell (%s)', + async code => { + // access(2) answers for the mode. An immutable directory, a file system + // that maps owners or a full one says yes there and no to the write, + // after the manifest is published. The next plan of the same wrap has + // to know, and of every such directory at once: a wrap has few plans. + const gitDirs = ['x', 'y', 'z', 'w'].map(name => + join(project, name, '.git'), + ) + for (const each of gitDirs) { + mkdirSync(join(each, 'hooks'), { recursive: true }) + writeFileSync(join(each, 'HEAD'), 'ref: refs/heads/main') } - return real(...args) - }) as typeof fs.writeFileSync) - - const command = await wrapInProject().finally(() => write.mockRestore()) - - expect(denyBindsOnGitDir(command)).toEqual([ - `--ro-bind ${gitDir} ${gitDir}`, - ]) - expect(refused).toBe(1) - expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) - }) + const real = fs.writeFileSync + let refused = 0 + const write = spyOn(fs, 'writeFileSync').mockImplementation((( + ...args: Parameters + ) => { + if (/\/(commondir|config\.worktree)$/.test(String(args[0]))) { + refused++ + throw Object.assign(new Error(code), { code }) + } + return real(...args) + }) as typeof fs.writeFileSync) + + const command = await wrapInProject().finally(() => write.mockRestore()) + + expect(denyBindsOnGitDir(command)).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + for (const each of gitDirs) { + expect(lastMountAt(command, each)).toBe(`--ro-bind ${each} ${each}`) + expect(readdirSync(each).sort()).toEqual(['HEAD', 'hooks']) + } + // Once a directory has refused one file it is not asked for the other. + expect(refused).toBe(gitDirs.length) + }, + ) it('refuses the command where the git directory holds the work tree', async () => { // `x/.git` names the project itself as its git directory. Denied whole, diff --git a/test/sandbox/linux-git-redirect-placeholder.test.ts b/test/sandbox/linux-git-redirect-placeholder.test.ts index ae0ad606d..d01cba5c8 100644 --- a/test/sandbox/linux-git-redirect-placeholder.test.ts +++ b/test/sandbox/linux-git-redirect-placeholder.test.ts @@ -4,11 +4,14 @@ import { spawn, spawnSync } from 'node:child_process' import { chmodSync, existsSync, + linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, + renameSync, rmSync, statSync, symlinkSync, @@ -26,30 +29,45 @@ import { lastMountAt, } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { leaveManifestNaming } from '../helpers/leftover-manifest.js' +import { + aDeadProcess, + type Process, + processAt, + refusal, + sleep, + waitFor, + writeManifest, + writeScript, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' /** - * The mount point for a file git reads back (`commondir`, `config.worktree`) - * is prepared on the host, in a git directory a command already running may - * be writing: one that made the directory after its own wrap has nothing of - * it mounted. So whatever the wrapper does to a name it found taken, it does - * to the file it looked at and to nothing the name was given to since. + * Denying a path that is not there means mounting something at it, and two of + * these the host's git reads back: it refuses to run at all against a + * `commondir` it cannot read, and neither a bound /dev/null nor an empty file + * is one. So the wrap makes these mount points itself, holding what git + * concludes with no file there, and binds each read-only onto itself. + * + * It makes them on the host, in a git directory a command already running may + * be writing: one that made the directory after its own wrap has nothing of it + * mounted. So nothing that is there is ever written over, and nothing is made + * anywhere but in the directory the plan looked at. */ describe.if(isLinux)('The mount point of a file git reads back', () => { const VICTIM = 'a file outside every write root\n' /** Echoed by a command that runs for real, so nothing concludes anything * from a sandbox that never started. */ const BOOTED = 'BOOTED' - const WROTE_GIT_DIR = 'WROTE_GIT_DIR' - const LIVE = bwrapCanNamespace() + const LIBRARY = join(import.meta.dir, '../helpers/isolated/library.ts') + /** One that outlives every test here. */ + const initProcess = (): Process => processAt(1) const runtime = usePrivateManifestDirectory() let dir: string const savedCwd = process.cwd() beforeEach(() => { - dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-race-'))) + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) }) afterEach(() => { @@ -67,556 +85,593 @@ describe.if(isLinux)('The mount point of a file git reads back', () => { return checkout } - function wrapIn(checkout: string, command = 'true'): Promise { + function wrapIn( + checkout: string, + command = 'true', + denyWithinAllow: string[] = [], + denyRead: string[] = [], + ): Promise { process.chdir(checkout) return wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: false, allowAllUnixSockets: true, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + readConfig: { denyOnly: denyRead }, + writeConfig: { allowOnly: [checkout], denyWithinAllow }, }) } - /** A file with exactly this mode, whatever the umask. */ - function writeWithMode(file: string, contents: string, mode: number): void { - writeFileSync(file, contents) - chmodSync(file, mode) - } - - /** What bubblewrap's own ensure_file() makes, left by a killed process. */ - function leaveStaleMountPoint(file: string): void { - writeWithMode(file, '', 0o444) - leaveManifestNaming(runtime.manifestDir(), [file]) + /** Whether `gitDir` is bound read-only above the bind that makes `checkout` + * writable. A pin spells the same words beneath that one. */ + function isDeniedWhole( + command: string, + checkout: string, + gitDir: string, + ): boolean { + const madeWritable = indexOfMount(command, '--bind', checkout, checkout) + expect(madeWritable).toBeGreaterThan(-1) + return lastIndexOfMount(command, '--ro-bind', gitDir, gitDir) > madeWritable } /** - * The source bwrap is given for the mount at `dest`. Throws where nothing - * is mounted there, so no assertion about it passes on a wrap that emitted - * no such mount. + * Wraps in `checkout`, and runs `swap` once: as the wrapper is about to open + * `gitDir` to make a `commondir` in it, or about to make the file. Those are + * the last moments a command writing the directory has to change what the + * plan saw. Returns the command, and where each `commondir` made really was: + * one made in the wrong place may be gone again by the end of the wrap. */ - function mountSource(command: string, dest: string): string { - const mount = lastMountAt(command, dest) - if (mount === undefined) throw new Error(`nothing is mounted at ${dest}`) - const [, source, mounted] = mount.split(' ') - if (source === undefined || mounted !== dest) { - throw new Error(`the mount at ${dest} has no source: ${mount}`) - } - return source - } - - /** - * Runs `wrap` and hands the name `dest` to `swap` right after the wrapper - * has touched that name for the `nth` time, counting from the exclusive - * create that found it taken: each later look at the path, and each stat of - * a descriptor opened on it, is one more. That is every point at which a - * command writing the directory could have given the name to something - * else between a check and what is done on the strength of it. - */ - async function wrapSwappingAfterTouch( - dest: string, - nth: number, + async function wrapSwappingBefore( + call: 'openSync' | 'writeFileSync', + checkout: string, + gitDir: string, swap: () => void, - wrap: () => Promise, - ): Promise<{ result: T; swapped: boolean }> { + ): Promise<{ command: string; made: string[] }> { type Fn = (...args: unknown[]) => unknown - const spies: { mockRestore(): void }[] = [] - const descriptors = new Set() - let armed = false - let touches = 0 + const [open, write] = [fs.openSync as Fn, fs.writeFileSync as Fn] + const made: string[] = [] let swapped = false - const touched = (): void => { - if (!armed || ++touches !== nth) return + const swapOnce = (): void => { + if (!swapped) swap() swapped = true - swap() - } - const intercept = ( - name: keyof typeof fs, - around: (real: Fn, args: unknown[]) => unknown, - ): void => { - const real = fs[name] as Fn - spies.push( - spyOn(fs, name).mockImplementation(((...args: unknown[]) => - around(real, args)) as never), - ) - } - const byPath = (real: Fn, args: unknown[]): unknown => { - try { - return real(...args) - } finally { - if (String(args[0]) === dest) touched() - } - } - - for (const name of [ - 'lstatSync', - 'statSync', - 'readFileSync', - 'chmodSync', - ] as const) { - intercept(name, byPath) } - intercept('writeFileSync', (real, args) => { - const exclusive = - String(args[0]) === dest && - (args[2] as { flag?: string } | undefined)?.flag === 'wx' - if (exclusive) armed = true - return byPath(real, args) - }) - intercept('openSync', (real, args) => { - try { - const fd = real(...args) as number - if (String(args[0]) === dest) descriptors.add(fd) - return fd - } finally { - if (String(args[0]) === dest) touched() - } - }) - intercept('fstatSync', (real, args) => { - try { - return real(...args) - } finally { - if (descriptors.has(args[0] as number)) touched() - } - }) - intercept('closeSync', (real, args) => { - descriptors.delete(args[0] as number) - return real(...args) - }) - + const spies = [ + spyOn(fs, 'openSync').mockImplementation(((...args: unknown[]) => { + // Nothing is made before a manifest names it, so an open of the + // directory before one is published is for something else. + const isPublished = readdirSync(runtime.manifestDir()).some(name => + name.endsWith('.json'), + ) + if (call === 'openSync' && args[0] === gitDir && isPublished) swapOnce() + return open(...args) + }) as never), + spyOn(fs, 'writeFileSync').mockImplementation(((...args: unknown[]) => { + const makesCommondir = + String(args[0]).endsWith('/commondir') && + (args[2] as { flag?: string } | undefined)?.flag === 'wx' + if (call === 'writeFileSync' && makesCommondir) swapOnce() + write(...args) + if (makesCommondir) made.push(realpathSync(String(args[0]))) + }) as never), + ] try { - return { result: await wrap(), swapped } + return { command: await wrapIn(checkout), made } } finally { for (const spy of spies) spy.mockRestore() + expect(swapped).toBe(true) } } - /** Gives the name `dest` to `replacement`, the way rename(2) does. */ - function renameOver(replacement: string, dest: string): void { - fs.renameSync(replacement, dest) - } - /** - * Every point at which the name can change hands, one wrap each, until the - * wrapper is done with the name before the swap comes due: the stale mount - * point's name goes to a link to a file outside every write root, made by - * `link`. Returns what each wrap emitted, so the caller asserts on more - * than survival. + * What each file of the git directory holds before the wrap, what the host + * finds there while the command runs, and whether the clean-up takes it away. + * `named` by the manifest a killed wrap left, which alone makes it a mount + * point: anything else that is there is somebody's file. */ - async function atEveryTouch( - victimMode: number, - link: (victim: string, name: string) => void, - check: (nth: number, victim: string) => void, - ): Promise { - const outcomes: string[] = [] - for (let nth = 1; ; nth++) { - const checkout = makeCheckout(`repo-${nth}`) + it.each<{ + name: string + file: 'commondir' | 'config.worktree' + before?: { contents: string; mode: number; named?: true } + during?: { contents: string; mode: number } + }>([ + { + name: 'an absent commondir', + file: 'commondir', + during: { contents: '.\n', mode: 0o444 }, + }, + { + name: 'an absent config.worktree', + file: 'config.worktree', + during: { contents: '', mode: 0o444 }, + }, + { + name: 'the commondir a killed wrap left', + file: 'commondir', + before: { contents: '.\n', mode: 0o444, named: true }, + }, + { + name: 'the config.worktree a killed wrap left', + file: 'config.worktree', + before: { contents: '', mode: 0o444, named: true }, + }, + { + name: 'a commondir naming a directory', + file: 'commondir', + before: { contents: '../..\n', mode: 0o644 }, + }, + { + name: 'a commondir as long as what is written, holding something else', + file: 'commondir', + before: { contents: '..', mode: 0o444 }, + }, + { + name: 'a commondir holding what is written, that no manifest names', + file: 'commondir', + before: { contents: '.\n', mode: 0o444 }, + }, + { + name: 'an empty commondir that no manifest names, a host git mid-write', + file: 'commondir', + before: { contents: '', mode: 0o644 }, + }, + { + name: 'a config.worktree with settings in it', + file: 'config.worktree', + before: { contents: '[core]\n', mode: 0o644 }, + }, + { + name: 'an empty read-only config.worktree that no manifest names', + file: 'config.worktree', + before: { contents: '', mode: 0o444 }, + }, + ])('binds $name from itself', async ({ file, before, during = before }) => { + const checkout = makeCheckout('repo') + const dest = join(checkout, '.git', file) + if (before !== undefined) { + writeFileSync(dest, before.contents) + chmodSync(dest, before.mode) + } + if (before?.named) writeManifest(runtime.manifestDir(), [dest]) + + const command = await wrapIn(checkout) + + expect(lastMountAt(command, dest)).toBe(`--ro-bind ${dest} ${dest}`) + expect(readFileSync(dest, 'utf8')).toBe(during!.contents) + expect(statSync(dest).mode & 0o777).toBe(during!.mode) + + cleanupBwrapMountPoints({ force: true }) + const isSomebodys = before !== undefined && !before.named + expect(existsSync(dest)).toBe(isSomebodys) + if (isSomebodys) expect(readFileSync(dest, 'utf8')).toBe(before.contents) + }) + + it.each([ + { whose: 'a killed wrap', maker: aDeadProcess, stays: false }, + { whose: 'a wrap of a running process', maker: initProcess, stays: true }, + ])( + 'denies the git directory whole where $whose left an empty commondir', + async ({ maker, stays }) => { + // What bubblewrap's own ensure_file() makes where the file has gone from + // under a command that then starts. It is no use to git and is not + // written over, so it takes no bind of its own. It goes as any mount + // point does, once no manifest of a running process names it. + const checkout = makeCheckout('repo') const gitDir = join(checkout, '.git') const commondir = join(gitDir, 'commondir') - leaveStaleMountPoint(commondir) - const victim = join(dir, `victim-${nth}`) - writeWithMode(victim, VICTIM, victimMode) - - const { result: command, swapped } = await wrapSwappingAfterTouch( - commondir, - nth, - () => { - const linked = join(dir, `link-${nth}`) - link(victim, linked) - renameOver(linked, commondir) - }, - () => - wrapIn( - checkout, - `echo ${BOOTED}; touch .git/probe && echo ${WROTE_GIT_DIR}`, - ), - ) - if (!swapped) return outcomes - check(nth, victim) - - // Either the file is denied where it stands or the git directory is - // denied whole: no wrap comes out with the redirect file left open. A - // pin of the git directory lies beneath the bind that makes the - // checkout writable in every wrap, so it is a bind of it above that - // one that is the deny. - const madeWritable = indexOfMount(command, '--bind', checkout, checkout) - expect(madeWritable).toBeGreaterThan(-1) - if ( - lastIndexOfMount(command, '--ro-bind', gitDir, gitDir) > madeWritable - ) { - expect(lastMountAt(command, commondir)).toBeUndefined() - outcomes.push('git directory denied whole') - if (LIVE) { - // The sandbox starts with the link still standing there, and - // nothing in the git directory can be written, the link included. - const run = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 30000, - cwd: checkout, - }) - const output = `${run.stdout}${run.stderr}` - expect({ nth, output }).toEqual({ - nth, - output: expect.stringContaining(BOOTED), - }) - expect(output).not.toContain(WROTE_GIT_DIR) - expect(readFileSync(commondir, 'utf8')).toBe(VICTIM) - check(nth, victim) - } - } else { - mountSource(command, commondir) - outcomes.push('file denied') - } + writeFileSync(commondir, '', { mode: 0o444 }) + writeManifest(runtime.manifestDir(), [commondir], maker()) + + const command = await wrapIn(checkout) - // Forced, as the exit handler runs it: what the wrap took for its own - // goes, and the victim is no more its own for being linked to. + expect(isDeniedWhole(command, checkout, gitDir)).toBe(true) + expect(lastMountAt(command, commondir)).toBeUndefined() cleanupBwrapMountPoints({ force: true }) - check(nth, victim) - } - } + expect(existsSync(commondir)).toBe(stays) + if (stays) expect(readFileSync(commondir, 'utf8')).toBe('') + expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) + }, + ) + + it('goes by the file, not by the spelling of the deny entry', async () => { + // A caller's own deny for the same file comes first and wins the dedup. Its + // spelling does not name the file, and /dev/null there would cost the + // repository every git command for as long as the command runs. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') - it('never writes through a link the name of a stale mount point was swapped for', async () => { - const outcomes = await atEveryTouch( - 0o644, - fs.symlinkSync, - (nth, victim) => { - expect({ nth, contents: readFileSync(victim, 'utf8') }).toEqual({ - nth, - contents: VICTIM, - }) - }, - ) + const command = await wrapIn(checkout, 'true', ['./.git/commondir/']) - // The swap landed at least once, and at least once between the wrapper - // finding the stale file and rewriting it, which is where it gives up on - // the file and plans again: an entry that is a link has the directory - // denied whole. - expect(outcomes.length).toBeGreaterThan(1) - expect(outcomes).toContain('git directory denied whole') + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') }) - it('never makes writable what such a link leads to', async () => { - // Read-only, so that a write through the link is refused and what is left - // to go wrong is the chmod that was there to make bubblewrap's own - // read-only mount point writable. - const outcomes = await atEveryTouch( - 0o444, - fs.symlinkSync, - (nth, victim) => { - expect({ - nth, - mode: (statSync(victim).mode & 0o777).toString(8), - contents: readFileSync(victim, 'utf8'), - }).toEqual({ nth, mode: '444', contents: VICTIM }) - }, + it('leaves every other absent deny on /dev/null', async () => { + const checkout = makeCheckout('repo') + const bashrc = join(checkout, '.bashrc') + + expect(lastMountAt(await wrapIn(checkout), bashrc)).toBe( + `--ro-bind /dev/null ${bashrc}`, ) + }) + + it('makes none where the deny takes no bind', async () => { + // A git directory denied for reading is hidden whole, and what is written + // in there never reaches the host. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') - expect(outcomes.length).toBeGreaterThan(1) + const command = await wrapIn(checkout, 'true', [], [gitDir]) + + expect(lastMountAt(command, gitDir)).toBe(`--tmpfs ${gitDir}`) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'config', 'hooks']) }) - it('never writes to another file the name was made a hard link to', async () => { - // No link to refuse this time: the name opens, as a regular file, and - // only what the descriptors say tells it from the file that was checked. - const outcomes = await atEveryTouch(0o644, fs.linkSync, (nth, victim) => { - expect({ nth, contents: readFileSync(victim, 'utf8') }).toEqual({ - nth, - contents: VICTIM, - }) - }) + it('leaves no mount point behind when the wrap itself throws', async () => { + // The caller does not clean up after a wrap that threw. + const checkout = makeCheckout('repo') - // It plans again there too, and finds somebody's file, bound from itself. - expect(outcomes.length).toBeGreaterThan(1) - expect(new Set(outcomes)).toEqual(new Set(['file denied'])) + expect( + await refusal( + wrapIn( + checkout, + 'true', + Array.from({ length: 4000 }, (_unused, n) => + join(checkout, `deny-${n}`), + ), + ), + ), + ).toBe('too_many_arguments') + + expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(false) }) - /** - * Runs `wrap` and says whether it waited on the FIFO that is, or is about - * to be, at `name`. Opening a FIFO to read waits for somebody to open it to - * write, and nobody will; so that a wrapper that does wait fails its test - * rather than hanging the run, a helper opens both ends after a while. A - * wrap that is back well before then waited on nothing. - */ - async function waitsOnFifoAt( - name: string, - wrap: () => Promise, - ): Promise<{ result: T; waited: boolean }> { - const RESCUE_AFTER_MS = 3000 - const rescuer = spawn( - 'sh', - [ - '-c', - `sleep ${RESCUE_AFTER_MS / 1000}; exec 3<>"$1"; sleep 1`, - 'sh', - name, - ], - { stdio: 'ignore' }, - ) - const started = Date.now() - try { - const result = await wrap() - return { result, waited: Date.now() - started >= RESCUE_AFTER_MS / 2 } - } finally { - rescuer.kill('SIGKILL') + it('does not remove a mount point that became something else', async () => { + // One was written to, as many bytes as it held. The others were swapped for + // links to files that hold what the mount points did: a check that looks + // through a link would pass them, and the unlink does not look through it. + const written = join(makeCheckout('written'), '.git', 'commondir') + const linked = makeCheckout('linked') + const links = { commondir: '.\n', 'config.worktree': '' } + await wrapIn(join(dir, 'written')) + await wrapIn(linked) + + chmodSync(written, 0o644) + writeFileSync(written, '..') + for (const [name, contents] of Object.entries(links)) { + expect(readFileSync(join(linked, '.git', name), 'utf8')).toBe(contents) + writeFileSync(join(dir, name), contents) + rmSync(join(linked, '.git', name)) + symlinkSync(join(dir, name), join(linked, '.git', name)) } - } + cleanupBwrapMountPoints({ force: true }) - it('does not wait on a FIFO the name was swapped for', async () => { - for (let nth = 1; ; nth++) { - const checkout = makeCheckout(`repo-${nth}`) - const commondir = join(checkout, '.git', 'commondir') - leaveStaleMountPoint(commondir) - const fifo = join(dir, `fifo-${nth}`) - expect(spawnSync('mkfifo', [fifo]).status).toBe(0) - - const { - result: { swapped }, - waited, - } = await waitsOnFifoAt(commondir, () => - wrapSwappingAfterTouch( - commondir, - nth, - () => renameOver(fifo, commondir), - () => wrapIn(checkout), - ), + expect(readFileSync(written, 'utf8')).toBe('..') + for (const name of Object.keys(links)) { + expect(lstatSync(join(linked, '.git', name)).isSymbolicLink()).toBe(true) + expect(existsSync(join(dir, name))).toBe(true) + } + }) + + it.each<{ + what: string + plant: (victim: string, name: string) => void + isStillThere: (name: string) => boolean + deniedWhole: boolean + }>([ + { + what: 'a link to a file outside every write root', + plant: symlinkSync, + isStillThere: name => lstatSync(name).isSymbolicLink(), + deniedWhole: true, + }, + { + what: 'a hard link to one', + plant: linkSync, + isStillThere: name => lstatSync(name).nlink === 2, + deniedWhole: false, + }, + { + what: 'a FIFO', + plant: (_victim, name) => + expect(spawnSync('mkfifo', [name]).status).toBe(0), + isStillThere: name => lstatSync(name).isFIFO(), + deniedWhole: false, + }, + ])( + 'writes to nothing where the name is taken by $what as it is made', + async ({ plant, isStillThere, deniedWhole }) => { + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + const victim = join(dir, 'victim') + writeFileSync(victim, VICTIM) + + const { command, made } = await wrapSwappingBefore( + 'writeFileSync', + checkout, + gitDir, + () => plant(victim, commondir), ) - if (!swapped) break - expect({ nth, waited }).toEqual({ nth, waited: false }) - expect(lstatSync(commondir).isFIFO()).toBe(true) + expect(made).toEqual([]) + // It plans again, and denies what it finds: somebody's file from itself, + // and the whole directory where the entry is a link. + expect(isDeniedWhole(command, checkout, gitDir)).toBe(deniedWhole) + expect(lastMountAt(command, commondir)).toBe( + deniedWhole ? undefined : `--ro-bind ${commondir} ${commondir}`, + ) cleanupBwrapMountPoints({ force: true }) - expect(lstatSync(commondir).isFIFO()).toBe(true) - } - }, 60000) + expect(isStillThere(commondir)).toBe(true) + expect(readFileSync(victim, 'utf8')).toBe(VICTIM) + expect(statSync(victim).mode & 0o777).toBe(0o644) + }, + ) + + describe.each(['x/.git', 'x'])('where %s becomes a link', swapped => { + // The plan resolved the path, and the name of the file alone would be + // followed wherever the path leads by now. + it.each(['openSync', 'writeFileSync'] as const)( + 'makes nothing elsewhere (ahead of its %s)', + async call => { + const project = join(dir, 'project') + mkdirSync(project) + const gitDir = join(makeCheckout('project/x'), '.git') + const elsewhere = join(dir, 'elsewhere') + mkdirSync(join(elsewhere, 'x', '.git'), { recursive: true }) + + const { made } = await wrapSwappingBefore(call, project, gitDir, () => { + renameSync(join(project, swapped), join(dir, 'aside')) + symlinkSync(join(elsewhere, swapped), join(project, swapped)) + }) + + // In the directory it opened, wherever that has been moved to. + expect(made).toEqual( + call === 'openSync' + ? [] + : [join(dir, 'aside', swapped === 'x' ? '.git' : '', 'commondir')], + ) + expect(readdirSync(join(elsewhere, 'x', '.git'))).toEqual([]) + cleanupBwrapMountPoints({ force: true }) + expect(lstatSync(join(project, swapped)).isSymbolicLink()).toBe(true) + }, + ) + }) - it('binds a FIFO that was there all along from itself, without waiting on it', async () => { + it('does not wait on a FIFO that is there', () => { + // Opening one to read waits for a writer, and none comes. In a process of + // its own, which can be given up on. const checkout = makeCheckout('repo') const commondir = join(checkout, '.git', 'commondir') expect(spawnSync('mkfifo', [commondir]).status).toBe(0) - const { result: command, waited } = await waitsOnFifoAt(commondir, () => - wrapIn(checkout), + const wrapped = spawnSync( + process.execPath, + [ + writeScript(dir, 'wrap.ts', [ + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY)}`, + `process.chdir(${JSON.stringify(checkout)})`, + `console.log(await wrapCommandWithSandboxLinux(${JSON.stringify({ + command: 'true', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + })}))`, + ]), + ], + { encoding: 'utf8', timeout: 30000 }, ) - expect(waited).toBe(false) - expect(mountSource(command, commondir)).toBe(commondir) + expect(`${wrapped.signal} ${wrapped.stderr}`).toBe('null ') + expect(lastMountAt(wrapped.stdout.trim(), commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) }, 60000) - it('does not remove a name that stopped being its mount point', async () => { - // The check looks through a link and the unlink does not, so a mount - // point swapped for a link to an empty file would pass for still empty - // and lose the link. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - const configWorktree = join(checkout, '.git', 'config.worktree') - const emptyFile = join(dir, 'empty') - const placeholderFile = join(dir, 'placeholder') - writeFileSync(emptyFile, '') - writeFileSync(placeholderFile, '.\n') + describe.if(bwrapCanNamespace() && Bun.which('git') !== null)( + 'with git', + () => { + /** Enough to commit without a hook, an identity or a signature. */ + const IDENT = [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + ] + + function git( + cwd: string, + args: string[], + ): { status: number | null; said: string } { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { + status: result.status, + said: `${result.stdout}${result.stderr}`, + } + } - await wrapIn(checkout) - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - expect(readFileSync(configWorktree, 'utf8')).toBe('') - rmSync(commondir) - symlinkSync(placeholderFile, commondir) - rmSync(configWorktree) - symlinkSync(emptyFile, configWorktree) + /** A repository with one commit, made by git itself. */ + function gitRepo(name: string): string { + const repo = join(dir, name) + mkdirSync(repo, { recursive: true }) + writeFileSync(join(repo, 'index.js'), 'console.log(1)\n') + for (const args of [ + ['-c', 'init.defaultBranch=main', 'init', '-q', '.'], + ['add', 'index.js'], + [...IDENT, 'commit', '-q', '-m', 'one'], + ]) { + expect(git(repo, args)).toMatchObject({ status: 0 }) + } + return repo + } - cleanupBwrapMountPoints({ force: true }) + /** + * Wraps a command with `checkout` as the cwd in a process of its own, + * which is then killed: nothing of that wrap's clean-up runs. What it + * leaves is its mount points and the manifest that names them. + */ + async function wrapAndBeKilled(checkout: string): Promise { + const killed = spawnSync( + process.execPath, + [ + writeScript(dir, 'killed-wrap.ts', [ + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY)}`, + `process.chdir(${JSON.stringify(checkout)})`, + `await wrapCommandWithSandboxLinux(${JSON.stringify({ + command: 'true', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + })})`, + "process.kill(process.pid, 'SIGKILL')", + ]), + ], + { encoding: 'utf8', timeout: 60000 }, + ) + expect(`${killed.signal} ${killed.stderr}`).toBe('SIGKILL ') + // A manifest this young counts as live with no command on its record. + await sleep(600) + } - expect(lstatSync(commondir).isSymbolicLink()).toBe(true) - expect(lstatSync(configWorktree).isSymbolicLink()).toBe(true) - expect(existsSync(placeholderFile)).toBe(true) - expect(existsSync(emptyFile)).toBe(true) - }) + it("keeps the host's git working while a wrapped command runs", async () => { + const sub = gitRepo('sub') + const repo = gitRepo('repo') + for (const args of [ + [ + '-c', + 'protocol.file.allow=always', + ...IDENT, + 'submodule', + 'add', + '-q', + sub, + 'lib', + ], + [...IDENT, 'commit', '-q', '-m', 'lib'], + ['worktree', 'add', '-q', join(dir, 'wt')], + ]) { + expect(git(repo, args)).toMatchObject({ status: 0 }) + } + const commondirs = [ + join(repo, '.git', 'commondir'), + join(repo, '.git', 'modules', 'lib', 'commondir'), + ] + + // Runs until the host releases it, so the host's git is exercised with + // the sandbox up and the mount points in place. + const child = spawn( + await wrapIn( + repo, + 'echo > up; i=0; while [ ! -f release ] && [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done', + ), + { shell: true, cwd: repo, stdio: 'ignore' }, + ) + try { + await waitFor(join(repo, 'up')) + + for (const commondir of commondirs) { + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + } + for (const args of [ + ['status', '--porcelain'], + ['log', '--oneline'], + ['worktree', 'list'], + ['rev-parse', '--git-common-dir'], + [...IDENT, 'commit', '--allow-empty', '-q', '-m', 'during'], + ]) { + expect({ args, ...git(repo, args) }).toMatchObject({ status: 0 }) + } + expect( + git(join(repo, 'lib'), ['status', '--porcelain']), + ).toMatchObject({ status: 0 }) + } finally { + writeFileSync(join(repo, 'release'), '') + await new Promise(resolve => child.on('close', resolve)) + } + expect(child.exitCode).toBe(0) - /** - * The ordinary arms, as one table: what each file of the git directory - * holds before the wrap, what is bound over it, what the host finds there - * while the command runs, and whether the cleanup takes it away. - */ - describe('what is bound, by what was there', () => { - const ownerCannotWrite = process.getuid?.() !== 0 - /** `named` by the manifest a killed wrap left, which makes it a mount - * point. Anything else that is there is somebody's file. */ - type Before = { contents: string; mode: number; named?: true } | undefined - type Expected = { - /** 'itself', or the bytes of the placeholder file bound instead. */ - boundFrom: 'itself' | { placeholder: string } - during: { contents: string; mode: number } - removedByCleanup: boolean - } - const cases: { - name: string - file: 'commondir' | 'config.worktree' - before: Before - expected: Expected - }[] = [ - { - name: 'an absent commondir', - file: 'commondir', - before: undefined, - expected: { - boundFrom: { placeholder: '.\n' }, - during: { contents: '.\n', mode: 0o444 }, - removedByCleanup: true, - }, - }, - { - name: 'an absent config.worktree', - file: 'config.worktree', - before: undefined, - expected: { - boundFrom: { placeholder: '' }, - during: { contents: '', mode: 0o444 }, - removedByCleanup: true, - }, - }, - { - name: 'a commondir placeholder a killed wrap left', - file: 'commondir', - before: { contents: '.\n', mode: 0o444, named: true }, - expected: { - boundFrom: { placeholder: '.\n' }, - during: { contents: '.\n', mode: 0o444 }, - removedByCleanup: true, - }, - }, - { - name: 'a config.worktree placeholder a killed wrap left', - file: 'config.worktree', - before: { contents: '', mode: 0o444, named: true }, - expected: { - boundFrom: { placeholder: '' }, - during: { contents: '', mode: 0o444 }, - removedByCleanup: true, - }, - }, - { - name: 'a commondir naming a directory', - file: 'commondir', - before: { contents: '../..\n', mode: 0o644 }, - expected: { - boundFrom: 'itself', - during: { contents: '../..\n', mode: 0o644 }, - removedByCleanup: false, - }, - }, - { - name: 'a commondir as long as the placeholder, holding something else', - file: 'commondir', - before: { contents: '..', mode: 0o444 }, - expected: { - boundFrom: 'itself', - during: { contents: '..', mode: 0o444 }, - removedByCleanup: false, - }, - }, - { - name: 'a config.worktree with settings in it', - file: 'config.worktree', - before: { contents: '[core]\n', mode: 0o644 }, - expected: { - boundFrom: 'itself', - during: { contents: '[core]\n', mode: 0o644 }, - removedByCleanup: false, - }, - }, - { - name: 'an empty commondir bubblewrap made, which a killed wrap left', - file: 'commondir', - before: { contents: '', mode: 0o444, named: true }, - expected: { - boundFrom: { placeholder: '.\n' }, - // Rewritten in place; made writable for that only where the owner - // of a read-only file cannot write it as it is. - during: { contents: '.\n', mode: ownerCannotWrite ? 0o644 : 0o444 }, - removedByCleanup: true, - }, - }, - { - name: "a commondir that holds what the placeholder does, and is nobody's leftover", - file: 'commondir', - before: { contents: '.\n', mode: 0o444 }, - expected: { - boundFrom: 'itself', - during: { contents: '.\n', mode: 0o444 }, - removedByCleanup: false, - }, - }, - { - name: "an empty read-only commondir that is nobody's leftover", - file: 'commondir', - before: { contents: '', mode: 0o444 }, - expected: { - boundFrom: { placeholder: '.\n' }, - during: { contents: '', mode: 0o444 }, - removedByCleanup: false, - }, - }, - { - name: "an empty read-only config.worktree that is nobody's leftover", - file: 'config.worktree', - before: { contents: '', mode: 0o444 }, - expected: { - boundFrom: 'itself', - during: { contents: '', mode: 0o444 }, - removedByCleanup: false, - }, - }, - { - name: 'an empty writable commondir, a host git mid-write', - file: 'commondir', - before: { contents: '', mode: 0o644 }, - expected: { - boundFrom: { placeholder: '.\n' }, - during: { contents: '', mode: 0o644 }, - removedByCleanup: false, - }, - }, - { - name: 'an empty writable config.worktree', - file: 'config.worktree', - before: { contents: '', mode: 0o644 }, - expected: { - boundFrom: 'itself', - during: { contents: '', mode: 0o644 }, - removedByCleanup: false, - }, - }, - ] + cleanupBwrapMountPoints() + expect(commondirs.filter(existsSync)).toEqual([]) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ + status: 0, + }) + }) - for (const { name, file, before, expected } of cases) { - it(name, async () => { - const checkout = makeCheckout('repo') - const dest = join(checkout, '.git', file) - if (before !== undefined) { - writeWithMode(dest, before.contents, before.mode) - } - if (before?.named) leaveManifestNaming(runtime.manifestDir(), [dest]) + it('leaves behind a killed wrap a commondir git reads, and takes an empty one away', async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const status = (): number | null => + git(repo, ['status', '--porcelain']).status + + await wrapAndBeKilled(repo) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(status()).toBe(0) + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + + // What bubblewrap makes where the file has gone from under it: git + // refuses every command in the repository until the next clean-up. + await wrapAndBeKilled(repo) + chmodSync(commondir, 0o644) + writeFileSync(commondir, '') + chmodSync(commondir, 0o444) + expect(git(repo, ['status', '--porcelain']).said).toContain('commondir') + + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(status()).toBe(0) + }) - const command = await wrapIn(checkout) + it('leaves git working across wraps, and the commondir unwritable', async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const run = (command: string): string => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: repo, + env: { ...process.env, LC_ALL: 'C' }, + }).stdout + + // No clean-up in between: the first wrap's mount point is still in the + // git directory, and the second wrap's scan finds it there. By name: + // the mount points of the dotfile denies are not files to stage. + for (const file of ['one.js', 'two.js']) { + writeFileSync(join(repo, file), 'console.log(1)\n') + expect( + run( + await wrapIn( + repo, + `echo ${BOOTED} && git add ${file} && git ${IDENT.join(' ')} commit -q -m ${file} && echo COMMIT_OK`, + ), + ), + ).toBe(`${BOOTED}\nCOMMIT_OK\n`) + } - const mount = lastMountAt(command, dest) - const source = mountSource(command, dest) - expect(mount).toBe(`--ro-bind ${source} ${dest}`) expect( - source === dest - ? 'itself' - : { placeholder: readFileSync(source, 'utf8') }, - ).toEqual(expected.boundFrom) - expect(source).not.toBe('/dev/null') - expect({ - contents: readFileSync(dest, 'utf8'), - mode: statSync(dest).mode & 0o777, - }).toEqual(expected.during) - + run( + await wrapIn( + repo, + `echo ${BOOTED}; echo ../decoy > .git/commondir || chmod 666 .git/commondir || echo DENIED`, + ), + ), + ).toBe(`${BOOTED}\nDENIED\n`) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') cleanupBwrapMountPoints({ force: true }) - expect(existsSync(dest)).toBe(!expected.removedByCleanup) - if (!expected.removedByCleanup) { - expect(readFileSync(dest, 'utf8')).toBe(expected.during.contents) - } + expect(existsSync(commondir)).toBe(false) }) - } - }) + }, + ) }) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 84fdd53f4..a995b0dc2 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -27,7 +27,7 @@ import { realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' import { indexOfMount, @@ -6994,651 +6994,3 @@ describe.if(isSupportedPlatform)( }) }, ) - -/** - * Denying a path that is not there means mounting something at it, and two of - * these the host's git reads back: it refuses to run at all against a - * `commondir` it cannot read, and neither a bound /dev/null nor an empty file - * is one. So these denies write their own mount point, holding what git - * concludes with no file there, and bind a read-only copy of the same bytes - * over it: the host's git keeps working for as long as the command runs, and - * the sandbox's does too. - */ -describe.if(isLinux)('Placeholders for the files git reads', () => { - /** Echoed by every command that runs for real, so nothing concludes - * anything from a sandbox that never started. */ - const BOOTED = 'BOOTED' - /** Imported by the child process below, which wraps and is then killed. */ - const LINUX_BACKEND_SOURCE = join( - import.meta.dir, - '../helpers/isolated/library.ts', - ) - const LIVE = bwrapCanNamespace() && Bun.which('git') !== null - /** Enough to commit without a hook, an identity or a signature. */ - const IDENT = [ - '-c', - 'user.name=t', - '-c', - 'user.email=t@t', - '-c', - 'commit.gpgsign=false', - '-c', - 'core.hooksPath=/dev/null', - ] - let dir: string - const savedCwd = process.cwd() - const savedTmpdir = process.env.TMPDIR - - beforeEach(() => { - dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) - }) - - afterEach(() => { - process.chdir(savedCwd) - // Forced cleanup also empties the placeholder store, so no case here - // leaves one of its directories behind. - cleanupBwrapMountPoints({ force: true }) - if (savedTmpdir === undefined) delete process.env.TMPDIR - else process.env.TMPDIR = savedTmpdir - rmSync(dir, { recursive: true, force: true }) - }) - - /** A checkout whose `.git` git would accept, wrapped with it as the cwd. */ - function makeCheckout(name: string): string { - const checkout = join(dir, name) - mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) - writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') - writeFileSync(join(checkout, '.git', 'config'), '[core]\n') - return checkout - } - - function wrapIn( - checkout: string, - command = 'true', - opts: { denyWithinAllow?: string[]; allowOnly?: string[] } = {}, - ): Promise { - process.chdir(checkout) - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - allowAllUnixSockets: true, - readConfig: undefined, - writeConfig: { - allowOnly: opts.allowOnly ?? [checkout], - denyWithinAllow: opts.denyWithinAllow ?? [], - }, - }) - } - - /** - * What bwrap is given to mount at `dest`, of the words `--flag src dest`. - * Throws where nothing is mounted there, so an assertion about the source - * cannot pass, or read as undefined, on a wrap that emitted no such mount. - */ - function mountSource(command: string, dest: string): string { - const mount = lastMountAt(command, dest) - if (mount === undefined) { - throw new Error(`nothing is mounted at ${dest}`) - } - const [, source, mounted] = mount.split(' ') - if (source === undefined || mounted !== dest) { - throw new Error(`the mount at ${dest} has no source: ${mount}`) - } - return source - } - - function git( - cwd: string, - args: string[], - ): { status: number | null; stdout: string } { - const result = spawnSync('git', args, { - cwd, - encoding: 'utf8', - timeout: 30000, - env: { ...process.env, LC_ALL: 'C' }, - }) - return { status: result.status, stdout: `${result.stdout}${result.stderr}` } - } - - /** A repository with one commit, made by git itself. */ - function gitRepo(name: string): string { - const repo = join(dir, name) - mkdirSync(repo, { recursive: true }) - expect( - git(repo, ['-c', 'init.defaultBranch=main', 'init', '-q', '.']), - ).toMatchObject({ status: 0 }) - writeFileSync(join(repo, 'index.js'), 'console.log(1)\n') - expect(git(repo, ['add', 'index.js'])).toMatchObject({ status: 0 }) - expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'one'])).toMatchObject({ - status: 0, - }) - return repo - } - - async function waitFor(ready: () => boolean, what: string): Promise { - for (let attempt = 0; attempt < 200; attempt++) { - if (ready()) return - await new Promise(resolve => setTimeout(resolve, 50)) - } - throw new Error(`timed out waiting for ${what}`) - } - - /** - * Wraps a command with `checkout` as the cwd in a process of its own, which - * is then killed: no exit event, so nothing of that wrap's clean-up runs. What - * it leaves is its mount points and the manifest that names them. - */ - async function wrapAndBeKilled(checkout: string): Promise { - const script = join(dir, 'killed-wrap.ts') - writeFileSync( - script, - [ - `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LINUX_BACKEND_SOURCE)}`, - `process.chdir(${JSON.stringify(checkout)})`, - 'await wrapCommandWithSandboxLinux({', - " command: 'true',", - ' needsNetworkRestriction: false,', - ' allowAllUnixSockets: true,', - ' readConfig: undefined,', - ` writeConfig: { allowOnly: [${JSON.stringify(checkout)}], denyWithinAllow: [] },`, - '})', - "process.kill(process.pid, 'SIGKILL')", - ].join('\n'), - ) - const killed = spawnSync(process.execPath, [script], { - encoding: 'utf8', - timeout: 60000, - }) - expect(`${killed.signal} ${killed.stderr}`).toBe('SIGKILL ') - // A manifest this young counts as live with no command on its record yet. - await new Promise(resolve => setTimeout(resolve, 600)) - } - - it('binds a commondir that is not there from a placeholder holding "."', async () => { - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - - const source = mountSource(await wrapIn(checkout), commondir) - - expect(source).not.toBe('/dev/null') - expect(readFileSync(source, 'utf8')).toBe('.\n') - // The mount point is this wrapper's own rather than the empty file - // bubblewrap would make: it is what the HOST's git reads meanwhile. - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - }) - - it('reads the placeholder off the file, not off the deny entry', async () => { - // A caller's own deny for the same file comes first and wins the dedup, - // so the decision has to be made from the path the entry resolves to. An - // entry's own spelling does not name the file: /dev/null lands there - // instead, and the repository loses every git command for the wrap. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - - const command = await wrapIn(checkout, 'true', { - denyWithinAllow: ['./.git/commondir/'], - }) - - expect(readFileSync(mountSource(command, commondir), 'utf8')).toBe('.\n') - }) - - it('binds a commondir that is there from itself', async () => { - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - writeFileSync(commondir, '../..\n') - - expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) - - cleanupBwrapMountPoints({ force: true }) - expect(readFileSync(commondir, 'utf8')).toBe('../..\n') - }) - - it('takes its own mount point away, and a changed one never', async () => { - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - - await wrapIn(checkout) - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(commondir)).toBe(false) - - // A mount point something has written a real redirect into is not this - // wrapper's to remove, the same way an empty one that gained content is - // not: the cleanup compares what is there with what it wrote. - await wrapIn(checkout) - // Taken over by something that wanted a real redirect there, mode and - // all: what the mount point holds is no longer what this wrote. - chmodSync(commondir, 0o644) - writeFileSync(commondir, '../..\n') - cleanupBwrapMountPoints({ force: true }) - expect(readFileSync(commondir, 'utf8')).toBe('../..\n') - }) - - it('repairs a commondir that a manifest names and that is empty', async () => { - // What bubblewrap's own ensure_file() makes where the placeholder has gone - // from under a command that then starts. An empty mount point is covered - // with /dev/null, the one thing a commondir must never be covered with. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - await wrapAndBeKilled(checkout) - chmodSync(commondir, 0o644) - writeFileSync(commondir, '') - chmodSync(commondir, 0o444) - - const source = mountSource(await wrapIn(checkout), commondir) - - expect(source).not.toBe('/dev/null') - expect(readFileSync(source, 'utf8')).toBe('.\n') - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(commondir)).toBe(false) - }) - - it('claims the commondir a killed wrap left behind, and binds the store', async () => { - // Nothing in memory knows the file is a mount point, and the manifest the - // killed process left says so: the next wrap names it too and the clean-up - // removes it. The bind comes off the store, not off the file, which a host - // process could rewrite between the wrap and the mount. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - await wrapAndBeKilled(checkout) - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - - const source = mountSource(await wrapIn(checkout), commondir) - expect(source).not.toBe(commondir) - expect(readFileSync(source, 'utf8')).toBe('.\n') - - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(commondir)).toBe(false) - }) - - it('takes a commondir holding "." that no manifest names for the caller\'s own', async () => { - // Only a manifest makes a path a mount point, whatever it holds. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - writeFileSync(commondir, '.\n', { mode: 0o444 }) - - expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) - - cleanupBwrapMountPoints({ force: true }) - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - }) - - it('claims the config.worktree a killed wrap left behind', async () => { - // The wrapper's own leftover, not a hand-made one: an empty - // config.worktree is a legitimate file nothing here may remove, and the - // manifest is what tells the two apart. - const checkout = makeCheckout('repo') - const configWorktree = join(checkout, '.git', 'config.worktree') - await wrapAndBeKilled(checkout) - expect(readFileSync(configWorktree, 'utf8')).toBe('') - - await wrapIn(checkout) - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(configWorktree)).toBe(false) - }) - - // 0444 is the mode bubblewrap gives its own mount points. - it.each([0o644, 0o444])( - 'leaves alone an empty config.worktree it did not leave there (mode %o)', - async mode => { - // An empty config.worktree reads as no worktree config at all, so one is - // legitimate, and this wrapper never claims what no manifest names. - const checkout = makeCheckout('repo') - const legitimate = join(checkout, '.git', 'config.worktree') - writeFileSync(legitimate, '', { mode }) - - expect(mountSource(await wrapIn(checkout), legitimate)).toBe(legitimate) - - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(legitimate)).toBe(true) - }, - ) - - it.each([0o644, 0o444])( - 'covers a zero-byte commondir that no manifest names, and leaves it (mode %o)', - async mode => { - // git creates commondir and writes it as two steps, so an empty one is a - // file another process may own right now. The sandbox still gets a - // commondir it can read, off the store, and the host's file is neither - // rewritten nor removed. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - writeFileSync(commondir, '', { mode }) - - const source = mountSource(await wrapIn(checkout), commondir) - expect(source).not.toBe(commondir) - expect(source).not.toBe('/dev/null') - expect(readFileSync(source, 'utf8')).toBe('.\n') - expect(readFileSync(commondir, 'utf8')).toBe('') - - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(commondir)).toBe(true) - expect(readFileSync(commondir, 'utf8')).toBe('') - }, - ) - - it.if(process.getuid?.() !== 0)( - 'denies a git directory whole when it takes no mount point', - async () => { - // A vendored checkout the wrapper cannot write: refusing every command - // in the tree would be a brick a sandboxed command can plant with one - // chmod, and a read-only bind of the directory needs nothing written to - // the host, costs what was already unwritable, and closes that route. - const checkout = makeCheckout('repo') - const gitDir = join(checkout, '.git') - chmodSync(gitDir, 0o555) - try { - const command = await wrapIn(checkout) - - expect(lastMountAt(command, gitDir)).toBe( - `--ro-bind ${gitDir} ${gitDir}`, - ) - // A deny bind is emitted after the write root's own bind; an - // ancestor pin spells the same words before it. - const writeRootBind = indexOfMount( - command, - '--bind', - checkout, - checkout, - ) - expect(writeRootBind).toBeGreaterThan(-1) - expect( - lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), - ).toBeGreaterThan(writeRootBind) - expect(existsSync(join(gitDir, 'commondir'))).toBe(false) - expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) - } finally { - chmodSync(gitDir, 0o755) - } - }, - ) - - it('leaves no mount point behind when the wrap itself throws', async () => { - // The files git reads back are written before bubblewrap is reached, and - // the caller does not clean up after a wrap that threw. - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - - const refusal = await wrapIn(checkout, 'true', { - denyWithinAllow: Array.from({ length: 4000 }, (_unused, n) => - join(checkout, `deny-${n}`), - ), - }).then( - () => undefined, - (err: unknown) => err, - ) - - expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) - expect((refusal as LinuxSandboxProfileError).code).toBe( - 'too_many_arguments', - ) - expect(existsSync(commondir)).toBe(false) - }) - - it('leaves every other absent deny on /dev/null', async () => { - const checkout = makeCheckout('repo') - - expect(mountSource(await wrapIn(checkout), join(checkout, '.bashrc'))).toBe( - '/dev/null', - ) - }) - - it('mints one placeholder file for the process, not one per wrap', async () => { - process.env.TMPDIR = dir - const checkout = makeCheckout('repo') - const commondir = join(checkout, '.git', 'commondir') - const stores = new Set() - - for (let wrap = 0; wrap < 20; wrap++) { - const command = await wrapIn(checkout) - stores.add(dirname(mountSource(command, commondir))) - // Not forced: the store is per process, so a batch of wraps ending - // must not empty it — and the mount point goes, so the next wrap - // takes the absent-path branch again. - cleanupBwrapMountPoints() - } - - expect(stores.size).toBe(1) - // One file per distinct placeholder, whatever the number of wraps: the - // repository's absent commondir and its absent config.worktree. - expect(readdirSync([...stores][0]!)).toHaveLength(2) - }) - - it('refuses the command when no placeholder can be written', async () => { - // Decided once, logged once, and fail-closed: /dev/null is the only - // deny left, and it costs the repository every git command. Refusing - // says so; sandboxing behind it would not. - const checkout = makeCheckout('repo') - // So that no deny needs the shared empty directory, whose own source - // this temp directory would break first. - mkdirSync(join(checkout, '.claude')) - const notADirectory = join(dir, 'not-a-directory') - writeFileSync(notADirectory, '') - process.env.TMPDIR = notADirectory - - const refusal = await wrapIn(checkout).then( - () => undefined, - (err: unknown) => err, - ) - - expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) - expect((refusal as LinuxSandboxProfileError).code).toBe( - 'deny_placeholder_unavailable', - ) - }) - - it.if(LIVE)( - 'keeps the placeholder out of the reach of the sandboxed command', - async () => { - // The store sits under $TMPDIR, which is routinely inside an allowed - // write path; the bind exposes the source file itself, so a command - // able to rewrite it would choose what git reads at the denied path. - process.env.TMPDIR = dir - const repo = gitRepo('repo') - const commondir = join(repo, '.git', 'commondir') - const source = mountSource( - await wrapIn(repo, 'true', { - allowOnly: [repo, dir], - }), - commondir, - ) - cleanupBwrapMountPoints() - - const probe = [ - `echo ${BOOTED}`, - `chmod 666 ${source} 2>&1 || echo CHMOD_REFUSED`, - `echo poison > ${source} 2>&1 || echo WRITE_REFUSED`, - `cat ${source}`, - `git rev-parse --git-common-dir`, - ].join('; ') - const command = await wrapIn(repo, probe, { allowOnly: [repo, dir] }) - // Memoised by content, so the command names the source this wrap binds. - expect(mountSource(command, commondir)).toBe(source) - - const run = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 30000, - cwd: repo, - env: { ...process.env, LC_ALL: 'C' }, - }) - const output = `${run.stdout}${run.stderr}` - - expect(output).toContain(BOOTED) - expect(output).toContain('Read-only file system') - expect(output).toContain('CHMOD_REFUSED') - expect(output).toContain('WRITE_REFUSED') - expect(output).not.toContain('poison') - // git still resolves its own git directory as its common directory. - expect(output).toContain(join(repo, '.git')) - }, - ) - - it.if(LIVE)( - "keeps the host's git working while a wrapped command runs", - async () => { - const sub = gitRepo('sub') - const repo = gitRepo('repo') - expect( - git(repo, [ - '-c', - 'protocol.file.allow=always', - ...IDENT, - 'submodule', - 'add', - '-q', - sub, - 'lib', - ]), - ).toMatchObject({ status: 0 }) - expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'lib'])).toMatchObject({ - status: 0, - }) - expect( - git(repo, ['worktree', 'add', '-q', join(dir, 'wt')]), - ).toMatchObject({ status: 0 }) - const commondir = join(repo, '.git', 'commondir') - const submoduleCommondir = join( - repo, - '.git', - 'modules', - 'lib', - 'commondir', - ) - - // Runs until the host releases it, so the host's git is exercised with - // the sandbox up and the mount points in place. - const command = await wrapIn( - repo, - `echo ${BOOTED} > up; i=0; while [ ! -f release ] && [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done; echo ${BOOTED}`, - ) - const child = spawn(command, { - shell: true, - cwd: repo, - stdio: 'ignore', - }) - try { - await waitFor( - () => existsSync(join(repo, 'up')), - 'the sandbox to start', - ) - - // Both mount points are on the host, holding a redirect git accepts. - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - expect(readFileSync(submoduleCommondir, 'utf8')).toBe('.\n') - for (const args of [ - ['status', '--porcelain'], - ['log', '--oneline'], - ['worktree', 'list'], - ['rev-parse', '--git-common-dir'], - ]) { - expect({ args, ...git(repo, args) }).toMatchObject({ status: 0 }) - } - expect( - git(repo, [ - ...IDENT, - 'commit', - '--allow-empty', - '-q', - '-m', - 'during', - ]), - ).toMatchObject({ status: 0 }) - expect(git(join(repo, 'lib'), ['status', '--porcelain'])).toMatchObject( - { - status: 0, - }, - ) - } finally { - writeFileSync(join(repo, 'release'), '') - await new Promise(resolve => child.on('close', resolve)) - } - expect(child.exitCode).toBe(0) - - cleanupBwrapMountPoints() - expect(existsSync(commondir)).toBe(false) - expect(existsSync(submoduleCommondir)).toBe(false) - expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) - }, - ) - - it.if(LIVE)( - 'leaves a killed wrap behind a commondir git reads, and repairs an empty one', - async () => { - const repo = gitRepo('repo') - const commondir = join(repo, '.git', 'commondir') - - // What a killed process leaves: the host's git works on it, and the next - // wrap takes it away. - await wrapAndBeKilled(repo) - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) - expect(git(repo, ['log', '--oneline'])).toMatchObject({ status: 0 }) - await wrapIn(repo) - cleanupBwrapMountPoints() - expect(existsSync(commondir)).toBe(false) - - // What bubblewrap makes where the placeholder has gone from under it: git - // refuses every command in the repository until the next wrap rewrites - // it. - await wrapAndBeKilled(repo) - chmodSync(commondir, 0o644) - writeFileSync(commondir, '') - chmodSync(commondir, 0o444) - const broken = git(repo, ['status', '--porcelain']) - expect(broken.status).not.toBe(0) - expect(broken.stdout).toContain('commondir') - - await wrapIn(repo) - expect(readFileSync(commondir, 'utf8')).toBe('.\n') - expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) - cleanupBwrapMountPoints() - expect(existsSync(commondir)).toBe(false) - expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) - }, - ) - - it.if(LIVE)( - 'leaves git working across wraps, and the commondir unwritable', - async () => { - const repo = gitRepo('repo') - - const run = (command: string) => - spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 30000, - cwd: repo, - env: { ...process.env, LC_ALL: 'C' }, - }) - // By name: the dotfile denies stub their absent paths, and the mount - // points bwrap leaves for them are not files `git add -A` can stage. - const commit = (file: string) => - `echo ${BOOTED} && git add ${file} && git ${IDENT.join(' ')} ` + - `commit -q -m ${file} && echo COMMIT_OK` - - writeFileSync(join(repo, 'one.js'), 'console.log(1)\n') - const first = run(await wrapIn(repo, commit('one.js'))) - expect(first.stdout).toContain(BOOTED) - expect(first.status).toBe(0) - expect(first.stdout).toContain('COMMIT_OK') - - // No cleanupBwrapMountPoints() in between: the first wrap's mount point - // for the absent commondir is still sitting in the git directory, and - // the second wrap's scan finds it there. - writeFileSync(join(repo, 'two.js'), 'console.log(2)\n') - const second = run(await wrapIn(repo, commit('two.js'))) - expect(second.stdout).toContain(BOOTED) - expect(second.status).toBe(0) - expect(second.stdout).toContain('COMMIT_OK') - - const write = run( - await wrapIn(repo, 'echo ../decoy > .git/commondir || echo DENIED'), - ) - expect(write.stdout).toContain('DENIED') - expect(existsSync(join(repo, '.git', 'commondir'))).toBe(true) - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(join(repo, '.git', 'commondir'))).toBe(false) - }, - ) -}) From 1146c212b4f7d5ac6cb1c6749ae05caa23ec3f09 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:54:34 +0000 Subject: [PATCH 129/141] deny what a kept git directory keeps in turn, from either end A repository's git directory keeps others: its submodules' under `modules`, its linked worktrees' under `worktrees`. Those keep others again, and not all of them were found. Each of these let a sandboxed command leave a hook, or a `commondir`, for a git run on the host afterwards: - The submodules of a linked worktree live under the WORKTREE's git directory, `.git/worktrees//modules/`, which nothing walked. The two walks now call each other, over one set of the directories visited, by real path, so that a link leading back up ends the branch. - A git directory reached through a `.git` file, and the one its `commondir` names, had their own entries denied and nothing of what they keep. A session in a linked worktree that may write the repository's git directory, which it needs in order to commit, could write that repository's submodules' hooks and its sibling worktrees' `commondir`. `gitFileDenies` now names the git directories it reached and both backends enumerate them like any other. - `worktrees` being a link was not held, as `modules` being one is. - A `modules` beneath a directory taken for a git directory was never taken for one itself, so a `HEAD` left in `vendor` took the denies off the submodule `vendor/modules`. It is looked at like any other directory. - A directory that file content names stopped being a git directory once `HEAD` and `objects` were renamed aside: one command moved them, the next wrote the hooks and moved them back. There is one test now for what a git directory is, and `config` and `hooks`, which are denied and so stay, count in it. - Where a submodule is degraded to one bind, what a worktree of its that is reached through a link is denied by was dropped with the rest. A marker counts only as the kind of entry git reads there, so the submodule `vendor/config/lib` no longer makes a git directory of `vendor`, which cost it its git writes. One case stays open and is in the README: a submodule with `refs`, `logs` or the like in the middle of its name, below a planted marker. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- README.md | 8 +- src/sandbox/linux-sandbox-utils.ts | 80 +-- src/sandbox/macos-sandbox-utils.ts | 8 +- src/sandbox/mandatory-deny-paths.ts | 516 +++++++++--------- .../mandatory-deny-modules-walk.test.ts | 74 ++- test/sandbox/mandatory-deny-paths.test.ts | 5 +- .../mandatory-deny-time-budget.test.ts | 3 +- test/sandbox/mandatory-deny-worktrees.test.ts | 246 ++++++++- 8 files changed, 612 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 1faf18d6b..80f426fe5 100644 --- a/README.md +++ b/README.md @@ -1185,17 +1185,17 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Which git directories are covered.** - `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). -- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. They are listed for the working directory's own repository and each of its submodules' git directories (`.git/modules//worktrees//`, where `git worktree add` inside a submodule puts one) and, on Linux, for each nested repository the scan finds. A session whose working directory is itself a linked worktree reaches its own git directory through its `.git` file; its sibling worktrees' are not listed from that end. -- A directory a pointer or a `commondir` names is a git directory by what it holds (`HEAD`, `objects` or `commondir`) or by where it is: `.git/worktrees/`, and anything under a `.git/modules`, is one with every marker moved aside. Where it is is judged on the path the kernel lands on, so a symlinked `.git` cannot lend that shape to an ordinary directory. Renaming `HEAD` inside a linked worktree's git directory therefore drops no deny. +- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. A submodule keeps linked worktrees under its own git directory (`.git/modules//worktrees//`) and a linked worktree keeps submodules under its own (`.git/worktrees//modules//`): each is listed with what it keeps in turn. All of this is listed for the working directory's own repository and, on Linux, for each nested repository the scan finds. A git directory reached through a `.git` file is listed the same way, with the one its `commondir` names: a session whose working directory is a linked worktree, and which may write the repository's git directory in order to commit, has that repository's submodules and its sibling worktrees denied too. +- A directory is a git directory by what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. A directory a pointer or a `commondir` names is also one by where it is: `.git/worktrees/`, and anything under a `.git/modules`, is one whatever it holds. Where it is is judged on the path the kernel lands on, so a symlinked `.git` cannot lend that shape to an ordinary directory. - An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. - A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. - A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. A pointer or a `commondir` whose VALUE reaches nothing is not denied whole anywhere either — neither the directory it would fold to nor the deepest one still reachable towards it, which for a value like `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself. git opens nothing through such a value, and a `.git` file is something a sandboxed command is deliberately allowed to create, so failing closed there would let one command leave every command after it unable to write the project. What is denied is the pointer file and the links the walk did go through, with one `SRT_DEBUG` warning naming the file. -- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree`, `modules` and an entry of a `.git/modules` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. +- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. - **`modules` is held as a link, and is no deny path of its own.** A git directory keeps its submodules' git directories under `modules`, each with precise denies of its own, so denying that directory whole would take every one of them read-only. But it is an entry like the others in the one respect that matters: remove the link, put a directory of your own where it was, and every submodule git directory the repository keeps is one nothing denied - which is a hook the host's git runs. So it is held the way a chain hop is, by its own name where a filter matches the name a rename uses, and by the directory holding it - that git directory, denied whole - where a deny resolves. This applies to the top-level `.git/modules` as much as to a submodule's own. **What it costs, and that a command can arrange it:** an ordinary repository has no `.git/modules` at all, and an absent one is deliberately not denied (a mount there would stop `git submodule add`), so one `ln -s` inside the sandbox makes `.git` a git directory with a symlinked entry - and from the next wrap on `git add`, `git commit` and `git checkout -b` fail read-only in that repository, with nothing inside the sandbox able to remove the link. `git status`, `log` and `diff` keep working, and removing the link from outside the sandbox puts it back. That is the same family as the other costs a command can plant, and it is the price of holding the link at all. - **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. **A chain that reaches NOTHING is held by its hops alone.** A loop, or a chain longer than the kernel follows, has no landing: git gives up on it exactly as this does, so the deny the landing would get stands on nothing, and every link the walk visited before it gave up is the whole of what a command can move — put a directory in one of their places and the chain reaches it, and the host's git reads the hooks or config inside (tested against real git). Each is therefore denied like any other hop, named once however many times the loop met it. A loop that stays inside the git directory needs nothing more: the whole-directory deny that a symlinked entry already brings covers its links. One that runs out through the work tree is held by the directories holding those links — and where THAT cannot be done, because such a directory is the working directory or a write root, Linux **refuses the command** (`deny_unresolvable_git_entry`), since the warning that stands in for a bind elsewhere leans on both ends of the chain being denied and here there is no far end. That refusal is what such a repository got before any of this existed, when the deny path simply could not be resolved. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. - **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either — as long as the chain reaches something, which is what leaves both its ends denied; a chain that reaches nothing through such a hop is refused instead (see above), unless it is a pointer's value, where the refusal would be the lockout it exists to avoid and the warning is the whole answer. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. - The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The same budget bounds the listing of `.git/worktrees`, the walk of a symlink chain a git directory entry leads into, and the walk of the path a `.git` pointer or a `commondir` names: a link target runs to 4095 bytes and a pointer file to 1 MiB, and every component of either is an `lstat`. The `.git` pointer files one scan lists share a single such budget between them. The budgets add up rather than overlap: at worst a wrap spends one on the working directory's own repository before the scan, the scan's own timeout, and then one each on the walk after a scan that could not read a directory, the git-directory walk `allowGitConfig` adds, all the pointer files together, and every nested repository found. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. -- A directory that looks like a git directory does not end the `.git/modules` walk. What makes it look like one is the name of a single entry, and a segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, so a `HEAD` left in `vendor` would otherwise take the denies off `vendor/lib` from the next command on. The directory is denied as a git directory and what else it holds is walked as well. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and one of those is walked after all where that listing shows a `config` or a `hooks`, which a submodule named `vendor/lfs` has and git's own directory of that name does not. +- A directory that looks like a git directory does not end the `.git/modules` walk. What makes it look like one is the name of a single entry, and a segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, so a `HEAD` left in `vendor` would otherwise take the denies off `vendor/lib` from the next command on. The directory is denied as a git directory and what else it holds is walked as well, its `modules` like any other directory: that is where a git directory keeps its own submodules, and may as well be the submodule `vendor/modules`. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and one of those is walked after all where that listing shows a `config` or a `hooks`, which a submodule named `vendor/lfs` has and git's own directory of that name does not. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while the profile fits; diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 80889bcdc..58e4f333b 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -62,11 +62,7 @@ import { gitRedirectPlaceholder, } from './mandatory-deny-paths.js' import type { GitDirTreeDenies, GitChainHop } from './mandatory-deny-paths.js' -import type { - CollapseLevel, - RepositorySubmodules, - SubmoduleDenyPlan, -} from './linux-deny-collapse.js' +import type { CollapseLevel, SubmoduleDenyPlan } from './linux-deny-collapse.js' import { NO_COLLAPSE, collapseEverything, @@ -821,11 +817,15 @@ export function linuxGetCwdMandatoryDenyPaths( function cwdMandatoryDenyPlan( allowGitConfig: boolean, deadline: number | undefined, + visited = new Set(), ): SubmoduleDenyPlan { const cwd = process.cwd() - const denyPaths = cwdDangerousDenyPaths(cwd) - const repositories: RepositorySubmodules[] = [] - const chainHops: GitChainHop[] = [] + const plan: SubmoduleDenyPlan = { + denyPaths: cwdDangerousDenyPaths(cwd), + repositories: [], + chainHops: [], + } + const { denyPaths, chainHops } = plan const dotGitPath = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined @@ -837,16 +837,14 @@ function cwdMandatoryDenyPlan( // could not be looked at, which is no reason to skip the enumeration — // deny it whole instead. if (!isAbsenceErrno(err)) { - return { denyPaths: [...denyPaths, dotGitPath], repositories, chainHops } + denyPaths.push(dotGitPath) + return plan } } - let ownEntries: string[] = [] + const walk = { deadline, visited } if (dotGitStat?.isDirectory()) { - const denies = gitDirTreeDenies(dotGitPath, allowGitConfig, { deadline }) - denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) - chainHops.push(...denies.chainHops) - repositories.push(...degradableGitDirs(denies)) - ownEntries = gitDirDenyPaths(dotGitPath, allowGitConfig) + addGitDirTree(plan, gitDirTreeDenies(dotGitPath, allowGitConfig, walk)) + plan.ownEntries = gitDirDenyPaths(dotGitPath, allowGitConfig) } else if (dotGitStat?.isFile()) { // A pointer file (linked worktree, submodule checkout) has no hooks/ // beneath it, and binding a path under a file makes bwrap fail. @@ -857,30 +855,41 @@ function cwdMandatoryDenyPlan( ) chainHops.push(...pointer.chainHops) // What the pointer names is this checkout's git directory, and the one - // its `commondir` names with it. No `modules` is walked from this end, - // so every entry met here is one of theirs. - ownEntries = pointer.chainHops + // its `commondir` names with it: every entry met so far is one of theirs. + plan.ownEntries = pointer.chainHops .filter(hop => hop.kind === 'entry') .map(hop => hop.source) + // A command in a linked worktree is commonly let write the repository's + // git directory, and what that keeps is as much the host's git's to run. + for (const gitDir of pointer.gitDirs) { + addGitDirTree(plan, gitDirTreeDenies(gitDir, allowGitConfig, walk)) + } } else if (dotGitStat !== undefined) { // Neither: no pointer git reads, and no .git/hooks for as long as it is // there, so it is denied, which keeps it there. denyPaths.push(dotGitPath) } - return { denyPaths, repositories, chainHops, ownEntries } + return plan } /** - * What of one repository's denies a wrap whose profile does not fit may - * degrade: its submodule git directories, then its linked worktrees'. A - * collapse works back from the end, so within a repository the worktrees' go - * first: a command in this working directory has less use for another - * checkout's HEAD and index than for a submodule's objects. + * Adds one repository's denies to `plan`. What of them a wrap whose profile + * does not fit may degrade is its submodule git directories, then its linked + * worktrees'. A collapse works back from the end, so within a repository the + * worktrees' go first: a command in this working directory has less use for + * another checkout's HEAD and index than for a submodule's objects. */ -function degradableGitDirs(denies: GitDirTreeDenies): RepositorySubmodules[] { - return [repositorySubmodules(denies), repositoryWorktrees(denies)].filter( - repository => repository !== undefined, +function addGitDirTree( + plan: SubmoduleDenyPlan, + denies: GitDirTreeDenies, +): void { + plan.denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) + plan.chainHops.push(...denies.chainHops) + plan.repositories.push( + ...[repositorySubmodules(denies), repositoryWorktrees(denies)].filter( + repository => repository !== undefined, + ), ) } @@ -1316,8 +1325,9 @@ async function linuxGetMandatoryDenyPaths( // The working directory's own repository is the first the collapse works // back through, so it is the last to lose its precise denies. + const visited = new Set() const plan = asProfileRefusal(() => - cwdMandatoryDenyPlan(allowGitConfig, deadline), + cwdMandatoryDenyPlan(allowGitConfig, deadline, visited), ) const denyPaths = plan.denyPaths @@ -1516,6 +1526,7 @@ async function linuxGetMandatoryDenyPaths( ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), ) plan.chainHops.push(...pointer.chainHops) + pointer.gitDirs.forEach(denyGitDir) } } @@ -1532,12 +1543,15 @@ async function linuxGetMandatoryDenyPaths( // is a number a command can choose. const nestedDeadline = walkDeadline() for (const gitDir of nestedGitDirs.sort()) { - const denies = asProfileRefusal(() => - gitDirTreeDenies(gitDir, allowGitConfig, { deadline: nestedDeadline }), + addGitDirTree( + plan, + asProfileRefusal(() => + gitDirTreeDenies(gitDir, allowGitConfig, { + deadline: nestedDeadline, + visited, + }), + ), ) - denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) - plan.chainHops.push(...denies.chainHops) - plan.repositories.push(...degradableGitDirs(denies)) } // Deduplicated here rather than at emission: a checked-out submodule's diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 0c4ee0dff..81db35d68 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -203,10 +203,14 @@ export function macGetMandatoryDenyEntries( // file. Nested pointer files are matched by vnode type instead // (gitPointerFilter), which cannot follow them. const pointer = gitFileDenies(dotGit, allowGitConfig) + const visited = new Set() + const trees = pointer.gitDirs.map(gitDir => + gitDirTreeDenies(gitDir, allowGitConfig, { visited }), + ) entries.push( ...gitDiskDenyEntries( - pointer.denyPaths, - pointer.chainHops, + [...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], + [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], cwd, allowWritePaths, ), diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 4068502e3..9dbeced8e 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -54,11 +54,24 @@ const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS const WALK_DEADLINE_CHECK_INTERVAL = 128 /** - * Entries whose presence makes a directory a git directory. git needs HEAD - * and objects; config and hooks are what this file protects, so a directory - * holding either is treated as one even if HEAD has been moved aside. + * Whether a directory holding `entries` is taken for a git directory. + * Deliberately looser than git's `is_git_directory` (a valid HEAD plus objects/ + * and refs/), which only ever denies more: any one of these will do. git needs + * `HEAD` and `objects`, and neither is a deny path, so a command can rename both + * aside. `config`, `hooks` and `commondir` are what this file protects: from the + * first wrap that saw the directory on, one that is there cannot be moved. + * + * Each counts as the kind of entry git reads there, so that `vendor/config/lib` + * does not make a git directory of `vendor`. An entry of no known type counts. */ -const GIT_DIR_MARKERS = new Set(['HEAD', 'config', 'hooks', 'objects']) +function looksLikeGitDir(entries: fs.Dirent[]): boolean { + return entries.some(entry => + ['hooks', 'objects'].includes(entry.name) + ? !entry.isFile() + : ['HEAD', 'config', 'commondir'].includes(entry.name) && + !entry.isDirectory(), + ) +} /** * Directories git itself keeps in a git directory: its objects, its refs and @@ -99,13 +112,6 @@ const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) */ const GIT_ENTRY_FILES = new Set(['config', 'commondir', 'config.worktree']) -/** - * The narrower set {@link gitDirKind} goes by, for a directory that file - * content names; see there for why `config` and `hooks` are not in it and - * `commondir` is. - */ -const TARGET_GIT_DIR_MARKERS = new Set(['HEAD', 'objects', 'commondir']) - /** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' @@ -470,8 +476,9 @@ export interface GitDirDenies { * as well, and `chainHops` carries the directory holding each for the backend * that needs one — see {@link GitChainHop}. * - * `modules` is checked for being a link as well, though it is no deny path of - * its own: see the comment at the end of this function. + * `modules` and `worktrees` are checked for being a link as well, though + * neither is a deny path of its own: see the comment at the end of this + * function. * * An ordinary git directory costs one lstat per entry and is denied by exactly * what it always was. `deadline` bounds the walk of the chain an entry that IS @@ -573,38 +580,40 @@ export function gitDirDenies( break } } - // `modules` is not one of the deny paths - denying it whole would take - // every submodule git directory under it read-only, and each of those has - // precise denies of its own - but it is an entry like the others in the one - // respect that matters here: remove the link, put a directory of your own - // where it was, and every submodule git directory this repository keeps is - // one nothing denied. So it is held the way a chain hop is, and for the - // same reason: its own name, which is what a rename or an unlink of a link - // uses, and the directory holding it - this git directory - denied whole - // for the backend whose denies resolve, where a bind at the link's own path - // would land on the modules tree and take every submodule with it. - const modulesPath = path.join(gitDir, 'modules') - const modules = gitDirEntry(modulesPath, deadline) - switch (modules.kind) { - case 'plain': - break - case 'link': - case 'unknown': - denyWhole = true - denyPaths.push(modulesPath) - chainHops.push({ - kind: 'entry', - chain: 'resolved', - source: modulesPath, - link: modulesPath, - holder: undefined, - }) - holdChain(modulesPath, modules.hops, 'resolved') - break - case 'unreachable': - denyWhole = true - holdChain(modulesPath, modules.hops, 'unresolvable') - break + // `modules` and `worktrees` are not deny paths - denying one whole would take + // every git directory under it read-only, and each of those has precise + // denies of its own - but each is an entry like the others in the one respect + // that matters here: remove the link, put a directory of your own where it + // was, and every git directory this repository keeps there is one nothing + // denied. So it is held the way a chain hop is, and for the same reason: its + // own name, which is what a rename or an unlink of a link uses, and the + // directory holding it - this git directory - denied whole for the backend + // whose denies resolve, where a bind at the link's own path would land on the + // tree it leads to. + for (const name of ['modules', 'worktrees']) { + const keptPath = path.join(gitDir, name) + const kept = gitDirEntry(keptPath, deadline) + switch (kept.kind) { + case 'plain': + break + case 'link': + case 'unknown': + denyWhole = true + denyPaths.push(keptPath) + chainHops.push({ + kind: 'entry', + chain: 'resolved', + source: keptPath, + link: keptPath, + holder: undefined, + }) + holdChain(keptPath, kept.hops, 'resolved') + break + case 'unreachable': + denyWhole = true + holdChain(keptPath, kept.hops, 'unresolvable') + break + } } return { denyPaths, @@ -717,8 +726,9 @@ export function gitFileDenyPaths( * leads to is a git directory like any other, so an entry of it that is a * symlink needs the directory holding the link denied whole on the backend * whose denies resolve, and so does a link in the path the pointer's own - * value walks. Nothing else is enumerated through a pointer — a target's own - * `.git/modules` is not walked — so this is what it adds. + * value walks. What such a git directory keeps under its `modules` and its + * `worktrees` is not enumerated here: `gitDirs` names each one reached, for the + * caller to hand to {@link gitDirTreeDenies}. * * `deadline` bounds the walk of the pointer's value and of the `commondir`'s, * each of which can run to the megabyte the file may hold, and of any chain @@ -730,11 +740,14 @@ export function gitFileDenies( gitFile: string, allowGitConfig: boolean, deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, -): GitDirDenies { - const denyPaths = [gitFile] - const linkedEntryDirs: string[] = [] - const chainHops: GitChainHop[] = [] - const gathered: GatheredDenies = { denyPaths, linkedEntryDirs, chainHops } +): GitDirDenies & { gitDirs: string[] } { + const gathered: GatheredDenies = { + denyPaths: [gitFile], + linkedEntryDirs: [], + chainHops: [], + gitDirs: [], + } + const denies = { ...gathered, escapingDenyPaths: [] } // Looked at before the file is, whatever the file turns out to hold: a tree // full of `.git` files is as many reads of up to a megabyte as a command // cared to leave names for, and one that is no pointer starts no walk that @@ -757,12 +770,12 @@ export function gitFileDenies( `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, { level: 'warn' }, ) - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } + return denies case 'none': break } if (target === undefined) { - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } + return denies } const targets = gitMetadataTargets(path.dirname(gitFile), target, deadline) holdValueChain(gitFile, targets, gathered) @@ -807,7 +820,7 @@ export function gitFileDenies( ) } } - return { denyPaths, escapingDenyPaths: [], linkedEntryDirs, chainHops } + return denies } /** What following a `gitdir:` pointer or a `commondir` adds to, one target @@ -815,7 +828,10 @@ export function gitFileDenies( type GatheredDenies = Pick< GitDirDenies, 'denyPaths' | 'linkedEntryDirs' | 'chainHops' -> +> & { + /** The targets that are git directories. */ + gitDirs: string[] +} /** The denies of one directory a `gitdir:` or a `commondir` names, added to * `gathered`; see {@link gitDirTargetDenies}. */ @@ -837,6 +853,7 @@ function gatherTargetDenies( gathered.denyPaths.push(...denies.denyPaths) gathered.linkedEntryDirs.push(...denies.linkedEntryDirs) gathered.chainHops.push(...denies.chainHops) + if (kind === 'git-dir') gathered.gitDirs.push(target) } /** @@ -941,10 +958,14 @@ function gatherCommonDirDenies( * and config, the same for each submodule git directory under its `modules` * (what a commit inside that submodule runs), what redirects git in each * linked worktree's git directory under its `worktrees`, plus whatever the - * walks could not see through. `deadline` bounds both walks (see - * {@link submoduleGitDirs}) and, out of the same budget, the chains the + * walks could not see through. A submodule keeps linked worktrees and a linked + * worktree keeps submodules, each under its own git directory, so the two + * walks call each other: see {@link keptSubmodules} and {@link keptWorktrees}. + * + * `deadline` bounds the walks and, out of the same budget, the chains the * entries of each git directory they found lead into (see - * {@link gitDirDenies}). + * {@link gitDirDenies}). `visited` is for a caller that enumerates several git + * directories, one of which may keep another: see {@link TreeWalk}. * * Kept apart rather than flattened so that a backend which cannot carry every * mount can degrade the submodule denies and leave the rest alone; @@ -954,72 +975,33 @@ function gatherCommonDirDenies( export function gitDirTreeDenies( gitDir: string, allowGitConfig: boolean, - options: { deadline?: number } = {}, + options: { deadline?: number; visited?: Set } = {}, ): GitDirTreeDenies { - const modulesDir = path.join(gitDir, 'modules') - const deadline = - options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS - const modules = submoduleGitDirs(modulesDir, deadline) - const own = gitDirDenies(gitDir, allowGitConfig, deadline) - const submoduleUnreadableDirs: string[] = [] - const submodules = modules.gitDirs.map(submodule => { - const own = gitDirDenies(submodule, allowGitConfig, deadline) - // `git worktree add` inside a submodule keeps the worktree's git directory - // under the SUBMODULE's, and its `commondir` redirects git there as one - // under the repository's own `worktrees` does. What is found belongs to - // the submodule: it is all under its git directory, so a bind of that - // stands in for it where the profile is degraded, except what a - // `commondir` names, which is kept as escaping. - const kept = worktreeGitDirs(submodule, allowGitConfig, deadline) - submoduleUnreadableDirs.push(...kept.unreadableDirs) - return { - gitDir: submodule, - denyPaths: [ - ...own.denyPaths, - ...kept.gitDirs.flatMap(worktree => worktree.denyPaths), - ], - escapingDenyPaths: [ - ...own.escapingDenyPaths, - ...kept.gitDirs.flatMap(worktree => worktree.escapingDenyPaths), - ], - linkedEntryDirs: [ - ...own.linkedEntryDirs, - ...kept.linkedEntryDirs, - ...kept.gitDirs.flatMap(worktree => worktree.linkedEntryDirs), - ], - chainHops: [ - ...own.chainHops, - ...kept.chainHops, - ...kept.gitDirs.flatMap(worktree => worktree.chainHops), - ], - } - }) - const worktrees = worktreeGitDirs(gitDir, allowGitConfig, deadline) + const walk: TreeWalk = { + allowGitConfig, + deadline: + options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, + visited: options.visited ?? new Set(), + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + chainHops: [], + } + const submodules = keptSubmodules(gitDir, walk) + const own = gitDirDenies(gitDir, allowGitConfig, walk.deadline) + const worktrees = keptWorktrees(gitDir, walk) + const all = [own, ...submodules, ...worktrees] return { ownDenyPaths: own.denyPaths, - modulesDir, + modulesDir: path.join(gitDir, 'modules'), submodules, - worktreesDir: worktrees.worktreesDir, - worktrees: worktrees.gitDirs, - unreadableDirs: [ - ...modules.unreadableDirs, - ...submoduleUnreadableDirs, - ...worktrees.unreadableDirs, - ], + worktreesDir: path.join(gitDir, 'worktrees'), + worktrees, + unreadableDirs: [...walk.unreadableDirs].sort(), linkedEntryDirs: [ - ...own.linkedEntryDirs, - ...modules.linkedEntryDirs, - ...submodules.flatMap(submodule => submodule.linkedEntryDirs), - ...worktrees.linkedEntryDirs, - ...worktrees.gitDirs.flatMap(worktree => worktree.linkedEntryDirs), - ], - chainHops: [ - ...own.chainHops, - ...modules.chainHops, - ...submodules.flatMap(submodule => submodule.chainHops), - ...worktrees.chainHops, - ...worktrees.gitDirs.flatMap(worktree => worktree.chainHops), + ...[...walk.linkedEntryDirs].sort(), + ...all.flatMap(denies => denies.linkedEntryDirs), ], + chainHops: [...walk.chainHops, ...all.flatMap(denies => denies.chainHops)], } } @@ -1045,24 +1027,60 @@ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { return denyPaths } -/** The linked worktrees' git directories under one `.git/worktrees`, each - * with what it is denied by, and what could not be read of the rest. */ -interface WorktreeScan { - worktreesDir: string - /** Sorted by path. A `commondir` that names another git directory than the - * one these sit in adds that directory's denies to the worktree's own, as - * ones that lead out of it. */ - gitDirs: Array<{ gitDir: string } & GitDirDenies> - /** As in {@link SubmoduleScan}, for `worktreesDir` and its entries. */ - unreadableDirs: string[] - linkedEntryDirs: string[] - chainHops: GitChainHop[] +/** A git directory another one keeps, and what it is denied by. */ +type KeptGitDir = { gitDir: string } & GitDirDenies + +/** + * `own`, the denies of `gitDir`, with those of the git directories it `keeps` + * in turn: all under it, so that a bind of it stands in for them where the + * profile is degraded. Except one reached through a link, which is somewhere + * else, and what leads out of any of them. + */ +function withWhatItKeeps( + gitDir: string, + own: GitDirDenies, + keeps: KeptGitDir[], +): KeptGitDir { + const all = [own, ...keeps] + const real = keeps.length > 0 ? realPathOrSelf(gitDir) : gitDir + return { + gitDir, + denyPaths: all.flatMap(denies => denies.denyPaths), + escapingDenyPaths: [ + ...own.escapingDenyPaths, + ...keeps.flatMap(kept => + isAtOrUnder(realPathOrSelf(kept.gitDir), real) + ? kept.escapingDenyPaths + : kept.denyPaths, + ), + ], + linkedEntryDirs: all.flatMap(denies => denies.linkedEntryDirs), + chainHops: all.flatMap(denies => denies.chainHops), + } +} + +/** + * The submodule git directories under the `modules` of `gitDir`, sorted by + * path ({@link walkModules}), each with the linked worktrees it keeps: + * `git worktree add` inside a submodule puts the worktree's git directory under + * the SUBMODULE's. + */ +function keptSubmodules(gitDir: string, walk: TreeWalk): KeptGitDir[] { + return walkModules(path.join(gitDir, 'modules'), walk).map(submodule => + withWhatItKeeps( + submodule, + gitDirDenies(submodule, walk.allowGitConfig, walk.deadline), + keptWorktrees(submodule, walk), + ), + ) } /** * The git directories of the linked worktrees `gitDir` keeps, one per entry - * of its `worktrees`, and what each is denied by - * ({@link worktreeGitDirDenyPaths}). + * of its `worktrees`, sorted by path, and what each is denied by + * ({@link worktreeGitDirDenyPaths}). With the submodules it keeps: those of a + * linked worktree have their git directories under the WORKTREE's `modules`, + * while their checkouts can lie outside everything the command may write. * * Every directory there is one, whatever it holds: git makes nothing else * under `worktrees`, and an entry a command emptied — or made, for a `.git` @@ -1074,25 +1092,17 @@ interface WorktreeScan { * * Each one's `commondir` is followed as it is from a `.git` pointer: the * host's git, run in that worktree, uses the hooks and config of whatever it - * names, which is this `gitDir` unless something has rewritten it. + * names, which is this `gitDir` unless something has rewritten it. No bind of + * the worktree's git directory covers that, so it is marked as leading out. * - * `deadline` is the `.git/modules` walk's, and running out of it throws - * {@link SubmoduleWalkBudgetError} the same way: a `worktrees` is as writable - * inside the sandbox as a `modules` is, and as easy to fill. + * Running out of time throws {@link SubmoduleWalkBudgetError} as the + * `.git/modules` walk does: a `worktrees` is as writable inside the sandbox as + * a `modules` is, and as easy to fill. */ -function worktreeGitDirs( - gitDir: string, - allowGitConfig: boolean, - deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, -): WorktreeScan { +function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { + const { allowGitConfig, deadline } = walk const worktreesDir = path.join(gitDir, 'worktrees') - const walk: SubmoduleWalk = { - gitDirs: [], - unreadableDirs: new Set(), - linkedEntryDirs: new Set(), - chainHops: [], - } - const gitDirs: WorktreeScan['gitDirs'] = [] + const kept: KeptGitDir[] = [] const entries = listDirectory(worktreesDir, walk) ?? [] for (const [at, entry] of entries.entries()) { if (at % WALK_DEADLINE_CHECK_INTERVAL === 0 && Date.now() > deadline) { @@ -1105,35 +1115,57 @@ function worktreeGitDirs( if (found.kind === 'skip') continue // Where a link leads to nothing is denied as a git directory that is not // there yet, as the `.git/modules` walk denies one. - const worktreeGitDir = found.kind === 'dangling' ? found.landing : child + const worktree = found.kind === 'dangling' ? found.landing : child + if (isVisited(worktree, walk)) continue const own = gitDirDenies( - worktreeGitDir, + worktree, allowGitConfig, deadline, - worktreeGitDirDenyPaths(worktreeGitDir, allowGitConfig), + worktreeGitDirDenyPaths(worktree, allowGitConfig), ) const common: GatheredDenies = { denyPaths: [], linkedEntryDirs: [], chainHops: [], + gitDirs: [], } - gatherCommonDirDenies(worktreeGitDir, allowGitConfig, common, deadline, [ - worktreeGitDir, + gatherCommonDirDenies(worktree, allowGitConfig, common, deadline, [ + worktree, gitDir, ]) - gitDirs.push({ - gitDir: worktreeGitDir, - denyPaths: [...own.denyPaths, ...common.denyPaths], - // No bind of the worktree's git directory covers what its commondir - // names, so a degrade of it must not drop those. - escapingDenyPaths: [...own.escapingDenyPaths, ...common.denyPaths], - linkedEntryDirs: [...own.linkedEntryDirs, ...common.linkedEntryDirs], - chainHops: [...own.chainHops, ...common.chainHops], - }) + kept.push( + withWhatItKeeps( + worktree, + { + denyPaths: [...own.denyPaths, ...common.denyPaths], + escapingDenyPaths: [...own.escapingDenyPaths, ...common.denyPaths], + linkedEntryDirs: [...own.linkedEntryDirs, ...common.linkedEntryDirs], + chainHops: [...own.chainHops, ...common.chainHops], + }, + keptSubmodules(worktree, walk), + ), + ) + } + return kept.sort((a, b) => (a.gitDir < b.gitDir ? -1 : 1)) +} + +/** + * {@link walkModules} by itself, with what it could not see through. + */ +export function submoduleGitDirs( + modulesDir: string, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): SubmoduleScan { + const walk: TreeWalk = { + allowGitConfig: false, + deadline, + visited: new Set(), + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + chainHops: [], } return { - worktreesDir, - gitDirs: gitDirs.sort((a, b) => (a.gitDir < b.gitDir ? -1 : 1)), + gitDirs: walkModules(modulesDir, walk), unreadableDirs: [...walk.unreadableDirs].sort(), linkedEntryDirs: [...walk.linkedEntryDirs].sort(), chainHops: walk.chainHops, @@ -1147,52 +1179,45 @@ function worktreeGitDirs( * the walk. * * A directory recorded as a git directory is walked on all the same. What - * makes it one is a name ({@link GIT_DIR_MARKERS}), and a segment of a + * makes it one is what it holds ({@link looksLikeGitDir}), and a segment of a * submodule's name (`vendor` of `vendor/lib`) is an ordinary directory a * sandboxed command can write: a walk that stopped at the first directory * which looks like a git directory would let one empty `HEAD` left in `vendor` * take the denies off every submodule beneath it for the commands that - * follow. git refuses a submodule whose git directory would sit inside - * another's, so in a tree git made there is nothing past a git directory to - * find, and looking costs one listing of each directory in it. The ones where - * a repository is large are not walked into beyond that listing: see - * {@link GIT_OWN_DIRECTORIES}. `modulesDir` itself, and a git directory's own - * `modules`, are never taken for a git directory whatever they hold. + * follow. That goes for its `modules` too, which is where a git directory keeps + * its own submodules and may as well be the submodule `vendor/modules`: it is + * looked at like any other directory. git refuses a submodule whose git + * directory would sit inside another's, so in a tree git made there is nothing + * else past a git directory to find, and looking costs one listing of each + * directory in it. The ones where a repository is large are not walked into + * beyond that listing: see {@link GIT_OWN_DIRECTORIES}. `modulesDir` itself is + * never taken for a git directory whatever it holds. * * Every directory is visited once, keyed by where it really is, so a symlink * pointing back into the tree ends the branch that reached it rather than * looping - but a bind mount gives the same directory a second real path, and - * one pointed at its own ancestor has no end the visited set can see. `deadline` - * is what bounds that, and running out of it throws + * one pointed at its own ancestor has no end the visited set can see. The + * deadline is what bounds that, and running out of it throws * {@link SubmoduleWalkBudgetError} rather than handing back a listing that * stops somewhere unknown. Nothing bounds how DEEP a real tree may be: a * submodule nested a hundred levels down has its hooks denied like any other, * and the walk keeps its own stack so that such a tree cannot overflow this * one's. */ -export function submoduleGitDirs( - modulesDir: string, - deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, -): SubmoduleScan { - const walk: SubmoduleWalk = { - gitDirs: [], - unreadableDirs: new Set(), - linkedEntryDirs: new Set(), - chainHops: [], - } - // Where the walk still has to look, and where it has already been. A - // directory listed to see whether it is a git directory carries its entries - // with it, so that no directory is listed twice. The root counts as visited: - // an entry linked straight back to it is then the same dead end as one - // linked to any other directory already walked. `gitDir` says the directory - // was recorded as a git directory, so that some of its entries may be git's - // own. +function walkModules(modulesDir: string, walk: TreeWalk): string[] { + // The root counts as visited: an entry linked straight back to it is then the + // same dead end as one linked to any other directory already walked. + if (isVisited(modulesDir, walk)) return [] + const gitDirs: string[] = [] + // Where the walk still has to look. A directory listed to see whether it is a + // git directory carries its entries with it, so that no directory is listed + // twice. `gitDir` says the directory was recorded as a git directory, so that + // some of its entries may be git's own. const pending: Array<{ dir: string entries?: fs.Dirent[] gitDir?: boolean }> = [{ dir: modulesDir }] - const visited = new Set([realPathOrSelf(modulesDir)]) let entriesSeen = 0 for (let item = pending.pop(); item !== undefined; item = pending.pop()) { const entries = item.entries ?? listDirectory(item.dir, walk) @@ -1200,7 +1225,7 @@ export function submoduleGitDirs( for (const entry of entries) { if ( entriesSeen++ % WALK_DEADLINE_CHECK_INTERVAL === 0 && - Date.now() > deadline + Date.now() > walk.deadline ) { throw new SubmoduleWalkBudgetError( `[Sandbox] The walk of ${modulesDir} ran out of the time it was given while listing ${item.dir}, with ${pending.length} directories still to look at; refusing to sandbox on the submodule git directories it did reach`, @@ -1209,8 +1234,6 @@ export function submoduleGitDirs( const child = path.join(item.dir, entry.name) let childEntries: fs.Dirent[] | undefined if (item.gitDir === true) { - // Queued when the git directory was recorded. - if (entry.name === 'modules') continue // A link at `config`, `commondir` or `config.worktree` is read as // this git directory's entry by what denies it, which holds both ends // of it (see gitDirDenies). Following it here as well would walk the @@ -1238,55 +1261,62 @@ export function submoduleGitDirs( // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. - const found = walkEntry(entry, child, walk, deadline) + const found = walkEntry(entry, child, walk, walk.deadline) if (found.kind === 'skip') continue if (found.kind === 'dangling') { // Where a link leads to nothing is denied as a git directory that is // not there yet, so a command cannot fill one in for the host's git // to find. Nothing is walked: there is nothing there to walk. - if (!visited.has(found.landing)) { - visited.add(found.landing) - walk.gitDirs.push(found.landing) - } + if (!isVisited(found.landing, walk)) gitDirs.push(found.landing) continue } - const visitKey = realPathOrSelf(child) - if (visited.has(visitKey)) continue - visited.add(visitKey) + if (isVisited(child, walk)) continue childEntries ??= listDirectory(child, walk) if (childEntries === undefined) continue - const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) - if (isGitDir) { - walk.gitDirs.push(child) - // A git directory keeps its own submodules under `modules`. - pending.push({ dir: path.join(child, 'modules') }) - } + const isGitDir = looksLikeGitDir(childEntries) + if (isGitDir) gitDirs.push(child) // Anything else is a segment of a submodule name (`vendor` of // `vendor/lib`) - and a directory that looks like a git directory can // be one as well, so what else it holds is looked at either way. pending.push({ dir: child, entries: childEntries, gitDir: isGitDir }) } } - return { - gitDirs: walk.gitDirs.sort(), - unreadableDirs: [...walk.unreadableDirs].sort(), - linkedEntryDirs: [...walk.linkedEntryDirs].sort(), - chainHops: walk.chainHops, - } + return gitDirs.sort() } -/** What the walk has found so far. The deny paths a directory it could not - * see through produces are a set: one unreadable entry and the next fold to - * the same ancestor, and a repeated path is a mount the profile never pays - * for but would otherwise be counted as one. */ -interface SubmoduleWalk { - gitDirs: string[] +/** + * What the walks of one enumeration go by, and what they find beside the git + * directories. + */ +interface TreeWalk { + allowGitConfig: boolean + deadline: number + /** + * Every directory a walk has been into, by where it really is. One set for + * all of them: a link can lead from a `worktrees` back to the git directory + * that keeps it, and each walk would then find the other's tree anew, one + * spelling longer, for as long as a path can get. A caller that enumerates + * several git directories shares it between them, so that one which another + * keeps is not enumerated twice. + */ + visited: Set + /** As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold + * to the same ancestor, and a repeated path is a mount the profile never + * pays for but would otherwise be counted as one. */ unreadableDirs: Set linkedEntryDirs: Set chainHops: GitChainHop[] } +/** Whether a walk has been into `dir` before. It has from now on. */ +function isVisited(dir: string, walk: TreeWalk): boolean { + const real = realPathOrSelf(dir) + if (walk.visited.has(real)) return true + walk.visited.add(real) + return false +} + /** What one entry of a walked directory is worth looking into. */ type WalkEntry = /** A directory to walk, reached directly or through a symlink. */ @@ -1298,10 +1328,7 @@ type WalkEntry = | { kind: 'skip' } /** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ -function listDirectory( - dir: string, - walk: SubmoduleWalk, -): fs.Dirent[] | undefined { +function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { try { return fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { @@ -1335,7 +1362,7 @@ function listDirectory( function walkEntry( entry: fs.Dirent, entryPath: string, - walk: SubmoduleWalk, + walk: TreeWalk, deadline: number, ): WalkEntry { if (entry.isDirectory()) return { kind: 'directory' } @@ -1472,33 +1499,11 @@ function gitDirTargetDenies( } /** - * Whether `dir` is a git directory. Deliberately looser than git's - * `is_git_directory` (a valid HEAD plus objects/ and refs/): every directory - * git accepts has a HEAD entry, so this accepts those and some besides, - * which only ever denies more. - * - * Narrower than {@link GIT_DIR_MARKERS}, which the `.git/modules` walk uses: - * a directory here is named by file content a sandboxed command can write, so - * accepting `config` or `hooks` would let that content aim a deny at any - * directory at all. The walk reaches only what lies under `.git/modules` — - * except through a symlinked entry there, which a command able to write under - * it can aim at one directory of its choosing. - * - * `HEAD` and `objects` are not the whole answer, because neither is a deny - * path: a command can rename one aside, and a linked worktree's git directory - * holds `HEAD` and no `objects`, so one `mv` would leave the next wrap - * denying the pointer file alone and not following `commondir`. Two things - * stand in where they are gone: - * - * - `commondir` counts as they do. A linked worktree's git directory always - * holds one, wherever its repository keeps it — a bare repository's - * `repo.git/worktrees/` as much as a `.git/worktrees/` — and it IS - * a deny path, so from the first wrap that saw the directory on it cannot - * be moved aside. No ordinary directory has an entry of that name, and one - * a command makes is what one named `HEAD` already was. - * - WHERE a directory is cannot be arranged by what a file says, so one that - * sits where only git puts a git directory is one whatever it holds just - * now: see {@link isGitOwnedLocation}. + * Whether `dir`, which a `gitdir:` or a `commondir` names, is a git directory: + * by what it holds ({@link looksLikeGitDir}), or by where it is. WHERE a + * directory is cannot be arranged by what a file says, so one that sits where + * only git puts a git directory is one whatever it holds just now: see + * {@link isGitOwnedLocation}. */ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] @@ -1508,8 +1513,9 @@ function gitDirKind(dir: string): GitDirKind { if (isUnusablePathError(err)) return 'unusable' return isAbsenceError(err) ? 'absent' : 'unreadable' } - if (entries.some(e => TARGET_GIT_DIR_MARKERS.has(e.name))) return 'git-dir' - return isGitOwnedLocation(dir) ? 'git-dir' : 'other' + return looksLikeGitDir(entries) || isGitOwnedLocation(dir) + ? 'git-dir' + : 'other' } /** diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 8c0ca8ce9..8fec587b1 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -12,7 +12,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux, isWindows } from '../helpers/platform.js' @@ -77,12 +77,14 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }) it('finds the submodule beneath a name segment a command left a marker in', () => { - // Each of the four names, as the emptiest thing a command can leave. + // Each of the names, as the emptiest thing of its kind a command can leave. + const markers = ['HEAD', 'config', 'commondir', 'hooks/', 'objects/'] const expected: string[] = [] - for (const marker of ['HEAD', 'config', 'hooks', 'objects']) { - const vendor = join(modules, `vendor-${marker}`) + for (const marker of markers) { + const vendor = join(modules, `vendor-${basename(marker)}`) const lib = makeGitDir(join(vendor, 'lib')) - writeFileSync(join(vendor, marker), '') + if (marker.endsWith('/')) mkdirSync(join(vendor, marker)) + else writeFileSync(join(vendor, marker), '') // The segment is recorded too. The markers only ever deny more, and a // HEAD alone is also what a git directory looks like with the rest of // it moved aside, so what the segment was denied by before it still is. @@ -98,11 +100,10 @@ describe('The .git/modules walk beneath what looks like a git directory', () => chainHops: [], }) const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) - for (const marker of ['HEAD', 'config', 'hooks', 'objects']) { - const lib = join(modules, `vendor-${marker}`, 'lib') - for (const deny of gitDirDenyPaths(lib, false)) { - expect(denies).toContain(deny) - } + for (const gitDir of expected) { + expect(denies).toEqual( + expect.arrayContaining(gitDirDenyPaths(gitDir, false)), + ) } }) @@ -118,22 +119,24 @@ describe('The .git/modules walk beneath what looks like a git directory', () => ]) }) - it('finds a submodule whose own name is one of the markers', () => { - // `x/config`: the git directory is a DIRECTORY named config, which makes - // `x` look like a git directory with no help from any command. - const x = join(modules, 'x') - const sub = makeGitDir(join(x, 'config')) + it('takes a marker for one only as the kind of entry git reads there', () => { + // `x/config` and `x/HEAD`: each git directory is a DIRECTORY of that name. + // Taken for a git directory, `x` would be denied its `config` whole, which + // costs that submodule its git writes with no help from any command. + const subs = ['HEAD', 'commondir', 'config'].map(name => + makeGitDir(join(modules, 'x', name)), + ) + writeFileSync(join(modules, 'x', 'objects'), '') - const scan = submoduleGitDirs(modules) + expect(submoduleGitDirs(modules).gitDirs).toEqual(subs) + }) - // Both. The deny `x` gets for its `config` is the submodule's git - // directory whole, which is all the submodule had before and costs it its - // git writes in the sandbox; its own hooks and config are now named too. - expect(scan.gitDirs).toEqual([x, sub]) - const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) - for (const deny of gitDirDenyPaths(sub, false)) { - expect(denies).toContain(deny) - } + it('finds a submodule named hooks, and denies it whole', () => { + // A directory is what git reads there, so `x` is recorded as well. + const x = join(modules, 'x') + const sub = makeGitDir(join(x, 'hooks')) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([x, sub]) }) it('finds a submodule named like one of the directories git keeps, beneath such a segment', () => { @@ -158,12 +161,25 @@ describe('The .git/modules walk beneath what looks like a git directory', () => const expected = GIT_OWN_NAMES.map(name => makeGitDir(join(modules, name))) writeFileSync(join(modules, 'HEAD'), '') writeFileSync(join(modules, 'config'), '') - const nested = makeGitDir(join(modules, 'objects', 'modules', 'refs')) - writeFileSync(join(modules, 'objects', 'modules', 'HEAD'), '') - expect(submoduleGitDirs(modules).gitDirs).toEqual( - [...expected, nested].sort(), - ) + expect(submoduleGitDirs(modules).gitDirs).toEqual(expected.sort()) + }) + + it('takes the modules of a git directory for what it holds', () => { + // Where `lib` keeps its own submodules, and the submodule `vendor/modules` + // once a command has left a `HEAD` in `vendor`: nothing tells the two apart. + const lib = makeGitDir(join(modules, 'lib')) + const nested = makeGitDir(join(lib, 'modules', 'nested')) + const vendor = join(modules, 'vendor') + const named = makeGitDir(join(vendor, 'modules')) + writeFileSync(join(vendor, 'HEAD'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + nested, + vendor, + named, + ]) }) it('does not walk into what git itself keeps in a git directory', () => { diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index a995b0dc2..52e921a82 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -3710,8 +3710,8 @@ describe('Git metadata deny paths - Unit Tests', () => { // lstat refused: whether it is a link is unknown, so the entry keeps // the deny it has (and keeps it through a degrade, in case it is one) // and the git directory is denied whole as a link would need. `modules` - // is in the same position, and named for the backend that holds a link - // by its own name. + // and `worktrees` are in the same position, and named for the backend + // that holds a link by its own name. const gitDir = makeGitDir(join(dir, 'repo', '.git')) chmodSync(gitDir, 0o000) try { @@ -3720,6 +3720,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.denyPaths).toEqual([ ...gitDirDenyPaths(gitDir, false), join(gitDir, 'modules'), + join(gitDir, 'worktrees'), ]) expect(denies.linkedEntryDirs).toEqual([gitDir]) expect(denies.escapingDenyPaths).toEqual(gitDirDenyPaths(gitDir, false)) diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index 92f11fbf5..de24a5e52 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -552,7 +552,8 @@ describe.if(!isWindows)( mkdirSync(join(dir, 'repo', 'worktree')) writeFileSync(pointer, 'gitdir: ../through/gd\n') const through = join(dir, 'repo', 'through') - const expectedPointer: GitDirDenies = { + const expectedPointer: ReturnType = { + gitDirs: [join(through, 'gd')], denyPaths: [ pointer, through, diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index c19661dc6..9c442df0c 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' import { spawnSync } from 'node:child_process' import { chmodSync, @@ -14,6 +15,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux, isWindows } from '../helpers/platform.js' import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-utils.js' @@ -28,6 +30,7 @@ import { gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, + gitFileDenies, gitFileDenyPaths, } from '../../src/sandbox/mandatory-deny-paths.js' import * as denyCollapse from '../../src/sandbox/linux-deny-collapse.js' @@ -223,6 +226,114 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(literals).toContain(join(mainGitDir, 'config')) }) + /** + * A repository that keeps a submodule and two linked worktrees, and what is + * denied of those from the checkout of the first: a command there is + * commonly let write the repository's git directory, to commit. + */ + function repositoryThatKeepsMore(): { checkout: string; kept: string[] } { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const checkout = join(dir, 'wt') + const own = makeLinkedWorktree(mainGitDir, 'wt', checkout) + const other = makeLinkedWorktree(mainGitDir, 'other', join(dir, 'other')) + return { + checkout, + kept: [ + ...gitDirDenyPaths( + makeGitDir(join(mainGitDir, 'modules', 'lib')), + false, + ), + ...gitDirDenyPaths( + makeGitDir(join(own.worktreeGitDir, 'modules', 'lib')), + false, + ), + join(other.worktreeGitDir, 'commondir'), + join(other.worktreeGitDir, 'gitdir'), + ], + } + } + + it('names the git directories it reached, for what they keep', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + + expect(gitFileDenies(pointer, false).gitDirs).toEqual([ + worktreeGitDir, + mainGitDir, + ]) + }) + + it.if(isLinux)('carries what they keep into the Linux deny list', () => { + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + expect(linuxGetCwdMandatoryDenyPaths(false, [dir])).toEqual( + expect.arrayContaining(kept), + ) + }) + + it('carries what they keep into the macOS entries', () => { + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + const literals = macGetMandatoryDenyEntries(false, [dir]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toEqual(expect.arrayContaining(kept)) + expect(literals.length).toBe(new Set(literals).size) + }) + + it.if(isLinux)( + 'denies what the git directory behind a nested pointer keeps', + async () => { + // Under no name the scan looks for, so the pointer is the only way in. + const store = makeGitDir(join(dir, 'project', 'store')) + const hooks = join(makeGitDir(join(store, 'modules', 'lib')), 'hooks') + mkdirSync(join(dir, 'project', 'checkout')) + writeFileSync( + join(dir, 'project', 'checkout', '.git'), + 'gitdir: ../store\n', + ) + process.chdir(join(dir, 'project')) + + const wrapped = await wrap('true', [join(dir, 'project')]) + + expect(lastMountAt(wrapped, hooks)).toBe(`--ro-bind ${hooks} ${hooks}`) + }, + ) + + it.if(isLinux)( + 'enumerates once a git directory reached both ways', + async () => { + // Found twice, what it keeps would be degraded twice, and the second + // time gives nothing back. + const project = join(dir, 'project') + const submodule = makeGitDir(join(project, '.git', 'modules', 'lib')) + makeGitDir(join(project, '.git')) + makeGitDir(join(submodule, 'modules', 'nested')) + mkdirSync(join(project, 'lib')) + writeFileSync( + join(project, 'lib', '.git'), + 'gitdir: ../.git/modules/lib\n', + ) + process.chdir(project) + const list = spyOn(fs, 'readdirSync') + + const listed = await wrap('true', [project]) + .then(() => list.mock.calls.map(([listedDir]) => listedDir)) + .finally(() => list.mockRestore()) + + expect( + listed.filter(listedDir => listedDir === join(submodule, 'modules')), + ).toHaveLength(1) + }, + ) + it('keeps them with nothing left in the directory at all', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const { worktreeGitDir, pointer } = makeLinkedWorktree( @@ -288,7 +399,10 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const pointer = join(dir, 'checkout', '.git') writeFileSync(pointer, 'gitdir: ../data/worktrees/a\n') - expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + expect(gitFileDenies(pointer, false)).toMatchObject({ + denyPaths: [pointer], + gitDirs: [], + }) }) for (const mainGitDirName of ['.git', 'main.git']) { @@ -364,6 +478,30 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ]) }) + it.each([ + { left: 'config', allowGitConfig: false }, + { left: 'hooks', allowGitConfig: true }, + ])( + 'is followed with all but its $left moved aside', + ({ left, allowGitConfig }) => { + // HEAD, objects and refs are no deny paths: a command can rename them + // aside, and back once the next wrap has left the hooks writable. What + // is denied stays where it is, so it has to be enough. + const gitDir = makeGitDir(join(dir, 'store')) + for (const name of ['HEAD', 'objects', 'refs', 'config', 'hooks']) { + if (name !== left) renameSync(join(gitDir, name), join(dir, name)) + } + mkdirSync(join(dir, 'checkout')) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../store\n') + + expect(gitFileDenyPaths(pointer, allowGitConfig)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, allowGitConfig), + ]) + }, + ) + it('is followed where its objects live elsewhere', () => { // HEAD and refs, which is all git itself asks of the directory when the // object store is named by the environment. @@ -643,6 +781,110 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(literals).toContain(join(worktreeGitDir, 'gitdir')) }) + it('denies the submodules a linked worktree keeps, and what those keep', () => { + // A linked worktree's submodules have their git directories under the + // WORKTREE's, inside the repository, while their checkouts are in the + // worktree, which can lie outside everything the command may write. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + const submodule = makeGitDir(join(worktreeGitDir, 'modules', 'a', 'lib')) + const deeper = makeLinkedWorktree(submodule, 'wt2', join(dir, 'wt2')) + const deepest = makeGitDir(join(deeper.worktreeGitDir, 'modules', 'x')) + + const denies = gitDirTreeDenies(mainGitDir, false) + + // All under the worktree's git directory, so a bind of it stands in for + // them where the profile is degraded. + expect(denies.worktrees).toEqual([ + { + gitDir: worktreeGitDir, + denyPaths: [ + ...heldFiles(worktreeGitDir), + ...gitDirDenyPaths(submodule, false), + ...heldFiles(deeper.worktreeGitDir), + ...gitDirDenyPaths(deepest, false), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + }, + ]) + }) + + it('keeps through a degrade what a kept git directory reached through a link is denied by', () => { + // A bind of the submodule's git directory does not cover it. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(elsewhere) + mkdirSync(join(submodule, 'worktrees')) + symlinkSync(elsewhere, join(submodule, 'worktrees', 'linked')) + symlinkSync(join(dir, 'absent'), join(submodule, 'worktrees', 'dangling')) + + const [found] = gitDirTreeDenies(mainGitDir, false).submodules + + expect(found?.escapingDenyPaths).toEqual([ + ...heldFiles(join(dir, 'absent')), + ...heldFiles(join(submodule, 'worktrees', 'linked')), + ]) + }) + + it('ends where a link leads back to a git directory it has been into', () => { + // Each walk would find the other's tree anew, one spelling longer, for as + // long as a path can get. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodules = ['a', 'b', 'c'].map(name => + makeGitDir(join(mainGitDir, 'modules', name)), + ) + for (const submodule of submodules) { + mkdirSync(join(submodule, 'worktrees')) + symlinkSync(mainGitDir, join(submodule, 'worktrees', 'back')) + } + + const denies = gitDirTreeDenies(mainGitDir, false, { + deadline: Date.now() + 5000, + }) + + expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) + }) + + it('holds a worktrees that is a link, as it holds a modules that is one', () => { + // Remove the link, put a directory where it was, and every `.git` file + // that names a worktree's git directory through it opens a planted one. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(join(elsewhere, 'wt'), { recursive: true }) + const worktrees = join(mainGitDir, 'worktrees') + symlinkSync(elsewhere, worktrees) + + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(denies.linkedEntryDirs).toEqual([mainGitDir]) + expect(denies.ownDenyPaths).toContain(worktrees) + expect(gitDirTreeDenyPaths(denies)).toEqual( + expect.arrayContaining(heldFiles(join(worktrees, 'wt'))), + ) + }) + + it('enumerates once a git directory that another one keeps', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + makeGitDir(join(submodule, 'modules', 'nested')) + makeLinkedWorktree(submodule, 'wt', join(dir, 'wt')) + const visited = new Set() + + gitDirTreeDenies(mainGitDir, false, { visited }) + const again = gitDirTreeDenies(submodule, false, { visited }) + + expect(gitDirTreeDenyPaths(again)).toEqual( + gitDirDenyPaths(submodule, false), + ) + }) + it('costs a submodule with no linked worktrees nothing', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) From f691c11003c9d883389d2eb8e8ef2f7fe9778368 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:01:05 +0000 Subject: [PATCH 130/141] the walk of a chain or a pointer's value: no overflow, no lossy link - What is still to be walked is a stack. It was an array taken from at the front and rebuilt at each link, which a value of half a million components made quadratic where the runtime copies, and it was spread into one call's arguments, which overflows. - A link whose target is not valid UTF-8 was read as a string that names another path. It is refused like a pointer's value of such bytes, with `deny_git_metadata_unreadable` on Linux: the deny used to land on the link itself, and bubblewrap then never started, with no code to go by. - The deny that stands in for an entry of `.git/modules` that cannot be looked behind is aimed where the chain lands, not where one hop folds to on paper. - A tree's deny paths are added to the plan one at a time, not as arguments. - macOS: one time budget for all that is read off disk, where every deny path had ten seconds of its own, and one call for the entries. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- README.md | 2 +- src/sandbox/linux-sandbox-utils.ts | 8 +- src/sandbox/macos-sandbox-utils.ts | 80 ++++++++----------- src/sandbox/mandatory-deny-paths.ts | 45 ++++++----- src/utils/real-path.ts | 7 +- .../mandatory-deny-modules-walk.test.ts | 21 +++++ .../mandatory-deny-time-budget.test.ts | 56 +++++++++++++ 7 files changed, 145 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 80f426fe5..2e56d490d 100644 --- a/README.md +++ b/README.md @@ -1218,7 +1218,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. A symlink whose target is not valid UTF-8 is refused the same way, where it is an entry of a git directory or lies on the way to one. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry` — an entry of the working directory's OWN repository (`hooks`, `config`, `commondir` or `config.worktree` of the git directory its `.git` is or names) is a symlink whose chain reaches nothing (a loop, or a name no file can occupy), and one of the links it goes through sits in the working directory or a path this command may write, where no read-only bind can hold it. There is no landing to deny instead, so the command is refused rather than run with the link free; the message names the entry, the link and the directory holding it. Repairing the chain, moving the links inside the git directory, or a `denyWrite` entry naming the directory holding them, each lifts it. A pointer's value that reaches nothing is NOT this: see the bullet above. Neither is the same chain behind anything a command may have made (a nested repository the scan found, a submodule's git directory, a `modules` that was not there): those get the warning an unheld hop gets and the command runs, because refusing there would let a few ordinary writes lock every later command out, and would protect nothing the command could not already reach by creating a nested repository outright. The four entries of the working directory's own repository are denied at their own paths from the first wrap that saw it, so a link found at one is the caller's layout; what is left to plant is a repository created and linked this way by one command in a directory that had none. - `deny_placeholder_unavailable` where a `.git` pointer file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`): the mount point for its `commondir` cannot be written, and the directory cannot be denied whole instead. Making it writable lifts it. - `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 58e4f333b..b290c4522 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -884,8 +884,12 @@ function addGitDirTree( plan: SubmoduleDenyPlan, denies: GitDirTreeDenies, ): void { - plan.denyPaths.push(...linuxGitDirTreeDenyPaths(denies)) - plan.chainHops.push(...denies.chainHops) + // One at a time: a call takes only so many arguments, and a command can make + // more git directories than that. + for (const denyPath of linuxGitDirTreeDenyPaths(denies)) { + plan.denyPaths.push(denyPath) + } + for (const hop of denies.chainHops) plan.chainHops.push(hop) plan.repositories.push( ...[repositorySubmodules(denies), repositoryWorktrees(denies)].filter( repository => repository !== undefined, diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 81db35d68..d2b5243e0 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -3,6 +3,7 @@ import { spawn } from 'child_process' import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' +import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' import { whichSync } from '../utils/which.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { @@ -160,6 +161,15 @@ export function macGetMandatoryDenyEntries( // becomes a literal entry here — both of its spellings, since a filter and // a bind hold a path that goes through a symlink by different ends (see // gitDiskDenyEntries). + // One budget for all that is read off disk, which a command can make large. + const deadline = Date.now() + DEFAULT_RIPGREP_TIMEOUT_MS + const withOnDisk = ( + denyPaths: string[], + chainHops: readonly GitChainHop[] = [], + ): PathEntry[] => [ + ...entries, + ...gitDiskDenyEntries(denyPaths, chainHops, cwd, allowWritePaths, deadline), + ] const dotGit = path.resolve(cwd, '.git') let dotGitStat: fs.Stats | undefined try { @@ -169,56 +179,29 @@ export function macGetMandatoryDenyEntries( // could not be looked at, which is no reason to skip the enumeration: a // literal entry is a subpath deny, so this denies it whole, as the Linux // backend's bind of the same path does. - if (!isAbsenceErrno(err)) { - return [ - ...entries, - ...gitDiskDenyEntries([dotGit], [], cwd, allowWritePaths), - ] - } + if (!isAbsenceErrno(err)) return withOnDisk([dotGit]) } if (dotGitStat?.isDirectory()) { - const tree = gitDirTreeDenies(dotGit, allowGitConfig) - entries.push( - ...gitDiskDenyEntries( - gitDirTreeDenyPaths(tree), - tree.chainHops, - cwd, - allowWritePaths, - ), - ) - } else { - // Absent, or a pointer file: the repository's own hooks and config are - // denied either way, so neither can be created under a .git that is not - // there yet. - entries.push( - ...gitDiskDenyEntries( - gitDirDenyPaths(dotGit, allowGitConfig), - [], - cwd, - allowWritePaths, - ), - ) - if (dotGitStat?.isFile()) { - // cwd checked out as a linked worktree or submodule: .git is a pointer - // file. Nested pointer files are matched by vnode type instead - // (gitPointerFilter), which cannot follow them. - const pointer = gitFileDenies(dotGit, allowGitConfig) - const visited = new Set() - const trees = pointer.gitDirs.map(gitDir => - gitDirTreeDenies(gitDir, allowGitConfig, { visited }), - ) - entries.push( - ...gitDiskDenyEntries( - [...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], - [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], - cwd, - allowWritePaths, - ), - ) - } + const tree = gitDirTreeDenies(dotGit, allowGitConfig, { deadline }) + return withOnDisk(gitDirTreeDenyPaths(tree), tree.chainHops) } - - return entries + // Absent, or a pointer file: the repository's own hooks and config are + // denied either way, so neither can be created under a .git that is not + // there yet. + const own = gitDirDenyPaths(dotGit, allowGitConfig) + if (!dotGitStat?.isFile()) return withOnDisk(own) + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFilter), which cannot follow them. + const pointer = gitFileDenies(dotGit, allowGitConfig, deadline) + const visited = new Set() + const trees = pointer.gitDirs.map(gitDir => + gitDirTreeDenies(gitDir, allowGitConfig, { deadline, visited }), + ) + return withOnDisk( + [...own, ...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], + [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], + ) } /** @@ -266,6 +249,7 @@ function gitDiskDenyEntries( chainHops: readonly GitChainHop[], cwd: string, allowWritePaths: readonly string[], + deadline: number, ): PathEntry[] { const hopLinks = new Set(chainHops.map(hop => hop.link)) const entries: PathEntry[] = [] @@ -274,7 +258,7 @@ function gitDiskDenyEntries( for (const denyPath of denyPaths) { const spellings = hopLinks.has(denyPath) ? [denyPath] - : [denyPath, physicalDenyPath(denyPath)] + : [denyPath, physicalDenyPath(denyPath, deadline)] // A literal entry is a subpath deny, so one naming the working directory // or a write root - by either spelling - takes the whole tree read-only // for every command after the one that arranged it, and a `.git` pointer diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 9dbeced8e..259cd733b 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' -import { realPathOrSelf } from '../utils/real-path.js' +import { linkTextOf, realPathOrSelf } from '../utils/real-path.js' import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' import { isAbsenceErrno, @@ -1428,8 +1428,7 @@ function walkEntry( // IT, rather than on the directory holding the link, which would take // every submodule beside it over one entry; the link itself is a link // like any other, and its holder is denied for it. - const denied = - deepestReachableAncestor(linkTarget(entryPath) ?? entryPath) ?? entryPath + const denied = deepestReachableAncestor(chain.landing) ?? entryPath walk.unreadableDirs.add(denied) walk.linkedEntryDirs.add(holder) holdChain('resolved') @@ -1441,16 +1440,6 @@ function walkEntry( } } -/** Where `link` points, resolved against the directory holding it, or - * undefined when it cannot be read. */ -function linkTarget(link: string): string | undefined { - try { - return path.resolve(path.dirname(link), fs.readlinkSync(link)) - } catch { - return undefined - } -} - /** * What a directory a `gitdir:` or `commondir` names is denied by. An existing * directory that is not a git directory is left alone: file content must not @@ -1722,7 +1711,9 @@ function gitMetadataTargets( * is an lstat, and a command can leave as many of them as it likes. `deadline` * is what bounds that, looked at as the components go by, and running out of * it throws {@link SubmoduleWalkBudgetError} as the `.git/modules` walk does: - * a landing that was never reached is not one to work a deny out from. + * a landing that was never reached is not one to work a deny out from. A link + * whose target is no text throws {@link GitMetadataError}, as a value that is + * none does. */ function resolveChain( base: string, @@ -1730,12 +1721,14 @@ function resolveChain( deadline: number, ): ResolvedChain { let current = path.isAbsolute(target) ? path.parse(target).root : base - let pending = target.split('/') + // What is still to be walked, last component first: a value can hold half a + // million, and an array taken from at the front is copied each time. + const pending = target.split('/').reverse() const hops: ChainHop[] = [] const named = new Set() let followed = 0 let componentsSeen = 0 - while (pending.length > 0) { + for (let name = pending.pop(); name !== undefined; name = pending.pop()) { // On the first component as well as every so many after it: a tree full // of short chains is as many walks as a command cared to leave, and none // of them is long enough to come round to a later look. @@ -1747,8 +1740,7 @@ function resolveChain( `[Sandbox] The walk of ${abridged(target)} from ${base} ran out of the time it was given at ${current}, ${followed} symlinks in and with ${pending.length} path components still to go; refusing to sandbox on a path that was not walked to where it leads`, ) } - const name = pending.shift() - if (name === undefined || name === '' || name === '.') continue + if (name === '' || name === '.') continue if (name === '..') { current = path.dirname(current) continue @@ -1756,9 +1748,18 @@ function resolveChain( const next = path.join(current, name) let link: string | undefined try { - if (fs.lstatSync(next).isSymbolicLink()) link = fs.readlinkSync(next) - } catch { - return { landing: path.join(next, ...pending), hops, resolved: true } + if (fs.lstatSync(next).isSymbolicLink()) link = linkTextOf(next) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EILSEQ') { + throw new GitMetadataError( + `[Sandbox] ${next} is a symlink whose target is not valid UTF-8; refusing to sandbox with a deny list that would name another path than the one it leads to`, + ) + } + return { + landing: path.join(next, pending.reverse().join('/')), + hops, + resolved: true, + } } if (link === undefined) { current = next @@ -1786,7 +1787,7 @@ function resolveChain( // A link's own target is walked in its place, from the directory holding // it unless it is absolute. if (path.isAbsolute(link)) current = path.parse(link).root - pending = [...link.split('/'), ...pending] + for (const part of link.split('/').reverse()) pending.push(part) } return { landing: current, hops, resolved: true } } diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts index c6fd38d4e..745f8d301 100644 --- a/src/utils/real-path.ts +++ b/src/utils/real-path.ts @@ -19,6 +19,11 @@ function textOf(ask: typeof fs.readlinkSync, at: string): string { return text } +/** Where the link at `at` points. EILSEQ where that is no text. */ +export function linkTextOf(at: string): string { + return textOf(fs.readlinkSync, at) +} + /** * `p` with every symbolic link on the way to it resolved. The real path is * the kernel's on every runtime, whatever characters its names hold. @@ -112,7 +117,7 @@ export function wayTo(folded: string): { { code: 'ELOOP', syscall: 'realpath', path: folded }, ) } - const target = textOf(fs.readlinkSync, next) + const target = linkTextOf(next) if (target.startsWith('/')) real = '' rest.unshift(...target.split('/')) } diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 8fec587b1..1dbef314e 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -222,6 +222,27 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }) }) + it.if(!isWindows && process.getuid?.() !== 0)( + 'fails closed where a link it cannot look behind really leads', + () => { + // `..` after a link is the parent of where the link leads, not of the + // directory that holds it. + const elsewhere = join(dir, 'elsewhere') + const locked = join(elsewhere, 'locked') + mkdirSync(join(elsewhere, 'deep'), { recursive: true }) + mkdirSync(join(locked, 'inner'), { recursive: true }) + mkdirSync(modules) + symlinkSync(join(elsewhere, 'deep'), join(modules, 'via')) + symlinkSync('via/../locked/inner', join(modules, 'x')) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + it.if(!isWindows)( 'does not follow a link that is one of those directories', () => { diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index de24a5e52..c7a6bcc1e 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -18,6 +18,7 @@ import { LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' import { + GitMetadataError, SubmoduleWalkBudgetError, gitDirDenies, gitDirTreeDenies, @@ -509,6 +510,61 @@ describe.if(!isWindows)( 120000, ) + it('walks a value of as many components as its file can hold', () => { + // Half a million, which is more than one call takes as arguments: to a + // git directory that is there, and past a component that is not. + const gitDir = makeGitDir(join(dir, 'store')) + mkdirSync(join(dir, 'checkout')) + const pointer = join(dir, 'checkout', '.git') + const deadline = Date.now() + 60000 + + writeFileSync(pointer, `gitdir: ${'./'.repeat(500000)}../store\n`) + expect(gitFileDenies(pointer, false, deadline).gitDirs).toEqual([gitDir]) + + writeFileSync(pointer, `gitdir: absent/${'../'.repeat(300000)}\n`) + expect(gitFileDenies(pointer, false, deadline).gitDirs).toEqual([]) + }) + + it('takes what lies past a component that is not there as it is written', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync('../absent/a/b', join(gitDir, 'commondir')) + + expect(gitDirDenies(gitDir, false).denyPaths).toContain( + join(dir, 'repo', 'absent', 'a', 'b'), + ) + }) + + describe('a link whose target is no text', () => { + // A string made of those bytes names another path, or none. + const NO_TEXT = Buffer.from([0x2e, 0x2e, 0x2f, 0xff, 0xfe]) + + it('refuses, where it is an entry of a git directory', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync(NO_TEXT, join(gitDir, 'commondir')) + + expect(() => gitDirDenies(gitDir, false)).toThrow(GitMetadataError) + }) + + it.if(isLinux)('refuses the wrap with a code of its own', async () => { + // Left to bubblewrap, which cannot resolve the mount point either, the + // command would be handed out and never start. + const project = join(dir, 'project') + const gitDir = makeGitDir(join(project, 'nested', '.git')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(NO_TEXT, join(gitDir, 'hooks')) + process.chdir(project) + + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [project], denyWithinAllow: [] }, + }) + + expect(refusal?.code).toBe('deny_git_metadata_unreadable') + expect(refusal?.message).toContain(join(gitDir, 'hooks')) + }) + }) + it('resolves an ordinary chain exactly as it always has', () => { // hooks -> ../first -> second -> .githooks: three links, short targets. const gitDir = makeGitDir(join(dir, 'repo', '.git')) From 0a23ab66b65963a7628e2326648a6218b3085e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:10:40 +0000 Subject: [PATCH 131/141] small fixes from the review, and a monitor fallback that follows nothing Lock-outs a sandboxed command could plant: - An entry linked at a write root that is a file, /dev/null among the default ones, refused every later command: the guard against denying the work tree whole listed it, and took ENOTDIR for a tree it could not read. A file holds no tree, and the deny stands. - A directory under `.git/worktrees` made unreadable refused every later command over the `commondir` in it. It is denied whole, as the README said. Fail-open or wrong: - A `.git` file that could not be read for any reason at all was denied alone. Only what the host's git cannot read either is passed over now. Out of descriptors, or a disk that fails, refuses. - `gitdir: ` followed by a NUL: git joins the empty path to the directory that holds the file and opens that. So does this now. - `ripgrep.timeoutMs` above what a timer takes was taken for one millisecond. The schema refuses it. The violation monitor's fallback, for a repository that cannot be enumerated, looked at the entries of `.git` again and could fail again. It is path arithmetic on the shape of `.git` now. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- src/sandbox/linux-sandbox-utils.ts | 53 ++++-------- src/sandbox/mandatory-deny-paths.ts | 35 ++++++-- src/sandbox/sandbox-config.ts | 2 + test/config-validation.test.ts | 9 ++ test/sandbox/git-pointer-parity.test.ts | 11 +++ test/sandbox/linux-work-tree-guard.test.ts | 17 ++++ test/sandbox/mandatory-deny-worktrees.test.ts | 82 +++++++++++++++++++ 7 files changed, 161 insertions(+), 48 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index b290c4522..92974473a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -54,7 +54,6 @@ import { import { GitMetadataError, SubmoduleWalkBudgetError, - gitDirDenies, gitDirDenyPaths, gitDirTreeDenies, gitDirTreeDenyPaths, @@ -1132,6 +1131,12 @@ function withoutWorkTreeDenies( try { fs.readdirSync(landing) } catch (err) { + // A file holds no tree. It is a write root itself, /dev/null among the + // default ones, that an entry was linked to: the deny stands. + if ((err as NodeJS.ErrnoException).code === 'ENOTDIR') { + kept.push(denyPath) + continue + } const message = `[Sandbox Linux] ${denyPath} could not be listed (${errorText(err)}), ` + `and it holds the working directory or a path this command may ` + @@ -1232,65 +1237,35 @@ export function linuxGetMonitorCwdDenyPaths( } catch (err) { const cwd = process.cwd() logForDebugging( - `[Sandbox Linux] ${monitorFallbackReason(cwd, err)}; the violation monitor judges writes against ${cwd}'s plain deny paths instead.`, + `[Sandbox Linux] The git directories ${cwd} leads to could not be enumerated (${errorText(err)}); the violation monitor judges writes against its plain deny paths instead.`, { level: 'warn' }, ) return [ ...cwdDangerousDenyPaths(cwd), - ...monitorDotGitDenyPaths(cwd, allowGitConfig, allowWritePaths), + ...monitorDotGitDenyPaths(cwd, allowGitConfig), ] } } -/** Why the monitor is falling back, in the terms of the thing that failed: - * the two refusals differ in what a wrapped command in this directory gets. */ -function monitorFallbackReason(cwd: string, err: unknown): string { - if (err instanceof SubmoduleWalkBudgetError) { - return `The walk of this repository's submodule git directories ran out of the time it was given (${errorText(err)}), which a wrap retakes from the clock and may well finish` - } - return `Could not resolve ${path.resolve(cwd, '.git')} the way git does (${errorText(err)}). Every wrapped command in this directory is refused until that is fixed` -} - /** * What the monitor treats as denied under `cwd`'s own `.git`, without - * following anything: the same three answers the wrap gives for the shape - * `.git` turns out to be. Absent is none of them — the wrap denies nothing - * there either, since a mount at `.git` would block `git init`. + * following anything: the answers the wrap gives for the shape `.git` turns out + * to be. Absent is none of them — the wrap denies nothing there either, since a + * mount at `.git` would block `git init`. */ function monitorDotGitDenyPaths( cwd: string, allowGitConfig: boolean, - allowWritePaths: readonly string[], ): string[] { const dotGitPath = path.resolve(cwd, '.git') - let dotGitStat: fs.Stats | undefined try { - dotGitStat = fs.statSync(dotGitPath) + return fs.statSync(dotGitPath).isDirectory() + ? gitDirDenyPaths(dotGitPath, allowGitConfig) + : [dotGitPath] } catch (err) { // There and not inspectable: denied whole, as the wrap denies it. return isAbsenceErrno(err) ? [] : [dotGitPath] } - // A pointer file is itself a deny, and what it leads to is exactly what - // could not be followed; a git directory's own hooks and config need - // nothing followed to name. - if (dotGitStat.isFile()) return [dotGitPath] - if (dotGitStat.isDirectory()) { - try { - const denies = gitDirDenies(dotGitPath, allowGitConfig) - return [ - ...withoutChainHopLinks(denies.denyPaths, denies.chainHops), - ...denies.linkedEntryDirs.map(wholeDirDenyLanding), - ...chainHopDenies(denies.chainHops, allowWritePaths).held, - ] - } catch (err) { - // An entry that is a symlink into a chain too long to walk in the time - // there is. The entries' own names need nothing followed, and the - // monitor is not what fails over one repository. - if (!(err instanceof SubmoduleWalkBudgetError)) throw err - return gitDirDenyPaths(dotGitPath, allowGitConfig) - } - } - return [] } /** diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 259cd733b..54d9ff955 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -805,19 +805,26 @@ export function gitFileDenies( gatherCommonDirDenies(gitDir, allowGitConfig, gathered, deadline) } } catch (err) { - if (err instanceof GitMetadataError) throw err - // Out of time is not a pointer that could not be followed: what it names - // was not worked out, and the file alone is no deny for that. - if (err instanceof SubmoduleWalkBudgetError) throw err // A dangling pointer names nothing git would read. A pointer this process - // cannot read is one the host's git cannot read either, so the file - // itself is the whole deny; an unreadable TARGET is denied whole by - // gitDirTargetDenies instead. - if (!isAbsenceError(err)) { + // may not read is one the host's git, the same user's, cannot read either. + // For those the file itself is the whole deny; an unreadable TARGET is + // denied whole by gitDirTargetDenies instead. + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'EACCES' || code === 'EPERM' || code === 'ELOOP') { logForDebugging( - `[Sandbox] Could not follow ${gitFile}, denying only the file itself: ${err}`, + `[Sandbox] Could not read ${gitFile}, denying only the file itself: ${err}`, { level: 'warn' }, ) + } else if ( + err instanceof GitMetadataError || + err instanceof SubmoduleWalkBudgetError + ) { + throw err + } else if (!isAbsenceError(err)) { + // No descriptor left, a disk that fails: git may well read it later. + throw new GitMetadataError( + `[Sandbox] ${gitFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, + ) } } return denies @@ -1117,6 +1124,14 @@ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { // there yet, as the `.git/modules` walk denies one. const worktree = found.kind === 'dangling' ? found.landing : child if (isVisited(worktree, walk)) continue + // Denied whole where it cannot be listed, and git, the same user's, reads + // no `commondir` in there either: nothing to refuse the command over. + if ( + found.kind === 'directory' && + listDirectory(worktree, walk) === undefined + ) { + continue + } const own = gitDirDenies( worktree, allowGitConfig, @@ -1617,6 +1632,8 @@ function gitMetadataPath(contents: Buffer, file: string): string | undefined { end-- } const nul = contents.subarray(0, end).indexOf(0) + // git joins the empty string to the directory it reads the path from. + if (nul === 0) return '.' const pathBytes = contents.subarray(0, nul === -1 ? end : nul) if (pathBytes.length === 0) return undefined diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index f264da30c..003894ca8 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1019,6 +1019,8 @@ export const RipgrepConfigSchema = z.object({ .number() .int() .positive() + // A timer takes no more, and takes one millisecond for anything above. + .max(2 ** 31 - 1) .optional() .describe( 'How long one ripgrep run may take before it is killed, in milliseconds ' + diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index c0f353325..744613ad9 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach, spyOn } from 'bun:test' import { NetworkConfigSchema, + RipgrepConfigSchema, SandboxRuntimeConfigSchema, WindowsConfigSchema, } from '../src/sandbox/sandbox-config.js' @@ -320,6 +321,14 @@ describe('Config Validation', () => { } }) + test('should take no ripgrep timeout that a timer cannot', () => { + const withTimeout = (timeoutMs: number) => + RipgrepConfigSchema.safeParse({ command: 'rg', timeoutMs }).success + + expect(withTimeout(2 ** 31 - 1)).toBe(true) + expect(withTimeout(2 ** 31)).toBe(false) + }) + test('should validate config with custom ripgrep command and args', () => { const config = { network: { diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 518b30156..93acdc179 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -734,6 +734,17 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect(denyPaths).not.toContain(checkout) }) + it('opens the directory holding a pointer whose path ends at once', () => { + // The file is long enough for git to take it for a pointer, and the path in + // it is a C string of no length, which git joins to where the file is. + const checkout = makeGitDir(join(root, `pointer-${caseCount++}`)) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: \0elsewhere\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBe(checkout) + expect(gitFileDenyPaths(pointer, false)).toContain(join(checkout, 'hooks')) + }) + it('runs a hook again as soon as a looping hooks link reaches a directory', () => { // The other half of what a loop means, against real git: while // `.git/hooks` runs into one, git finds no hook there and commits diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts index c1104435b..c39bbfa12 100644 --- a/test/sandbox/linux-work-tree-guard.test.ts +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -145,6 +145,23 @@ describe.if(isLinux)('a git entry that is a link to the work tree', () => { expect(binds).toContain(nestedGitDir) }) + it('does not refuse for an entry linked at a write root that is a file', async () => { + // /dev/null is among the default ones. It holds no tree that listing it + // could show, and one link would refuse every command from then on. + const checkout = makeCheckout('repo') + const file = join(dir, 'file') + writeFileSync(file, '') + plantNestedRepository(checkout, '/dev/null') + symlinkSync(file, join(checkout, 'nested', '.git', 'config')) + + const binds = readOnlyBinds( + await wrapIn(checkout, 'true', [checkout, '/dev/null', file]), + ) + + // The deny wins: what git reads as its config is not the command's to write. + expect(binds).toContain(file) + }) + it('does not bind a directory that holds the checkout', async () => { const checkout = makeCheckout('repo') // `dir` holds the checkout and is not itself writable here. diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index 9c442df0c..efcc75ca5 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -22,6 +22,7 @@ import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-util import { cleanupBwrapMountPoints, linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { @@ -253,6 +254,42 @@ describe.if(!isWindows)('Linked worktree git directories', () => { } } + it.each([ + { code: 'EACCES', refuses: false }, + { code: 'EMFILE', refuses: true }, + { code: 'EIO', refuses: true }, + ])( + 'goes by why the pointer could not be read ($code)', + ({ code, refuses }) => { + // What this user may not read, the host's git cannot read either. Any + // other failure is this process's, now, and git may well read the file. + const { pointer } = makeLinkedWorktree( + makeGitDir(join(dir, 'main', '.git')), + 'wt', + join(dir, 'wt'), + ) + const real = fs.openSync + const open = spyOn(fs, 'openSync').mockImplementation((( + ...args: Parameters + ) => { + if (args[0] === pointer) + throw Object.assign(new Error(code), { code }) + return real(...args) + }) as typeof fs.openSync) + try { + if (refuses) { + expect(() => gitFileDenies(pointer, false)).toThrow( + GitMetadataError, + ) + } else { + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + } + } finally { + open.mockRestore() + } + }, + ) + it('names the git directories it reached, for what they keep', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const { worktreeGitDir, pointer } = makeLinkedWorktree( @@ -725,6 +762,51 @@ describe.if(!isWindows)('Linked worktree git directories', () => { } }) + it.if(NOT_ROOT)( + "denies whole a worktree's git directory it cannot list, and refuses nothing", + () => { + // One `mkdir` and one `chmod` away for a command, and git reads no + // `commondir` in there either. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + chmodSync(worktreeGitDir, 0o000) + try { + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.unreadableDirs).toEqual([worktreeGitDir]) + expect(tree.worktrees).toEqual([]) + } finally { + chmodSync(worktreeGitDir, 0o755) + } + }, + ) + + it.if(isLinux)( + 'hands the monitor the plain deny paths of a repository it cannot enumerate', + () => { + const checkout = join(dir, 'main') + const mainGitDir = makeGitDir(join(checkout, '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + writeFileSync(join(worktreeGitDir, 'commondir'), Buffer.from([0xff])) + process.chdir(checkout) + + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) + expect(linuxGetMonitorCwdDenyPaths(false)).toEqual( + expect.arrayContaining(gitDirDenyPaths(mainGitDir, false)), + ) + }, + ) + it('refuses rather than hand back a listing that ran out of its time', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) From 0630ac12261ed6e392646ad314355f88d9de5e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:18:11 +0000 Subject: [PATCH 132/141] linux: two slow paths of the collapse that a command could choose the size of - The level the passes start from was counted down to a step at a time. Where the entries a step took are not among what is counted (git directories the command may not write), each step took a few and gave nothing back, and every step goes over the whole deny list. A step that gives nothing back is the last now, as it is for the passes. The function sits with the rest of the arithmetic, where it can be tested by itself. - The warning that says what was degraded resolved each git directory once for every whole-directory deny of the plan. Once each will do. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- src/sandbox/linux-deny-collapse.ts | 41 +++++++++++- src/sandbox/linux-sandbox-utils.ts | 39 ++--------- .../mandatory-deny-time-budget.test.ts | 64 +++++++++++++++++++ 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index b3d55819d..584bd904a 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -275,6 +275,39 @@ export function collapseEverything(plan: SubmoduleDenyPlan): CollapseLevel { } } +/** + * Where the caller's passes start: the level counting alone says a profile with + * room for `mounts` cannot do without, since every deny path that `takesAMount` + * (one inside the write allowlist) takes one of its own, and the ancestor pins + * beneath each are more on top. Without it a repository with ten thousand + * submodules is planned out in full before anything is degraded, which takes + * minutes, and a wrapped command can make such a tree. + * + * It is not the answer, and the caller's measurement is: what it leaves out can + * only mean more collapsing, and the one way it can ask for too much — a deny + * path counted here that another deny turns out to cover — is a mount the + * profile saves rather than one it needs. + */ +export function collapseFloor( + plan: SubmoduleDenyPlan, + mounts: number, + takesAMount: (denyPath: string) => boolean, +): CollapseLevel { + let level = NO_COLLAPSE + let deniesBefore = Number.POSITIVE_INFINITY + for (;;) { + const denies = collapsedDenyPaths(plan, level).filter(takesAMount).length + if (denies <= mounts) return level + // A step that gave nothing back took entries that take no mount, and how + // many more of those lie ahead is a command's to choose. + if (denies >= deniesBefore) return collapseEverything(plan) + deniesBefore = denies + const degraded = collapseFurther(plan, level, denies - mounts) + if (degraded === undefined) return level + level = degraded + } +} + /** How many entries before `from` it takes for `gives` to reach `mounts`, at * least one: a step that degraded nothing would loop for ever. */ function stepBack( @@ -355,9 +388,13 @@ function coveredByWholeDirDeny( ): (candidate: string) => boolean { const covers = plan.repositories .flatMap(repository => repository.wholeDirDenies) - .map(underDirectory) + .map(realPathOrSelf) if (covers.length === 0) return () => false - return candidate => covers.some(under => under(candidate)) + // Resolved once, not once for each of them: both are a command's to multiply. + return candidate => { + const real = realPathOrSelf(candidate) + return covers.some(cover => isAtOrUnder(real, cover)) + } } /** Every submodule git directory the plan may degrade, in the order a diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 92974473a..14935ed75 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -65,6 +65,7 @@ import type { CollapseLevel, SubmoduleDenyPlan } from './linux-deny-collapse.js' import { NO_COLLAPSE, collapseEverything, + collapseFloor, collapseFurther, collapsedDenyPaths, describeCollapse, @@ -3290,7 +3291,11 @@ async function generateFilesystemArgs( normalizePathForSandbox(p, { literal: true }), ) const plan = guardedDenyPlan(found, writeRoots) - let level = collapseFloor(plan, writeRoots, wordsAvailable) + let level = collapseFloor( + plan, + Math.floor(wordsAvailable / BWRAP_WORDS_PER_MOUNT), + denyPath => writeRoots.some(allowed => isAtOrUnder(denyPath, allowed)), + ) // What the pass before this one came to, once there has been one. let wordsBefore: number | undefined for (;;) { @@ -3347,38 +3352,6 @@ async function generateFilesystemArgs( } } -/** - * Where the passes above start: the level counting alone says the profile - * cannot do without, since every deny path inside the write allowlist takes - * a mount of its own and the ancestor pins beneath each are more on top. - * Without it a repository with ten thousand submodules is planned out in full - * before anything is degraded, and a wrapped command can make such a tree: - * what one costs every command after it is not free to leave at whatever it - * comes to. - * - * It is not the answer, and the measurement above is: what it leaves out can - * only mean more collapsing, and the one way it can ask for too much — a - * deny path counted here that another deny turns out to cover — is a mount - * the profile saves rather than one it needs. - */ -function collapseFloor( - plan: SubmoduleDenyPlan, - writeRoots: readonly string[], - wordsAvailable: number, -): CollapseLevel { - const mounts = Math.floor(wordsAvailable / BWRAP_WORDS_PER_MOUNT) - let level = NO_COLLAPSE - for (;;) { - const denies = collapsedDenyPaths(plan, level).filter(denyPath => - writeRoots.some(allowedPath => isAtOrUnder(denyPath, allowedPath)), - ).length - if (denies <= mounts) return level - const degraded = collapseFurther(plan, level, denies - mounts) - if (degraded === undefined) return level - level = degraded - } -} - /** * The mounts for one deny list: `mandatoryDenyPaths` are the mandatory denies * as {@link generateFilesystemArgs} has settled them, and the caller's own diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index c7a6bcc1e..9bfaef7bf 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -28,7 +28,9 @@ import type { GitDirDenies } from '../../src/sandbox/mandatory-deny-paths.js' import { NO_COLLAPSE, collapseEverything, + collapseFloor, collapseFurther, + describeCollapse, } from '../../src/sandbox/linux-deny-collapse.js' import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' @@ -237,6 +239,68 @@ describe.if(isLinux)('Passes over a profile that does not fit', () => { }) }) + it('does not count its way through entries that take no mount', () => { + // A thousand submodule git directories the command may not write, and + // eleven deny paths it may where ten fit. Nothing that can be degraded gives + // a mount back, and a step takes as few entries as would make up for one. + const modulesDir = join(dir, 'elsewhere', '.git', 'modules') + const gitDirs = Array.from({ length: 1000 }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: [join(modulesDir, `s${i}`, 'hooks')], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })) + const plan: SubmoduleDenyPlan = { + denyPaths: [ + ...Array.from({ length: 11 }, (_unused, i) => join(dir, `deny${i}`)), + ...gitDirs.flatMap(each => each.denyPaths), + ], + repositories: [{ modulesDir, gitDirs, wholeDirDenies: [] }], + chainHops: [], + } + let asked = 0 + + const level = collapseFloor(plan, 10, denyPath => { + asked++ + return !denyPath.startsWith(modulesDir) + }) + + expect(level).toEqual(collapseEverything(plan)) + expect(asked).toBeLessThanOrEqual(2 * plan.denyPaths.length) + }) + + it('resolves each path once to say what was degraded', () => { + // Both numbers are a command's to choose, and their product is minutes. + const modulesDir = join(dir, 'repo', '.git', 'modules') + const gitDirs = Array.from({ length: 200 }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: [join(modulesDir, `s${i}`, 'hooks')], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })) + const wholeDirDenies = Array.from({ length: 50 }, (_unused, i) => + join(modulesDir, `whole${i}`), + ) + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirs.flatMap(each => each.denyPaths), + repositories: [{ modulesDir, gitDirs, wholeDirDenies }], + chainHops: [], + } + const resolve = spyOn(fs.realpathSync, 'native') + try { + expect(describeCollapse(plan, collapseEverything(plan))).toContain( + '200 of the 200 submodule git directories', + ) + expect(resolve.mock.calls.length).toBeLessThanOrEqual( + 2 * (gitDirs.length + wholeDirDenies.length), + ) + } finally { + resolve.mockRestore() + } + }) + it('still stops at the first level that fits where stepping does give words back', async () => { // A hundred submodule git directories the command MAY write, in a budget // that holds some of them precise and not all: each one degraded gives From a56c5e02291bafd3520689834e7aacba539e0d3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:19:53 +0000 Subject: [PATCH 133/141] README: the mandatory git denies in about half the words What is denied, where, what it costs, which refusals a command can plant and what stays open. The reasoning behind each call is in the source, next to the code that makes it. Three statements were wrong and are corrected: `config.worktree` is read on top of `config`, not instead of it. A `denyWrite` entry does not lift `deny_unresolvable_git_entry`, which the refusal's own message said too. A directory made unreadable does refuse the command where it is the working directory, or holds it or a write root. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- README.md | 85 +++++++++++++----------------- src/sandbox/linux-sandbox-utils.ts | 2 +- 2 files changed, 37 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2e56d490d..cd53e67b2 100644 --- a/README.md +++ b/README.md @@ -1184,30 +1184,26 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Which git directories are covered.** -- `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). -- For every linked worktree a repository keeps, `commondir`, `gitdir` and `config.worktree` inside `.git/worktrees//` are denied as well, whether or not that worktree's checkout is anywhere the command can write: rewriting `commondir` there redirects the hooks and config the host's git uses in that other worktree, and what an existing `commondir` names is denied with it. A submodule keeps linked worktrees under its own git directory (`.git/modules//worktrees//`) and a linked worktree keeps submodules under its own (`.git/worktrees//modules//`): each is listed with what it keeps in turn. All of this is listed for the working directory's own repository and, on Linux, for each nested repository the scan finds. A git directory reached through a `.git` file is listed the same way, with the one its `commondir` names: a session whose working directory is a linked worktree, and which may write the repository's git directory in order to commit, has that repository's submodules and its sibling worktrees denied too. -- A directory is a git directory by what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. A directory a pointer or a `commondir` names is also one by where it is: `.git/worktrees/`, and anything under a `.git/modules`, is one whatever it holds. Where it is is judged on the path the kernel lands on, so a symlinked `.git` cannot lend that shape to an ordinary directory. -- An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config it leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory it names but that does not exist yet; one naming a directory that is not a git directory is not followed. -- A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does — and git opens that one: run against real git, `gitdir: hop/../evil` with `hop` a symlink opens the directory the kernel reaches, and a checkout whose pointer is a git directory only where the path folds is refused outright. Every symlink the path goes through is denied as well; see **A chain is held hop by hop** below. -- A directory a pointer or a `commondir` names that this cannot read is denied whole instead — git, running as the same user, cannot read through it either — and so is a directory under `.git/modules` the submodule walk cannot see through. A whole-directory deny leaves everything beneath it read-only inside the sandbox, a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Two things produce one under `.git/modules`: a directory the walk cannot list, and an entry whose target is there and cannot be inspected. What is denied is the deepest directory that can be reached towards it — towards the TARGET for an entry that is a symlink, not the directory holding it, which would be every submodule beside it — and that can be `.git/modules` itself where the walk cannot get past it. An entry that is a symlink loop reaches no directory at all, so nothing is denied for it: there is nothing behind it to protect, and a bind cannot be put at the link's own location either, since mounting there means resolving it. A pointer or a `commondir` whose VALUE reaches nothing is not denied whole anywhere either — neither the directory it would fold to nor the deepest one still reachable towards it, which for a value like `gitdir: a/gd` with `a` a loop in the checkout is the checkout itself. git opens nothing through such a value, and a `.git` file is something a sandboxed command is deliberately allowed to create, so failing closed there would let one command leave every command after it unable to write the project. What is denied is the pointer file and the links the walk did go through, with one `SRT_DEBUG` warning naming the file. -- **An entry that is itself a symlink is two things, and both are denied.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link: what a write through it reaches is one thing, the link itself — which a command can unlink and leave its own `hooks/` in place of, for the host's git to run before the next command's scan sees anything — is another. Which of the two one deny covers is the backend's: a Linux bind lands on what the path resolves to, while Seatbelt compares its filter against the path the kernel resolved the operation to — which for an unlink or a rename is the link's own path, since the last component is not followed, and for a write through the link is the target. So the target is denied on its own, and on Linux the directory holding the link is denied WHOLE: the git directory for one of its own entries, the directory holding a `.git/modules` entry for one of those. Everything under a whole deny is read-only, so in a repository that has such a link `git status`, `log` and `diff` go on working while `git add`, `git commit` and anything else writing the index or an object fail read-only until the link is gone; on macOS nothing is denied whole and git keeps working, the entry's own deny being what holds the link there. A link whose target is **not there yet** is no exception: where it lands is denied as a git directory that does not exist, so the command cannot create it and leave hooks for the host's git. An entry this cannot classify at all — an `lstat` that fails for anything but absence — is taken for a link, both denies and all. A link that is a loop reaches nothing, so only the whole deny is left to it. -- **`modules` is held as a link, and is no deny path of its own.** A git directory keeps its submodules' git directories under `modules`, each with precise denies of its own, so denying that directory whole would take every one of them read-only. But it is an entry like the others in the one respect that matters: remove the link, put a directory of your own where it was, and every submodule git directory the repository keeps is one nothing denied - which is a hook the host's git runs. So it is held the way a chain hop is, by its own name where a filter matches the name a rename uses, and by the directory holding it - that git directory, denied whole - where a deny resolves. This applies to the top-level `.git/modules` as much as to a submodule's own. **What it costs, and that a command can arrange it:** an ordinary repository has no `.git/modules` at all, and an absent one is deliberately not denied (a mount there would stop `git submodule add`), so one `ln -s` inside the sandbox makes `.git` a git directory with a symlinked entry - and from the next wrap on `git add`, `git commit` and `git checkout -b` fail read-only in that repository, with nothing inside the sandbox able to remove the link. `git status`, `log` and `diff` keep working, and removing the link from outside the sandbox puts it back. That is the same family as the other costs a command can plant, and it is the price of holding the link at all. -- **A chain is held hop by hop, not just at its two ends.** Neither end of a chain need point straight at the other. An entry can go through a link on the way: `hooks -> ../hooks-link` with `hooks-link -> .githooks`, or `hooks -> ../shared/hooks` with `shared` a symlinked directory. So can the VALUE of a `gitdir:` pointer or a `commondir`, which is a path git walks as the kernel does: `gitdir: hop/../evil`, or a git directory reached through a symlinked component. The file's or the entry's deny holds that end and the landing's deny holds the other; a link BETWEEN them is held by neither, and a command that retargets it — or renames it aside and puts its own directory there — moves where the chain resolves without touching either end, for the host's git to follow. So every hop is held as the entry or the pointer file is: its own path is denied, which is what holds it where a filter matches the name a rename or an unlink uses, and on Linux the directory HOLDING it is denied whole, which is what holds it where a deny resolves. On macOS a chain needs one thing more. A deny path whose own spelling goes through a link — everything under the git directory `gitdir: links/shared/gd` names, and everything under a submodule reached through a symlinked `.git/modules` entry — is compared against a resolved path it can never equal, so it holds the link's own name and nothing beneath it. Every path the git denies were read off disk is therefore named twice there, as it was written and at the landing the kernel walks it to, absent tails included; the two are the same string, and one entry, wherever no link is involved. A HOP is the exception and keeps its own name alone: what a hop leads to is a whole directory nothing asked to deny, so denying it would take everything beside the git directory with it, and the link's own name is the whole handle on one — the mirror image of Linux dropping a hop link from its binds. What lies past the hop is the git directory, whose own deny paths carry both spellings. A hop inside the git directory, or inside the directory holding a `.git/modules` entry, needs no directory of its own: the whole deny that entry already brings covers it. One that no allowed write path contains is read-only anyway. The chain is walked as the kernel walks it, so what a hop cannot be read past leaves the git directory denied whole, as an entry that cannot be classified does, and the hops before it are held all the same. **A chain that reaches NOTHING is held by its hops alone.** A loop, or a chain longer than the kernel follows, has no landing: git gives up on it exactly as this does, so the deny the landing would get stands on nothing, and every link the walk visited before it gave up is the whole of what a command can move — put a directory in one of their places and the chain reaches it, and the host's git reads the hooks or config inside (tested against real git). Each is therefore denied like any other hop, named once however many times the loop met it. A loop that stays inside the git directory needs nothing more: the whole-directory deny that a symlinked entry already brings covers its links. One that runs out through the work tree is held by the directories holding those links — and where THAT cannot be done, because such a directory is the working directory or a write root, Linux **refuses the command** (`deny_unresolvable_git_entry`), since the warning that stands in for a bind elsewhere leans on both ends of the chain being denied and here there is no far end. That refusal is what such a repository got before any of this existed, when the deny path simply could not be resolved. A link ABOVE the entry or the pointer file is not a hop of the chain at all: a checkout reached through a symlink is where the repository lives, and the walk starts where the kernel already is. -- **Known limit: a hop the working directory or a write root holds.** `.git/hooks -> ../hooks-link` with `hooks-link` in the repository root is the common shape of the above — and so is a pointer whose value goes through a link in the checkout itself, where the directory holding the hop is the working directory by construction — and there is nothing to bind over the directory holding it: a read-only bind of the working directory, or of a write root, takes the whole tree the sandbox exists to let the command write. Such a layout is one the caller had before this library saw it, so the command is not refused for it either — as long as the chain reaches something, which is what leaves both its ends denied; a chain that reaches nothing through such a hop is refused instead (see above), unless it is a pointer's value, where the refusal would be the lockout it exists to avoid and the warning is the whole answer. Instead **one `SRT_DEBUG` warning per wrap** names the entry (or the pointer file, and what it names) and the hop and says it can be retargeted from inside the sandbox; both ends of the chain stay denied. A `denyWrite` entry naming the directory holding the link closes it, at the cost of that directory; moving the link inside the git directory, or spelling the pointer's path without it, closes it outright. Restoring the link after the command was considered and not done: a hop the command replaced with a directory cannot be put back by a symlink call, so a restore means removing what a command wrote inside the caller's own work tree; `cleanupAfterCommand` is the embedder's to call; and nothing of it closes the window while the command runs. -- The `.git/modules` walk has no depth bound: a submodule's name is its path and submodules nest, so how deep a tree goes is a question about the repository, and a submodule a hundred levels down has its hooks and config denied like any other. Each directory is visited once, keyed by where it really is, so a symlinked entry pointing back into the tree ends that branch rather than looping. What bounds the walk is time — as long as the `ripgrep` scan gets, since a tree that takes longer than that to walk is one the command about to run could have made — and running out of it refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on the submodules the walk did reach. The same budget bounds the listing of `.git/worktrees`, the walk of a symlink chain a git directory entry leads into, and the walk of the path a `.git` pointer or a `commondir` names: a link target runs to 4095 bytes and a pointer file to 1 MiB, and every component of either is an `lstat`. The `.git` pointer files one scan lists share a single such budget between them. The budgets add up rather than overlap: at worst a wrap spends one on the working directory's own repository before the scan, the scan's own timeout, and then one each on the walk after a scan that could not read a directory, the git-directory walk `allowGitConfig` adds, all the pointer files together, and every nested repository found. The clock is looked at as the entries go by, so one directory a command filled with entries is stopped in the middle rather than at the end of it, and a walk made after the scan takes a budget of its own of the same length rather than what a slow scan left. -- A directory that looks like a git directory does not end the `.git/modules` walk. What makes it look like one is the name of a single entry, and a segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, so a `HEAD` left in `vendor` would otherwise take the denies off `vendor/lib` from the next command on. The directory is denied as a git directory and what else it holds is walked as well, its `modules` like any other directory: that is where a git directory keeps its own submodules, and may as well be the submodule `vendor/modules`. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and one of those is walked after all where that listing shows a `config` or a `hooks`, which a submodule named `vendor/lfs` has and git's own directory of that name does not. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. -- **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A profile takes three of bubblewrap's 9000 words per mount. A repository with hundreds of submodules asks for more mounts than that — four denies and an ancestor pin for each submodule git directory, and for a checked-out one its `.git` pointer file and the pin of the directory holding it as well — and the ceiling is not this library's to lift. Refusing every command in such a repository is the wrong answer to it, so the wrap builds the profile, counts what bubblewrap will actually parse, and while that is past the cap degrades the submodule denies a step at a time and builds again, fail-closed and only as far as the count says it has to be. The working directory's own repository is served first and the nested repositories the scan found after it, in sorted order, so what is degraded is taken from the end: - - 1. precise denies (`hooks`, `config`, `commondir`, `config.worktree`) while the profile fits; - 2. a submodule git directory that does not fit is denied WHOLE, one read-only bind — git writes inside that submodule fail read-only, its `objects`, `refs` and `index` with them, and nothing outside it changes. A checked-out submodule's `.git` pointer names the same git directory, so its copy of those denies goes with them; the pointer file keeps its own deny, which names a file and has nothing to collapse into, and so does what an entry that is a symlink leads to, which lies outside the git directory and no bind of it covers; - 3. where even one bind each does not fit, the enclosing `.git/modules` is denied whole and every submodule really under it goes with it. An entry the walk reached through a symlink can be anywhere, and one whose real path is outside keeps the deny it had: a bind of `modules` does not cover it. - - Linked worktrees' git directories are degraded by the same two steps, before the same repository's submodules: a worktree's git directory bound whole costs cross-worktree housekeeping (`git gc` expiring that worktree's reflog), and `.git/worktrees` bound whole costs `git worktree add`. A step that gives nothing back, because the git directories it took lie outside every write root and the read-only root holds them without a mount, ends the stepping: the next build is the last level, so the number of builds does not grow with how many submodules a repository holds. - - With one write root and no network restrictions, that starts at about 590 submodule git directories, or about 425 where each is checked out. A `SRT_DEBUG` warning says what was degraded, at which level, and for which repositories; there is no other wrap-time channel to report it on. The Linux violation monitor works the same denies out for itself, once for the session and with nothing degraded, so a command that forces a degrade later leaves it naming the path inside a submodule git directory the wrap has by then denied whole — the write is refused either way, and it is the path in the report that is stale, never what bubblewrap enforces. `too_many_arguments` is what is left for a profile that does not fit with every submodule deny degraded: measured at about 1485 CHECKED-OUT submodules, whose pointer files and pins no degrading saves, and at about 495 separate nested repositories, whose own four denies are not submodule denies. Bare submodule git directories do not reach it at any count, multi-segment (`vendor/lib`) and nested names included: the one bind over `.git/modules` is where they end. - -- On Linux, a nested repository is found by the per-command `ripgrep` scan, which never looks inside a `node_modules`: a repository under one is never found and its hooks stay writable. That exclusion is permanent — the scan walks gitignored data as it is, and a `node_modules` is where the cost of that would land. -- On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. Nothing is ever collapsed there, so every submodule keeps its precise denies however many there are. A Seatbelt profile has no cap on entries of its own, but it travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, from the caller's own `spawn` and with no code from this library. +- **What is denied in one:** `hooks/` and `commondir`, and unless `allowGitConfig` is on `config` and `config.worktree`. `commondir` moves the hooks and config git uses to another directory, and `config.worktree` is read on top of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). In a linked worktree's git directory that is reached from the repository that keeps it, it is `commondir`, `gitdir` and `config.worktree`: git reads no hooks and no `config` there while a `commondir` stands. +- **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. +- **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other, with what they keep: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. +- **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. +- **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. +- **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. +- **Time is what bounds the walks.** None has a depth bound, and each directory is visited once, by its real path. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. +- **A directory that looks like a git directory does not end the `.git/modules` walk.** A segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, and a `HEAD` left in it would otherwise take the denies off `vendor/lib`. It is denied as a git directory and what else it holds is walked as well, its `modules` included. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and is walked after all where that listing shows a `config` or a `hooks`. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. +- **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A mount takes three of bubblewrap's 9000 words, and a submodule git directory asks for five mounts, seven where it is checked out. Rather than refuse every command in a repository with hundreds of them, the wrap counts what the profile comes to and, while that is past the cap, degrades from the end: the working directory's own repository is served first, then the nested ones in sorted order, and within a repository the linked worktrees go before the submodules. + + 1. precise denies while the profile fits; + 2. a git directory that does not fit is denied WHOLE, one read-only bind: git writes inside that submodule fail read-only. What leads out of it (a `.git` file that names it, what an entry that is a symlink leads to) keeps its own deny; + 3. where one bind each does not fit, the enclosing `.git/modules` or `.git/worktrees` is denied whole, with everything really under it. + + With one write root and no network restrictions that starts at about 590 submodule git directories, or about 425 where each is checked out. An `SRT_DEBUG` warning says what was degraded. `too_many_arguments` is what is left for a profile that does not fit with everything degraded: measured at about 1485 CHECKED-OUT submodules, whose `.git` files no degrading saves, and at about 495 separate nested repositories. The violation monitor works the same denies out once for the session, with nothing degraded, so the path it reports can be one inside a git directory that is by then denied whole. + +- On Linux, the scan never looks inside a `node_modules`: a repository under one is not found and its hooks stay writable. +- On macOS only the working directory's own `.git` file is followed, and only its own repository is enumerated. A nested repository is matched by pattern, as `.git/` and `.git/modules//`, which covers a single-segment submodule name. Nothing is degraded there. A Seatbelt profile travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, with no code from this library. **The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap makes the mount point itself, once the manifest that names it is published, holding what git concludes with no file there (`.` for `commondir`, nothing for `config.worktree`), and binds it read-only onto itself. @@ -1216,31 +1212,26 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - The git directory can be one a command already running writes. So nothing found there is ever written over, and the file is made only in the directory the wrap looked at, never wherever its path leads by then. Where the name has been taken meanwhile (a link, another file, a FIFO), or the directory swapped for a link, that is left as found and the wrap plans its mounts again on what is there by then. - Where the git directory takes no file from this process (its mode, a read-only or immutable file system, a full disk), it is bound read-only whole instead, and the command can write nothing in it (`git add`, `git commit`). The same goes for one that holds an empty `commondir` a manifest names, which is what bubblewrap makes where the file went from under a command about to start: it goes with the next clean-up. That bind is the only mount the wrap puts inside the directory, since a mount point for another absent entry could not be made there, or could not be removed again. The directory's mode is asked first and does not always tell: where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). -**Refusals a wrapped command can plant (Linux).** The wrap-time refusals below follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well, because every wrap in that working directory hits the same thing. Each names the offending path, or the line the scan printed, in the message, and **each is lifted only by a command run outside the sandbox**: +**Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable` — a `.git` pointer file or a `commondir` whose target cannot be worked out the way git works it out: bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, or one that is there and cannot be read. A symlink whose target is not valid UTF-8 is refused the same way, where it is an entry of a git directory or lies on the way to one. Creating a new `.git` pointer inside an allowed write path is deliberately allowed, so writing `gitdir: ` is one command. On macOS the same condition is a `GitMetadataError`, exported from the package root. -- `deny_unresolvable_git_entry` — an entry of the working directory's OWN repository (`hooks`, `config`, `commondir` or `config.worktree` of the git directory its `.git` is or names) is a symlink whose chain reaches nothing (a loop, or a name no file can occupy), and one of the links it goes through sits in the working directory or a path this command may write, where no read-only bind can hold it. There is no landing to deny instead, so the command is refused rather than run with the link free; the message names the entry, the link and the directory holding it. Repairing the chain, moving the links inside the git directory, or a `denyWrite` entry naming the directory holding them, each lifts it. A pointer's value that reaches nothing is NOT this: see the bullet above. Neither is the same chain behind anything a command may have made (a nested repository the scan found, a submodule's git directory, a `modules` that was not there): those get the warning an unheld hop gets and the command runs, because refusing there would let a few ordinary writes lock every later command out, and would protect nothing the command could not already reach by creating a nested repository outright. The four entries of the working directory's own repository are denied at their own paths from the first wrap that saw it, so a link found at one is the caller's layout; what is left to plant is a repository created and linked this way by one command in a directory that had none. -- `deny_placeholder_unavailable` where a `.git` pointer file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`): the mount point for its `commondir` cannot be written, and the directory cannot be denied whole instead. Making it writable lifts it. -- `deny_scan_failed` where the scan failed for a reason no deny stands in for — an error code other than `ENOENT` or `EACCES`, or none at all. The raw stderr, truncated, is in the message. -- `deny_scan_failed` where a `.git/modules` tree takes longer to walk than the scan's own budget: a tree wide or slow enough is one a command can make, and the walk refuses rather than hand back a listing that stops somewhere unknown. Removing what makes it slow lifts it. On macOS the same condition is a `SubmoduleWalkBudgetError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is an entry of a git directory or lies on the way to one. On macOS a `GitMetadataError`, exported from the package root. +- `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. +- `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). +- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed is the working directory, or holds it or a write root. +- `too_many_arguments`: enough CHECKED-OUT submodules, or nested repositories, that the profile does not fit with everything degraded. -- `too_many_arguments` where a command plants enough CHECKED-OUT submodules — a `.git` pointer file and the git directory it names — that the profile does not fit with every submodule deny degraded, measured at about 1485 of them. +Any other directory a command makes unreadable refuses nothing: it is denied whole. Neither does a wide `.git/modules`: what that costs the commands after it is degraded denies. -A directory the command makes unreadable does **not** refuse anything: it is denied whole, so the next command can neither read it nor open it up again. Neither does a wide `.git/modules`, which is writable inside the sandbox: a command can fill one, and what that costs the commands after it is degraded denies rather than a refusal — git writes inside the submodules that were degraded fail read-only until the entries are removed, which takes a command run outside the sandbox, since what holds them is by then read-only inside it. A symlink planted under a `.git/modules` costs the same way: from the next command on that `.git/modules` is denied whole, so every submodule under it is read-only until the link is removed. - -**No whole-directory deny is ever emitted for the work tree.** Every one of them is a stand-in read off paths a sandboxed command can write - a `.git` pointer naming the checkout (`gitdir: .`, with one entry of the checkout a symlink, makes the checkout a git directory denied whole), a `.git/modules` entry linked at `../..`, a git directory entry pointing at the checkout, a directory left unreadable - and one that lands on the working directory, an ancestor of it, a write root or a directory holding one leaves every later command unable to write the project, with nothing inside the sandbox able to undo it. So: a directory that can be LISTED is dropped with a warning, since what the deny was holding keeps the deny paths of its own two ends; a directory that cannot be listed stands in for a tree nothing else covers, so the command is REFUSED instead (`deny_scan_failed`, naming the directory), which one `chmod` outside the sandbox lifts. Every deny path is judged by where it LANDS, since a bind resolves its destination: an entry that is itself a link to the checkout (`nested/.git/hooks -> ../..`) is dropped the same way, and the git directory around it, denied whole, is what holds the link. What a degrade adds is judged again, and a bind taken back there leaves the precise denies it stood for. The same call is made on macOS, where a literal entry is a subpath deny. A deny that is one of the denied names, standing where it is spelled (`.vscode`, the `hooks` of a git directory), is no stand-in, and on Linux it is kept: only the configuration puts a write root at it or beneath it, and there the deny wins and the write root comes back read-only. On macOS the patterns beneath the working directory hold those names whatever is dropped. - -**A held chain hop is the same family, and the layout need not pre-exist:** a command can create a nested repository whose hooks entry leads through a link in a directory of its choosing (`mkdir -p x/.git; touch x/.git/HEAD; ln -s ../../src/hop x/.git/hooks; ln -s /nowhere src/hop`), and from the next wrap on `src/` is bound read-only for holding the link, which cannot be removed from inside it either. That is a denial of service by code that could as easily have deleted `src/`, and it is why the hop is held rather than the reason not to hold it — but it is not only the cost of a layout the caller already had. An `SRT_DEBUG` line names, for each directory bound this way, the link it is bound for and the entry that is reached through it. +**No whole-directory deny is emitted for the work tree.** Each of them stands in for something read off paths a command can write (`gitdir: .` in a `.git` file, a `.git/modules` entry linked at `../..`, an entry of a git directory linked at the checkout), and one that lands on the working directory, a write root or a directory holding either would leave every later command unable to write the project. Judged by where it lands, such a deny is dropped with a warning where the directory can be listed: what it was holding keeps the deny paths of its own two ends. Where it cannot be listed the command is refused (`deny_scan_failed`). A write root that is a file holds no tree, and a deny that lands on it stands. So does one of the denied names where it is spelled (`.vscode`, the `hooks` of a git directory) with a write root configured at or beneath it: the deny wins. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: - removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; -- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason, and `git worktree prune` (and the prune `git gc` runs) for a STALE worktree entry the repository still keeps, whose `.git/worktrees//` files are denied: it prints one `failed to delete` line, exits 0 all the same, removes what it can of that entry and leaves the rest for the next prune outside the sandbox; a detached `git gc --auto` leaves no `gc.log` behind over it (measured with git 2.55); +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason. `git worktree prune`, and the prune `git gc` runs, prints one `failed to delete` line for a STALE worktree entry, exits 0 and leaves the rest of it for the next prune outside the sandbox; - `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; - from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; - on macOS, `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. On Linux both succeed, since nothing is mounted where no `.git` was when the command was wrapped, and the repository they make is not covered until the next wrap; -- on Linux, every write inside a git directory whose `hooks`, `config`, `commondir` or `config.worktree` is a symlink (`git add`, `git commit`, `git checkout -b`), and every write inside a `.git/modules` one of whose entries is a symlink, because those directories are denied whole — see **Which git directories are covered**; -- on Linux, every write inside a directory that HOLDS a hop of such a chain — an entry's or a `gitdir:` pointer value's — for the same reason: with `hooks -> ../links/hop`, or with `gitdir: links/hop/gd`, the `links` directory is read-only for the command. Only the directory around the link, never what the link leads to. +- on Linux, every write inside a directory that is denied whole because it holds a link: a git directory one of whose entries is a symlink (`git add`, `git commit`, `git checkout -b`), a `.git/modules` one of whose entries is, and with `hooks -> ../links/hop` or `gitdir: links/hop/gd` the `links` directory. See **Which git directories are covered**. **Known limit: a `core.hooksPath` or `include.path` into the work tree.** The denied `config` is never read. Where the repository's own config already sets `core.hooksPath` to a directory in the work tree (husky's `.husky/_`, generated and gitignored), git never looks in `.git/hooks`, and what it does run is ordinary writable content; the same goes for a file an `include.path` names. A sandboxed command cannot create that state, since `config` is denied, so it matters where the caller already has it. @@ -1248,25 +1239,21 @@ A directory the command makes unreadable does **not** refuse anything: it is den **Known limit: sequencer state.** `/rebase-merge/git-rebase-todo` takes `exec` lines that `git rebase --continue` runs, and it stays writable. It needs that specific command run on the host afterwards. -**Known limit: a link that IS the git directory.** The denies above hold a link INSIDE a git directory or a `.git/modules`, `modules` itself included. A `.git` that is itself a symlink to a git directory elsewhere is not one of them: what it points at is denied as any git directory is, and the link itself sits in the working tree, which the sandbox is there to let the command write. Replacing it makes the repository a different one, which the next command's scan covers — but a host git run between the two commands follows the new link. There is nothing to bind over: a mount at the link's own path resolves it, and the only handle above it is the working tree root. +**Known limit: a link that IS the git directory.** What a `.git` that is itself a symlink leads to is denied as any git directory is. The link sits in the work tree, with nothing to hold it by but the work tree's root, so a command can replace it. The next command's scan covers what it then leads to, but a git run on the host between the two follows the new link. -**Known limit: a hook FILE that is a symlink into the work tree.** The install idiom `ln -s ../../scripts/pre-commit .git/hooks/pre-commit`, with `hooks` itself a real directory, is not one of the entries above: the hooks directory is denied, so no name can be added there and none re-linked, but the script the link leads to is ordinary project content, and its CONTENT is writable — directly and through the link — on every build. That is the sandbox doing its job: a tracked script in the work tree is exactly what a command is there to edit, and the sandbox has no way to tell the script the user chose to run as a hook from any other file in the repository. A `denyWrite` entry naming the script is the remedy where that matters. +**Known limit: a hook FILE that is a symlink into the work tree.** With `ln -s ../../scripts/pre-commit .git/hooks/pre-commit` no name can be added to `hooks` and none re-linked, but the script is ordinary project content and stays writable. A `denyWrite` entry naming it is the remedy where that matters. **Known limit: a pre-existing HARD link of a denied file.** A bind protects a path, not an inode, so an append through a second name for `.git/config` elsewhere in the work tree lands. A sandboxed command cannot make such a link to a bound file (the bind is its own mount, and `link(2)` across one fails `EXDEV`), so it has to pre-exist. -**Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. +**Known limit (macOS).** A path matched by pattern is protected where it is: a command may still rename the directory _holding_ it aside and make a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). The literal denies (the working directory's own repository and what it keeps) are held against that. So is everything the scan reached on Linux, where the ancestors of every denied path are pinned (see **Pinned directories** below). **Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` once no running sandbox relies on them — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: - every code `ENOENT`: entries that went away while the scan walked. Nothing is at those paths to deny, and the rest of the listing still counts. -- any code `EACCES`: a directory of the user's own that the scan could not read holds an unknown tree, so it is denied whole, which also keeps its mode from being given back from inside, and the rest of the listing still counts. Another user's is left alone: its mode cannot be given back from inside, and there can be thousands. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and with a time budget of its own of the scan's length, not by reading the paths off stderr. A directory that is readable but not SEARCHABLE (mode 0400) lists its entries and lets nothing through them, so it is that directory, not the child whose own listing failed, that is denied: naming the child would ask bubblewrap for a file mount at what is really a directory and fail every later wrap in the tree. -- anything else, no code at all, or an `EACCES` the walk cannot find a directory for: the command is refused, with the raw stderr (truncated) in the message. - -A directory that sits at the very depth ripgrep stops at, one level past `mandatoryDenySearchDepth`, is a case of its own: ripgrep's walker opens one to list it and reports it when it cannot, while this walk stops a level above, so such an `EACCES` is accounted for with nothing to deny - nothing below it is in the scan's reach on either side. - -Those three read a run that exited on its own. A run that was KILLED is refused whatever its stderr held — the timeout, an OOM kill, a signal to the process group — because its listing stops wherever the kill arrived and nothing on stderr says what lay past that; the message names the signal, and says whether the run had used the time it was given. +- any code `EACCES`: a directory of the user's own that the scan could not read holds an unknown tree, so it is denied whole, and the rest of the listing still counts. Another user's is left alone. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and with a time budget of the scan's length. A directory that can be listed and not searched (mode 0400) is itself the one denied. +- anything else, no code at all, or an `EACCES` the walk finds no directory for: the command is refused, with the raw stderr (truncated) in the message. -A scan that could not be run at all and one that does not finish in time are refused too. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. +That is for a run that exited on its own. One that was killed (the timeout, an OOM kill, any signal) is refused whatever its stderr held, because its listing stops wherever the kill arrived, and so is one that could not be run at all. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 14935ed75..df1a63689 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1196,7 +1196,7 @@ function unresolvableGitEntry( .join('; ') return new LinuxSandboxProfileError( 'deny_unresolvable_git_entry', - `[Sandbox Linux] ${told}. That directory is the working directory or a path this command may write, so the only thing that holds the link - a read-only bind of the directory around it - would take the whole tree with it, and the chain has no far end to deny instead: a command in this sandbox could put a directory in the link's place and leave the entry pointing at hooks or config this profile never saw, for a git run on the host to use. Refusing the command rather than running it with that open. Repairing the chain so it reaches something, moving the links inside the git directory, or a denyWrite entry naming the directory holding them, each closes it.`, + `[Sandbox Linux] ${told}. That directory is the working directory or a path this command may write, so the only thing that holds the link - a read-only bind of the directory around it - would take the whole tree with it, and the chain has no far end to deny instead: a command in this sandbox could put a directory in the link's place and leave the entry pointing at hooks or config this profile never saw, for a git run on the host to use. Refusing the command rather than running it with that open. Repairing the chain so it reaches something, or moving the links inside the git directory, closes it.`, ) } From e43d93dcbe521f28257e860af3e2f22cf1d1293a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:25:58 +0000 Subject: [PATCH 134/141] shorter comments, and two helpers fewer The comments of the new source say what each thing is for and what it guards against, in about 430 lines less. The compiled code is the same but for: - `gitFileDenyPaths`, which only tests called, is gone for `gitFileDenies().denyPaths`; - `repositorySubmodules` and `repositoryWorktrees`, near copies of each other, are one `degradableGitDirs`; - the two pushes of a hop in `gitDirDenies` are one. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- src/sandbox/linux-deny-collapse.ts | 197 ++-- src/sandbox/linux-sandbox-utils.ts | 373 +++----- src/sandbox/mandatory-deny-paths.ts | 842 ++++++------------ test/sandbox/git-pointer-parity.test.ts | 20 +- test/sandbox/mandatory-deny-paths.test.ts | 69 +- test/sandbox/mandatory-deny-worktrees.test.ts | 26 +- 6 files changed, 508 insertions(+), 1019 deletions(-) diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 584bd904a..7344db61c 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -7,30 +7,26 @@ import type { import { isAtOrUnder } from './sandbox-utils.js' /** - * The submodule git directories of one repository, and the `.git/modules` - * directory they live under. Built from {@link GitDirTreeDenies}; the deny - * paths are the ones the plan's list already carries, so collapsing one of - * these drops them from wherever they came from — a checked-out submodule's - * `.git` pointer names the same git directory as the walk does, and the - * pointer's copy of its denies is the same string. + * The git directories one repository keeps under its `.git/modules`, or its + * `.git/worktrees`. Their deny paths are ones the plan's list carries, and + * collapsing one drops them from wherever they came from: a checked-out + * submodule's `.git` file names the same git directory as the walk does, in the + * same strings. */ export interface RepositorySubmodules { modulesDir: string /** Sorted by path, which is the order a collapse works back through. */ gitDirs: Array<{ gitDir: string } & GitDirDenies> /** - * The whole-directory denies UNDER `modulesDir`: what the walk could not - * see through, and the directories holding an entry it followed through a - * symlink. A bind of `modules` covers the ones really under it. The git - * directory's own whole-deny is not one of these: it holds `modules` - * rather than sitting under it, and it is no reason to degrade anything. + * The whole-directory denies UNDER `modulesDir`: what the walk could not see + * through, and the directories holding an entry that is a symlink. A bind of + * `modulesDir` covers the ones really under it. */ wholeDirDenies: string[] /** - * Set where these are not submodules at all but the git directories of the - * repository's linked worktrees, and `modulesDir` its `.git/worktrees`: the - * same shape degraded the same way (see {@link repositoryWorktrees}), and - * named for what it is in the wrap's warning. + * Set where these are linked worktrees' git directories and `modulesDir` is a + * `.git/worktrees`: degraded the same way, and named for what they are in the + * wrap's warning. */ linkedWorktrees?: true } @@ -40,43 +36,29 @@ export interface SubmoduleDenyPlan { /** Every deny path the wrap found, in the order it found them. */ denyPaths: string[] /** - * One per repository whose `.git/modules` the wrap walked and found - * something under, in the order they were served: the working directory's - * own repository first, then the nested ones the scan found, sorted. A - * repository with nothing under its `modules` is not here at all, so an - * absent one is never denied and never costs anything. Each repository's - * `.git/worktrees` follows its `modules` as an entry of its own, under the - * same conditions. + * In the order they were served: the working directory's own repository + * first, then the nested ones, sorted. See {@link degradableGitDirs}. */ repositories: RepositorySubmodules[] /** - * Every symlink a git directory is reached THROUGH that the wrap found — - * between an entry and what it leads to, or in the path a `gitdir:` - * pointer's value walks — in the order it found them. What this backend - * takes from one is the directory holding it, the only thing a bind can - * hold a link by; the link's own path is a deny path for the backend that - * holds a link by its name and is dropped here. Which holders a wrap can - * actually bind depends on where the write roots are, so it is decided - * there rather than here and nothing collapses them: see `chainHopDenies` - * and `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts. + * Every symlink a git directory is reached THROUGH. Which of the directories + * holding one a wrap can bind depends on the write roots, so that is decided + * there and nothing collapses them: `chainHopDenies` in + * src/sandbox/linux-sandbox-utils.ts. */ chainHops: GitChainHop[] /** * The entries of the git directory the working directory's own `.git` is or - * names: `hooks`, `config`, `commondir`, `config.worktree`. Each is denied - * at its own path from the first wrap that saw the repository, so a link - * found at one was there before any command ran: the layout is the user's, - * where any other git directory a wrap finds - a nested repository, a - * submodule's - may be one an earlier command made. Absent for a plan that - * was not built from a working directory. + * names. Each is denied from the first wrap that saw the repository, so a + * link found at one is the user's layout, where any other git directory may + * be one an earlier command made. */ ownEntries?: string[] } /** - * How far the submodule denies have been degraded, counted from the END of - * the order above, so that the repositories served first keep their precise - * denies longest and two wraps of one tree degrade the same entries. + * How far the denies have been degraded, counted from the END of the order + * above, so that two wraps of one tree degrade the same entries. */ export interface CollapseLevel { /** Submodule git directories denied whole, one read-only bind each. */ @@ -91,77 +73,46 @@ export const NO_COLLAPSE: CollapseLevel = { wholeModulesDirs: 0, } -/** The submodule denies of one repository, or undefined where it has none. */ -export function repositorySubmodules( - denies: GitDirTreeDenies, -): RepositorySubmodules | undefined { - // Under `modules` only: a git directory denied whole because one of its OWN - // entries is a symlink is not a submodule deny, and a repository with - // nothing under `modules` must stay out of the plan altogether — an absent - // `.git/modules` is never denied, since a mount point planted at one stops - // `git submodule add` working in that repository. - const wholeDirDenies = [ - ...denies.unreadableDirs, - ...denies.linkedEntryDirs, - ].filter(denyPath => isAtOrUnder(denyPath, denies.modulesDir)) - if (denies.submodules.length === 0 && wholeDirDenies.length === 0) { - return undefined - } - return { - modulesDir: denies.modulesDir, - gitDirs: denies.submodules, - wholeDirDenies, - } -} - /** - * The git directories of one repository's linked worktrees as a plan - * degrades them, or undefined where it has none. + * What of one repository's denies can be degraded: its submodule git + * directories, then its linked worktrees'. A collapse works back from the end, + * so within a repository the worktrees' go first: a command in this working + * directory has less use for another checkout's HEAD and index than for a + * submodule's objects. * - * A `.git/worktrees` is as writable inside the sandbox as a `.git/modules` - * is, so a command can fill one, and each directory in it costs a mount per - * file denied there and a pin. They are degraded in the steps a submodule is - * — the worktree's git directory bound read-only whole, then `worktrees` - * itself — and what a step costs is smaller: the command runs in another - * checkout, so what stops working is git housekeeping that reaches across - * worktrees (`git gc` expiring that worktree's reflog) and, at the last step, - * `git worktree add`. What a worktree's `commondir` names outside it is - * marked as leading out of the directory by the producer, and is kept. + * Neither is here where nothing is under it. An absent `.git/modules` or + * `.git/worktrees` is never denied: a mount point at one stops + * `git submodule add`, or `git worktree add`, in that repository. */ -export function repositoryWorktrees( +export function degradableGitDirs( denies: GitDirTreeDenies, -): RepositorySubmodules | undefined { - // Under `worktrees` only, and nothing at all for a repository that has - // none: an absent `.git/worktrees` is never denied, for the reason an - // absent `.git/modules` is not. - const wholeDirDenies = [ - ...denies.unreadableDirs, - ...denies.linkedEntryDirs, - ].filter(denyPath => isAtOrUnder(denyPath, denies.worktreesDir)) - if (denies.worktrees.length === 0 && wholeDirDenies.length === 0) { - return undefined - } - return { - modulesDir: denies.worktreesDir, - gitDirs: denies.worktrees, - wholeDirDenies, - linkedWorktrees: true, - } +): RepositorySubmodules[] { + const wholeDirDenies = [...denies.unreadableDirs, ...denies.linkedEntryDirs] + return [ + { modulesDir: denies.modulesDir, gitDirs: denies.submodules }, + { + modulesDir: denies.worktreesDir, + gitDirs: denies.worktrees, + linkedWorktrees: true as const, + }, + ] + .map(kept => ({ + ...kept, + // Under it only: a git directory denied whole for an entry of its OWN + // holds `modules` rather than sitting under it. + wholeDirDenies: wholeDirDenies.filter(denyPath => + isAtOrUnder(denyPath, kept.modulesDir), + ), + })) + .filter(kept => kept.gitDirs.length > 0 || kept.wholeDirDenies.length > 0) } /** - * The deny paths of `plan` at `level`. The wrap and the violation monitor - * both go through this, so what the monitor judges a refused write against is - * what the wrap enforced at the level it passes. - * - * A degraded submodule git directory's own deny paths are dropped from the - * list wherever they sit and one read-only bind of the git directory is added - * in their place; a `.git/modules` denied whole takes everything under it the - * same way. Two kinds of deny paths are NOT dropped: a `.git` pointer file's - * own, which names a file and has nothing to collapse into, and the ones the - * producer marked as leading out of the git directory — what an entry that is - * a symlink points at, which no bind of the directory holding the link - * covers. + * The deny paths of `plan` at `level`. A degraded git directory's deny paths + * are dropped from the list and one read-only bind of it is added; a + * `.git/modules` denied whole takes everything under it the same way. Not + * dropped: a `.git` file's own deny, and what the producer marked as leading + * out of the git directory. */ export function collapsedDenyPaths( plan: SubmoduleDenyPlan, @@ -180,11 +131,8 @@ export function collapsedDenyPaths( .map(repository => repository.modulesDir), ) - // Paths a bind above them now covers, and the binds that cover them. The - // binds go at the end: the deny loop's passes are order-independent, so - // where a path sits in the list is nothing to preserve, while leaving the - // rest of the list alone is what makes a level that degrades nothing - // exactly the list the wrap found. + // Paths a bind above them now covers, and the binds. The binds go at the end, + // which leaves the rest of the list as the wrap found it. const covered = new Set() const wholeBinds: string[] = [] for (const repository of plan.repositories) { @@ -219,16 +167,10 @@ export function collapsedDenyPaths( /** * `level` degraded far enough to give back `mountsOver` mounts, or undefined - * where there is nothing left to degrade — which is where the profile is - * refused with `too_many_arguments`. - * - * How much each step gives back is what the caller measures; the arithmetic - * here only decides how many entries one step takes, so that a repository - * with thousands of submodules is not rebuilt once per submodule. A - * submodule git directory gives back one mount per deny path the bind that - * replaces them covers — a deny leading out of it is kept and gives back - * nothing — plus the ancestor pin of the git directory itself, which that - * bind is. + * where nothing is left to degrade, which is where the profile is refused with + * `too_many_arguments`. What a step really gives back is the caller's to + * measure. The arithmetic decides how many entries one step takes, so that a + * repository with thousands of submodules is not rebuilt once for each. */ export function collapseFurther( plan: SubmoduleDenyPlan, @@ -262,11 +204,8 @@ export function collapseFurther( } /** - * The last level: every submodule git directory and every `.git/modules` - * denied whole, past which {@link collapseFurther} has nothing left to take. - * It is where the caller goes once a step has given nothing back, rather than - * on through entries that may all be like the ones it just took: see - * `generateFilesystemArgs` in src/sandbox/linux-sandbox-utils.ts. + * The last level, which is where the caller goes once a step has given nothing + * back. */ export function collapseEverything(plan: SubmoduleDenyPlan): CollapseLevel { return { @@ -325,13 +264,9 @@ function stepBack( } /** - * One line naming what `level` degraded, for the wrap's warning. - * - * What a whole-directory deny of the plan's own already covers is left out of - * it: a submodule under a `.git/modules` denied whole for an entry that is a - * symlink is read-only whole before anything is degraded, so its precise - * denies were never what held it and a collapse of it takes nothing away. - * Saying otherwise names a cost the profile did not pay here. + * One line naming what `level` degraded, for the wrap's warning. Not what a + * whole-directory deny of the plan's own covers already: a collapse of that + * takes nothing away. */ export function describeCollapse( plan: SubmoduleDenyPlan, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index df1a63689..a45a1c6c8 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -68,9 +68,8 @@ import { collapseFloor, collapseFurther, collapsedDenyPaths, + degradableGitDirs, describeCollapse, - repositorySubmodules, - repositoryWorktrees, } from './linux-deny-collapse.js' import type { FsReadRestrictionConfig, @@ -346,27 +345,25 @@ function takesAFileFromHere(dir: string): boolean { /** * See to it that the mount point at `dest`, a file git reads back, is there on - * the HOST and holds what git reads as no redirect (see - * `gitRedirectPlaceholder`). A mount point bubblewrap makes is empty, and git - * refuses to run at all against a `commondir` it cannot read, outside the - * sandbox as well for as long as the command runs. The profile binds the file - * read-only onto itself. + * the HOST and holds what git reads as no redirect (`gitRedirectPlaceholder`). + * One that bubblewrap makes is empty, and git refuses to run at all against + * such a `commondir`, outside the sandbox too for as long as the command runs. + * The profile binds the file read-only onto itself. * * INVARIANT: called only once this wrap's manifest, which names `dest`, is * published and the clean-ups under way then are over. Another wrap that finds - * the file reads the manifests after it has seen it, and takes what none of - * them names for the caller's own. A process killed from here on leaves a file - * a manifest names, which the next clean-up of any process removes. + * the file reads the manifests after it has seen it, and takes what none names + * for the caller's own. A process killed from here on leaves a file a manifest + * names. * - * THREAT: a command already running may be writing the git directory, if it - * made it after its own wrap. So the directory is held by a descriptor that - * followed no link, and nothing there is ever written over: the file is made - * exclusively, or found as another wrap made it. + * THREAT: a command already running may be writing the git directory. So the + * directory is held by a descriptor that followed no link, and nothing there is + * written over: the file is made exclusively, or found as another wrap made it. * * `changed` where the directory or the name is no longer what the plan saw; * `refused` where the directory takes no file from this process after all, or * what a killed wrap left there is no use to git. The caller plans again either - * way. Throws {@link LinuxSandboxProfileError} on anything else. + * way. */ function writeGitRedirectMountPoint( dest: string, @@ -559,13 +556,10 @@ const EACCES = 13 const ENOENT = 2 /** - * Every OS error code a failed ripgrep run mentioned, from the `(os error N)` - * tokens rg appends to each diagnostic. NO PATH IS EVER TAKEN FROM THIS TEXT: - * a directory's name is chosen by whoever created it and lands in stderr - * unescaped, and how a message is worded depends on ripgrep's version and on - * how many threads it has, so any path read out of a line can be a name rather - * than the file rg failed on. A name can only ADD tokens, and an unrecognised - * token refuses the wrap, so the worst a chosen name does is refuse. + * Every OS error code a failed ripgrep run mentioned, from its `(os error N)` + * tokens. NO PATH IS EVER TAKEN FROM THIS TEXT: a directory's name is chosen by + * whoever made it and lands in stderr unescaped. A name can only ADD tokens, + * and an unrecognised one refuses the wrap. */ function ripgrepFailureErrnos(stderr: string): Set { const errnos = new Set() @@ -621,30 +615,19 @@ function walkOutOfTime(cwd: string, dir: string): LinuxSandboxProfileError { } /** - * The directories at and under `cwd` this process cannot read — cannot list at - * all, or holding an entry it cannot type — walked the way the scan walks: - * `readdir` with the kernel's own entry types, symlinks not followed, - * `node_modules` skipped as the scan's globs skip it, no deeper than the scan - * reaches. This is what stands in for parsing paths out of rg's stderr: the - * answer comes off the filesystem, so a directory NAME cannot add to it, aim it - * elsewhere, or empty it. Exported for testing. + * The directories at and under `cwd` this process cannot read, walked the way + * the scan walks: symlinks not followed, `node_modules` skipped, no deeper than + * the scan reaches. It stands in for parsing paths out of rg's stderr, so that + * a directory NAME cannot add to the answer, aim it elsewhere or empty it. + * Exported for testing. * - * `own` are those whose mode is this user's to change. What is in one is not - * known, so the caller denies it whole, which also keeps the command from - * giving the mode back. + * `own` are those whose mode is this user's to change: the caller denies each + * whole, which also keeps the command from giving the mode back. `leftAlone` + * counts the rest, for which there is nothing to deny: another user's, of which + * there can be thousands, and one at the depth ripgrep stops at, which ripgrep + * opens and reports while nothing below it is in the scan's reach. * - * `leftAlone` counts the rest, which is what says the run's EACCES is accounted - * for where there is nothing to deny for it: - * - * - another user's: nobody else's mode can be given back from inside, and - * there can be thousands of those (`/home`), each of them a mount; - * - one that sits at the depth ripgrep stops at, which is opened here without - * being descended into. ripgrep's own walker opens one to list it and reports - * it when it cannot, and nothing below it is in the scan's reach either way. - * - * Throws {@link LinuxSandboxProfileError} when `deadline` passes. The caller - * hands it a budget of its own, of the scan's length, rather than what a slow - * scan left: see `walkDeadline` in `linuxGetMandatoryDenyPaths`. + * Throws {@link LinuxSandboxProfileError} when `deadline` passes. */ export function unreadableDirectories( cwd: string, @@ -672,15 +655,11 @@ export function unreadableDirectories( } catch (err) { // Absent means it went away mid-walk, which leaves nothing to deny. if (isAbsenceErrno(err)) return - // Which directory cannot be seen through is not always this one. A - // directory that is readable but not SEARCHABLE (mode 0400) lists its - // entries and lets nothing through them, so the walk comes here for a - // CHILD whose own readdir failed for want of the parent's search bit - - // and that child is a path this process cannot stat either, so - // recording it would ask bubblewrap for a file mount at what is really - // a directory, failing every later wrap in the tree with an untyped - // error. One lstat tells the two apart: it needs the parent's search - // bit and nothing of this directory's own. + // Not always this one. A directory that can be listed and not SEARCHED + // (mode 0400) sends the walk here for a CHILD, which cannot be stat'ed + // either: recorded, it would ask bubblewrap for a file mount at a + // directory. One lstat tells the two apart: it needs the parent's search + // bit alone. let denied = dir try { fs.lstatSync(dir) @@ -731,12 +710,9 @@ export function unreadableDirectories( /** * The `.git` directories below `cwd` as deep as the scan takes a repository - * from, walked as {@link unreadableDirectories} walks. For a caller whose scan - * cannot go by what a `.git` holds. - * - * A directory that cannot be listed holds none that this finds: ripgrep could - * not list it either and said so, which is where it is accounted for. Throws - * like that walk when `deadline` passes. + * from, walked as {@link unreadableDirectories} walks, for a scan that cannot + * go by what a `.git` holds. One that cannot be listed is accounted for where + * ripgrep reports it. */ function gitDirectoriesBelow( cwd: string, @@ -873,13 +849,7 @@ function cwdMandatoryDenyPlan( return plan } -/** - * Adds one repository's denies to `plan`. What of them a wrap whose profile - * does not fit may degrade is its submodule git directories, then its linked - * worktrees'. A collapse works back from the end, so within a repository the - * worktrees' go first: a command in this working directory has less use for - * another checkout's HEAD and index than for a submodule's objects. - */ +/** Adds one repository's denies to `plan`. */ function addGitDirTree( plan: SubmoduleDenyPlan, denies: GitDirTreeDenies, @@ -890,21 +860,14 @@ function addGitDirTree( plan.denyPaths.push(denyPath) } for (const hop of denies.chainHops) plan.chainHops.push(hop) - plan.repositories.push( - ...[repositorySubmodules(denies), repositoryWorktrees(denies)].filter( - repository => repository !== undefined, - ), - ) + plan.repositories.push(...degradableGitDirs(denies)) } /** - * {@link gitDirTreeDenyPaths} plus the directories that have to be denied - * WHOLE because they hold an entry that is a symlink, and minus the deny - * paths that name a symlink itself. Both follow from the same thing: a bind - * lands on what a deny path resolves to, so the link's own path keeps - * nothing and the directory around it is the handle on it, while a Seatbelt - * filter matches the path as a rename or an unlink names it and needs - * neither (see `gitDirDenies` in src/sandbox/mandatory-deny-paths.ts). + * {@link gitDirTreeDenyPaths} plus the directories denied WHOLE for holding an + * entry that is a symlink, and minus the deny paths that name a symlink itself: + * a bind lands on what a path resolves to, so the directory around a link is + * the only handle on it. */ function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { return [ @@ -914,13 +877,9 @@ function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { } /** - * Where a whole-directory deny LANDS. A bind resolves its destination, so a - * git directory reached through a symlinked `.git/modules` entry is spelled - * inside the git directory and binds whatever the link leads to - the - * checkout itself, where the entry is `ln -s ../.. .git/modules/x`. The - * backend resolves it either way (`resolveSymlinkedDenyPath`), so spelling it - * here changes no mount; what it changes is that - * {@link withoutWorkTreeDenies} sees what the deny would really cover. + * Where a whole-directory deny LANDS. The deny loop resolves it either way; + * spelled here, {@link withoutWorkTreeDenies} sees what it would really cover + * (`ln -s ../.. .git/modules/x`). */ function wholeDirDenyLanding(dir: string): string { return realPathOrSelf(dir) @@ -943,27 +902,14 @@ function withoutChainHopLinks( } /** - * Which of the symlinks a git directory is reached THROUGH — between an entry - * and what it leads to, or in the path a `gitdir:` pointer's value walks — - * this backend can hold, and which it cannot. - * - * A bind cannot be put on a link — the destination resolves — so the - * directory holding one is the whole handle, exactly as the git directory is - * the handle on the entry's own link. `held` is those directories, one - * read-only bind each, charged to the mount budget like any other deny. + * Which of the symlinks a git directory is reached THROUGH this backend can + * hold. `held` is the directories holding one, a read-only bind each. * - * Three kinds are not in it. A hop a whole-directory deny already covers - * names no holder at all. A hop no allowed write path contains is read-only - * from the initial `--ro-bind / /` already, and a bind of the directory - * around it would buy nothing. A hop whose holder IS a write root or the - * working directory, or sits above one, has nothing that can be bound over it - * — the bind would take the whole tree read-only, and a repository laid out - * that way (`.git/hooks -> ../hooks-link`, with `hooks-link` in the - * repository root) is one the caller had before this library saw it, so - * refusing every command in it is the wrong answer too. That last kind comes - * back in `unheld`, for the warning that has to stand in for a bind — or, - * where the chain it lies on reaches nothing so that there is no landing to - * deny either, for the refusal {@link guardedDenyPlan} makes of it. + * Not in it: a hop a whole deny covers already, which names no holder; a hop no + * write root contains, which is read-only anyway; and a hop whose holder is or + * holds the working directory or a write root, where the bind would take the + * whole tree. That last kind is `unheld`, for the warning or the refusal {@link + * guardedDenyPlan} makes of it. */ function chainHopDenies( chainHops: readonly GitChainHop[], @@ -992,16 +938,12 @@ function chainHopDenies( /** * The deny plan a wrap emits: `plan` with the directories holding a chain hop - * this backend can bind added to its deny paths, one warning per wrap for the - * hops it cannot bind - the only channel a wrap has to say so - and without - * any deny that would take the work tree whole. Ordinary trees have no hops - * at all and nothing that covers the work tree, and pay nothing here. + * added, one warning for the hops that cannot be held, and without any deny + * that would take the work tree whole ({@link withoutWorkTreeDenies}). * - * Throws {@link LinuxSandboxProfileError} with `deny_unresolvable_git_entry` - * where an unbindable hop lies on a chain that reaches NOTHING: a warning is - * what stands in for a bind while both ends of the chain are denied, and a - * chain that reaches nothing has no far end to deny. See - * {@link withoutWorkTreeDenies} for the refusal the other guard makes. + * Throws `deny_unresolvable_git_entry` where an unheld hop lies on a chain that + * reaches NOTHING: the warning leans on both ends being denied, and there is no + * far end. */ function guardedDenyPlan( plan: SubmoduleDenyPlan, @@ -1010,17 +952,11 @@ function guardedDenyPlan( const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) const ownEntries = new Set(plan.ownEntries ?? []) const unresolvable = unheld.filter( - // Only for an entry of the working directory's own repository. Each of - // those is denied at its own path from the first wrap on, so a link there - // is the user's layout, and refusing is the answer that leaves nothing - // open. Everything else is exempt, because a command may have made it: a - // `.git` pointer file, a nested repository the scan found, a submodule's - // git directory, a `modules` that was not there. Refusing for one of - // those would let a command lock every later command out of the project - // with a few ordinary writes, and would protect nothing: what it could - // reach by re-pointing a link it is free to write, it can reach by - // creating a nested repository outright, which pre-spawn scanning has - // always missed. + // Only for an entry of the working directory's own repository: each is + // denied from the first wrap on, so a link there is the user's layout. + // Anything else a command may have made, and refusing for it would let a + // few writes lock every later command out, and protect nothing it could not + // reach by creating a nested repository outright. hop => hop.kind === 'entry' && hop.chain === 'unresolvable' && @@ -1072,44 +1008,23 @@ function guardedDenyPlan( /** * `denyPaths` without any that would deny the work tree WHOLE. * - * A whole-directory deny is what this backend falls back on for a tree it - * cannot enumerate or a link it has no other handle on, and every producer of - * one reads paths a sandboxed command can create: a `.git` pointer whose - * value names the checkout (`gitdir: ..`, with one entry of the checkout a - * symlink, makes it a git directory denied whole), a `.git/modules` entry - * linked at `../..`, a git directory entry pointing at the checkout, a - * directory left unreadable. Any of them lands a `--ro-bind` on the working - * directory or a write root, and from the next wrap on nothing in the project - * can be written and nothing inside the sandbox can put it back: a denial of - * service on the session, planted by the very code the sandbox contains. So - * no such deny is emitted, whichever producer asked for it. - * - * What stands in its place depends on whether what is beneath it can be - * known, which is the one thing that distinguishes the two families: + * Every producer of a whole-directory deny reads paths a sandboxed command can + * create (`gitdir: ..`, a `.git/modules` entry linked at `../..`, an entry + * linked at the checkout, a directory left unreadable). One that lands on the + * working directory or a write root leaves nothing in the project writable from + * the next wrap on, with nothing inside the sandbox able to undo it. * - * - A directory this process CAN list was denied whole to hold something - * inside it - a symlinked entry, a link a bind cannot be put on. Both ends - * of what that deny was protecting keep their own deny paths, so the answer - * is the one an unheld chain hop already gets: drop it, and warn once. - * - A directory it cannot list stands in for a tree nothing else covers. - * Dropping it would run the command against an unscanned tree; keeping it - * locks the project silently. The command is REFUSED instead, naming the - * directory, which a `chmod` outside the sandbox puts right. + * - A directory this process CAN list was denied whole to hold something inside + * it, both ends of which keep their own deny paths: dropped, with a warning. + * - One it cannot list stands in for a tree nothing else covers: the command is + * REFUSED, naming it. * - * Each deny path is judged by where it LANDS, which is what a bind resolves - * to, and not by how it is spelled. The producers of a whole-directory deny - * spell theirs that way already (see `linuxGitDirTreeDenyPaths`), but the - * guard cannot rest on that: a git directory entry that is itself a link to - * the checkout (`hooks -> ../..`) is an ordinary deny path, spelled inside - * the git directory, and the deny loop binds the checkout for it all the - * same. Dropping only the landing would leave the entry's own path to put the - * bind back. + * Each is judged by where it LANDS, not by how it is spelled: `hooks -> ../..` + * is an ordinary deny path for which the deny loop would bind the checkout. * - * A deny that is one of the denied NAMES, standing where it is spelled, is no - * such stand-in, and is kept: `.vscode`, or the `hooks` of a git directory. It - * lies below the working directory, and only the configuration puts a write - * root at it or beneath it, which no command can change. There the deny wins, - * and the write root comes back read-only (see meetsAnyAllowedWritePath). + * One of the denied NAMES standing where it is spelled (`.vscode`, the `hooks` + * of a git directory) is no stand-in and is kept: only the configuration puts a + * write root at or beneath it, and there the deny wins. */ function withoutWorkTreeDenies( denyPaths: readonly string[], @@ -1216,18 +1131,11 @@ function cwdDangerousDenyPaths(cwd: string): string[] { /** * {@link linuxGetCwdMandatoryDenyPaths} for the violation monitor, which is - * started once for the session and must not fail over one repository: the - * same paths, or - where the enumeration refuses - this directory's plain - * deny paths, with a warning naming which refusal it was. What this decides - * is which refused write the monitor reports, never what bubblewrap - * enforces. - * - * Nothing is collapsed here. A wrap collapses only where its own profile does - * not fit, which is measured per command, while this is computed once for the - * session: a command that forces a collapse later leaves the monitor naming - * the precise path under a submodule git directory the wrap has by then - * denied whole. The write is refused either way; the path in the report is - * the one inside it. + * started once for the session and must not fail over one repository: where the + * enumeration refuses, this directory's plain deny paths, with a warning. It + * decides which refused write is reported, never what bubblewrap enforces. + * Nothing is collapsed here, so after a wrap that had to, the path in a report + * can be one inside a git directory that is denied whole. */ export function linuxGetMonitorCwdDenyPaths( allowGitConfig: boolean, @@ -1775,14 +1683,9 @@ const BWRAP_MAX_ARGS = 9000 const BWRAP_WORDS_PER_MOUNT = 3 /** - * Words the wrap adds AFTER the mounts, which the mounts must leave room for. - * Eighteen today — `--dev /dev`, `--unshare-pid`, `--unshare-user` and at most - * four capability words, `--bind /proc /proc`, `--unsetenv` and its name, - * `--disable-userns`, `--` with the shell and `-c`, and the one word the - * command itself is — plus the two `--args ` takes when the mounts move - * off the command line, and a little over. Everything - * BEFORE the mounts is counted as it is, not estimated: the wrap has already - * built it when it asks for them. + * Words the wrap adds AFTER the mounts, which the mounts must leave room for: + * eighteen today, two for `--args `, and a little over. What comes BEFORE + * the mounts is counted as it is. */ const BWRAP_TRAILING_WORDS = 24 @@ -1904,13 +1807,9 @@ export type LinuxSandboxProfileErrorCode = /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' /** - * A deny path git reads back — a git directory's `commondir` or - * `config.worktree` — could not be given a stand-in git accepts: neither - * the mount point on the host nor the placeholder in the temporary - * directory could be written. Mounting /dev/null there instead would stop - * git working in that repository altogether, so the command is refused. - * Whatever the wrap did make is named in its manifest and goes with the next - * clean-up. + * A git directory's `commondir` or `config.worktree` could not be given a + * mount point git accepts, and the directory cannot be denied whole instead. + * /dev/null there would stop git working in that repository altogether. */ | 'deny_placeholder_unavailable' /** @@ -1923,27 +1822,18 @@ export type LinuxSandboxProfileErrorCode = */ | 'deny_scan_failed' /** - * A `.git` pointer file or a `commondir` under the working directory names - * a git directory that cannot be worked out the way git works it out: its - * bytes are not valid UTF-8, it is larger than git reads, or it is there - * and could not be read. The hooks and config git runs through it are - * therefore unknown, so the command is refused rather than sandboxed - * without them. A sandboxed command can write such a file, and the - * message names it: the refusal is lifted only from outside the sandbox. + * A `.git` file, a `commondir` or a symlink names a git directory that cannot + * be worked out the way git works it out, so the hooks and config git runs + * through it are unknown. A sandboxed command can write one, and the message + * names it: the refusal is lifted only from outside the sandbox. */ | 'deny_git_metadata_unreadable' /** - * An entry of a git directory under the working directory is a symlink - * whose chain reaches nothing - a loop, or a name no file can occupy - and - * one of the links it goes through sits in the working directory or a path - * this command may write. Such a link is held only by a read-only bind of - * the directory around it, which there would take the whole tree; leaving - * it free lets a command in the sandbox put a directory in its place, which - * makes the chain reach hooks or config this profile never saw and a git - * run on the host afterwards uses. Neither end of the chain stands in for - * it: the entry's own deny is a directory this one denies whole, and the - * landing does not exist. So the command is refused, which is what a - * repository laid out this way got before any of these denies existed. + * An entry of the working directory's own git directory is a symlink whose + * chain reaches nothing, through a link that sits in the working directory or + * a write root. Only a read-only bind of that directory would hold the link, + * and left free a command can put a directory in its place, for the chain to + * reach hooks this profile never saw. */ | 'deny_unresolvable_git_entry' /** More mount points than one manifest holds. */ @@ -3175,18 +3065,10 @@ const MOUNT_PLAN_ATTEMPTS = 3 class MountPlanFailed extends Error {} /** - * `plan`'s deny paths at `level`. A level that degrades nothing is the list - * {@link guardedDenyPlan} already went over. One that does adds binds of its - * own - a submodule's git directory, a `modules` directory - spelled as the - * walk found them, and an entry the walk reached through a link can land - * anywhere, the checkout or another write root included - * (`.git/modules/x -> ../..`). So what a collapse adds goes past the same - * guard before it is emitted. - * - * A bind the guard takes back stood for the precise deny paths the collapse - * had folded into it, and those come back with it: they name entries of that - * directory, not the directory, so they take nothing whole, and without them - * the git directory behind the link would keep no deny at all. + * `plan`'s deny paths at `level`. What a collapse adds is spelled as the walk + * found it, and an entry reached through a link can land anywhere, the checkout + * included, so it goes past the same guard as the rest. A bind the guard takes + * back stood for precise deny paths, which come back with it. */ function collapsedForWrap( plan: SubmoduleDenyPlan, @@ -3223,28 +3105,18 @@ type FilesystemArgs = { * Generate filesystem bind mount arguments for bwrap, inside `wordsAvailable` * of bubblewrap's cap on parsed words. `undefined`, with its manifest let go * of, when what the plan relied on on the host had changed by the time that was - * published: the caller plans again, as it does when this throws - * {@link MountPlanFailed}. - * - * The mandatory-deny scan runs once. What it found is then planned into mounts, - * counted, and — while the count is past what the caller has room for — - * degraded a step at a time and planned again, each pass measuring the profile - * the previous one produced rather than predicting it: the ancestor pins a - * deny needs are known only once the deny list is complete, so the mounts a - * repository's submodules really cost cannot be worked out before this point - * (see src/sandbox/linux-deny-collapse.ts for what a step gives up, and - * `LinuxSandboxProfileErrorCode`'s `too_many_arguments` for what is left when - * there is nothing to degrade). A pass that comes out no shorter than the one - * before it is the last that is stepped to: the next is the last level, so - * the number of passes does not grow with what a repository holds. + * published: the caller plans again, as it does when this throws {@link + * MountPlanFailed}. * - * Only the pass that is settled on is published. A pass that is degraded has - * named nothing, and has made nothing on the host but where the library makes - * a mount point itself, which is kept track of from then on (see - * makeableMountPoint). + * The mandatory-deny scan runs once. What it found is planned into mounts, + * counted and, while the count is past what there is room for, degraded a step + * and planned again (src/sandbox/linux-deny-collapse.ts). Each pass measures + * the one before it: the pins a deny needs are known only once the list is + * complete. A pass no shorter than the one before is the last that is stepped + * to. * - * `gitDirsThatRefusedAFile` is the caller's, for all the plans of one wrap: the - * git directories in which a plan could not write what it had planned to. + * Only the pass that is settled on is published. `gitDirsThatRefusedAFile` is + * the caller's, for all the plans of one wrap. */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -3311,16 +3183,10 @@ async function generateFilesystemArgs( level, Math.ceil((words - wordsAvailable) / BWRAP_WORDS_PER_MOUNT), ) - // A pass no shorter than the one before it took entries that cost this - // profile nothing: submodule git directories no write root contains, - // say, which the read-only root holds without a mount of their own. - // How many more of those lie ahead is a number a command can choose, - // and finding out a step at a time is the whole profile planned once - // per step, so the stepping stops and the next pass is the last level. - // Where the words over are ones no level gives back, that is where the - // steps were going anyway; where entries further on would have given - // some back, it degrades more than they strictly needed, and costs - // what it costs whatever the repository holds. + // A pass no shorter than the one before took entries that cost this + // profile nothing, such as git directories no write root contains. How + // many more lie ahead is a command's to choose, and each step is the + // whole profile planned again, so the next pass is the last level. if ( degraded !== undefined && wordsBefore !== undefined && @@ -3983,15 +3849,10 @@ async function buildFilesystemArgs( // absent (or vanished): not a read-only re-bound directory } // The mount point of a file git reads back is written by this process, - // which cannot write every git directory the command can: a vendored - // checkout, or one the command itself made unwritable, whose mode - // bubblewrap's user namespace overrides for its owner. Refusing every - // command in the tree over that would be a brick a sandboxed command can - // plant, and a mount point left to bubblewrap is an empty file no - // clean-up of this process could remove. So the git directory is denied - // whole, which needs nothing written to it and denies the file and every - // other way into it at once. It joins the list here, ahead of every stub - // decision. + // which cannot write every git directory the command can. Refusing over + // that would be a brick a command can plant, and a mount point left to + // bubblewrap is an empty file this process could never remove. So the git + // directory is denied whole, ahead of every stub decision. const gitDir = path.dirname(resolvedPath) if ( gitRedirectPlaceholder(resolvedPath) !== undefined && diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 54d9ff955..71f03f519 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -10,12 +10,9 @@ import { } from './sandbox-utils.js' /** - * The path is absent, as opposed to unreadable or otherwise unverifiable. - * Narrower than the shared {@link isAbsenceErrno} by two codes: an absent - * path is denied as one a command could still create, and a symlink loop is - * not that — it is walked instead, so that the links the walk went through - * before it gave up are held ({@link resolveChain}) — while ENAMETOOLONG has - * its own answer in {@link isUnusablePathError}. + * Absent, so a command could still create it. Narrower than the shared {@link + * isAbsenceErrno}: a symlink loop is walked instead, for its links to be held, + * and ENAMETOOLONG is {@link isUnusablePathError}. */ function isAbsenceError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException | undefined)?.code @@ -24,32 +21,26 @@ function isAbsenceError(err: unknown): boolean { } /** - * The path is longer than the filesystem allows, so no file can occupy it: - * git's own stat of it fails, and a sandboxed command cannot create a git - * directory there either. + * Longer than the filesystem allows: no file can be there, for git to read or + * for a command to create. */ function isUnusablePathError(err: unknown): boolean { return (err as NodeJS.ErrnoException | undefined)?.code === 'ENAMETOOLONG' } /** - * How much of a `.git` pointer or a `commondir` is read: what git's - * `read_gitfile_gently` accepts for a pointer file, so one larger than this - * is not a pointer git follows either. `commondir` has no bound in git, so - * one this large is read by git and not by this, and fails closed. + * How much of a `.git` file or a `commondir` is read: what git accepts for a + * `.git` file. git has no bound for a `commondir`, so a larger one fails + * closed. */ const MAX_GIT_METADATA_BYTES = 1024 * 1024 -/** Time the `.git/modules` walk gets when the caller sets no deadline, and - * the walk of a symlink chain or a pointer's value likewise. */ +/** What a walk gets where the caller sets no deadline. */ const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS /** - * How often the walk looks at the clock: once per this many entries, rather - * than once per directory, because one directory can hold as many entries as - * the whole rest of the walk. {@link resolveChain} counts path components the - * same way, for the same reason: one link's target, or one pointer's value, - * can hold as many as every other path put together. + * A walk looks at the clock once per this many entries or path components: one + * directory, or one value, can hold as many as all the rest. */ const WALK_DEADLINE_CHECK_INTERVAL = 128 @@ -74,14 +65,9 @@ function looksLikeGitDir(entries: fs.Dirent[]): boolean { } /** - * Directories git itself keeps in a git directory: its objects, its refs and - * their logs, its hooks and `info`, the administrative directories of linked - * worktrees, and the stores git-lfs and rerere put beside them. The - * `.git/modules` walk goes on past a git directory (see - * {@link submoduleGitDirs}) and stays out of these: they are where a - * repository is large, and git keeps no submodule's git directory in any of - * them. `modules` is not one of them because it IS walked, as the place a git - * directory keeps its own submodules. + * Directories git and its helpers keep in a git directory, which is where a + * repository is large. The `.git/modules` walk goes on past a git directory + * ({@link walkModules}) and stays out of these but for one listing. */ const GIT_OWN_DIRECTORIES = new Set([ 'objects', @@ -95,21 +81,13 @@ const GIT_OWN_DIRECTORIES = new Set([ ]) /** - * What tells a submodule's git directory that only has one of those names - * (`vendor/lfs`, `tools/hooks`) from git's own directory of that name: the - * two entries this file protects. Every git directory git makes has a - * `config`, and git puts neither name in a directory of its own, where - * `logs/HEAD` and `lfs/objects` rule the other two markers out. A linked - * worktree or a ref namespace that is itself NAMED one of the two is the - * exception: what holds it is walked for it, which only ever denies more. + * What tells a submodule's git directory that has one of those names + * (`vendor/lfs`) from git's own directory of that name. `logs/HEAD` and + * `lfs/objects` rule the other markers out. */ const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) -/** - * The entries of a git directory that are files: `config`, and the two that - * redirect git to another directory. `hooks` is the fourth entry denied, and a - * directory, which {@link GIT_OWN_DIRECTORIES} has. - */ +/** The entries of a git directory that are denied and are files. */ const GIT_ENTRY_FILES = new Set(['config', 'commondir', 'config.worktree']) /** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ @@ -125,18 +103,12 @@ type GitMetadata = | { kind: 'too-large' } /** - * A git metadata file whose target cannot be worked out the way git works it - * out. Thrown rather than logged, and not swallowed by - * {@link gitFileDenyPaths}: sandboxing with a deny list that misses the hooks - * and config git reads is worse than not sandboxing, which is the same call - * the Linux backend makes for a scan that does not finish in time. - * - * The message names the file, because a sandboxed command can write one and - * the refusal is then lifted only by a command run outside the sandbox. On - * Linux the wrap turns this into a `LinuxSandboxProfileError` carrying - * `deny_git_metadata_unreadable`; on macOS it is the profile generator's own - * refusal and reaches the caller as itself. Branch on `.code`, not on the - * message. + * A git metadata file, or a link, whose target cannot be worked out the way git + * works it out. The wrap is refused: a deny list that misses the hooks and + * config git reads is worse than no sandbox. The message names the file, since + * a sandboxed command can write one and only a command run outside lifts it. On + * Linux it becomes a `LinuxSandboxProfileError` with + * `deny_git_metadata_unreadable`; on macOS it reaches the caller as itself. */ export class GitMetadataError extends Error { readonly code = 'git_metadata_unreadable' as const @@ -147,14 +119,9 @@ export class GitMetadataError extends Error { } /** - * The `.git/modules` walk ran out of the time it was given, so the submodule - * git directories it never reached would have been left with writable hooks — - * or the listing of `.git/worktrees`, which shares that time, did; or the - * walk of a symlink chain or of the path a `.git` pointer names did, which - * spends the same budget, so the git directory it never landed on would have - * been. The Linux wrap turns this into a `LinuxSandboxProfileError` carrying - * `deny_scan_failed`; on macOS it reaches the caller as itself. Branch on - * `.code`, not on the message. + * A walk ran out of the time it was given, so what it never reached would have + * been left writable. On Linux it becomes a `LinuxSandboxProfileError` with + * `deny_scan_failed`; on macOS it reaches the caller as itself. */ export class SubmoduleWalkBudgetError extends Error { readonly code = 'submodule_walk_budget_exhausted' as const @@ -164,15 +131,7 @@ export class SubmoduleWalkBudgetError extends Error { } } -/** - * One symlink a chain walk went through, and the directory holding it. - * - * A bind cannot be put on a link — mounting at its own path resolves it — so - * the directory around it is the only handle the backend whose denies resolve - * has on it, while the backend that matches the name an unlink or a rename - * uses wants the link's own path. Both are here, so each backend can take - * what it holds a link by. - */ +/** One symlink a chain walk went through, and the directory holding it. */ interface ChainHop { /** The link's own path, as the walk reached it. */ link: string @@ -184,69 +143,50 @@ interface ChainHop { * {@link resolveChain}. */ interface ResolvedChain { landing: string - /** In the order the walk met them and each named once: the first is the - * path the caller asked about, the rest are what lies between it and the - * landing. A loop meets the same link over and over; it is one hop. */ + /** + * In the order the walk met them, each once: the first is the path the caller + * asked about. + */ hops: ChainHop[] /** - * Whether the walk ran to the end of the path. False only where it gave up - * past the kernel's own hop limit — a loop, or a chain longer than the - * kernel follows — and `landing` is then just where it stopped, a path - * nothing can be reached through while the chain stands. + * False where the walk gave up past the kernel's hop limit, on a loop or a + * chain too long: `landing` is then only where it stopped. */ resolved: boolean } /** - * A symlink a git directory is reached THROUGH: a second hop of a symlinked - * entry's own link, a symlinked directory component on the way, or a - * component of the path a `gitdir:` pointer or a `commondir` names. - * - * Neither end of such a chain covers one. The entry's or the pointer file's - * deny holds that end and the landing's deny holds the other, while a hop in - * between sits in an ordinary directory of the work tree: a command that - * retargets it, or renames it aside and puts its own directory there, moves - * what the chain resolves to without touching either end, and the host's git - * follows the chain afterwards. + * A symlink a git directory is reached THROUGH: a further hop of a symlinked + * entry, a symlinked directory on the way, or a component of the path a + * `gitdir:` or a `commondir` names. The denies at the two ends of the chain + * cover neither it nor the directory it sits in, and a command that retargets + * it moves where the chain leads, for the host's git to follow. */ export interface GitChainHop { /** - * Where the chain starts: a git directory ENTRY that is itself a symlink, - * or the VALUE of a `gitdir:` pointer or a `commondir`, which git walks the - * same way and a command redirects the same way. Held alike; the warning - * that stands in where one cannot be held says which, because what closes - * it differs. + * Where the chain starts: a git directory ENTRY that is a symlink, or the + * VALUE of a `gitdir:` or a `commondir`. Held alike; the warning differs. */ kind: 'entry' | 'pointer' /** - * Whether the chain this hop lies on reaches anything. `unresolvable` is a - * loop, or a chain past the kernel's hop limit: nothing is behind it, both - * ends of it are held by a deny that stands on nothing, and each link the - * walk DID go through is a name that makes the chain reach a real directory - * as soon as a command puts one in its place. A backend that cannot hold - * such a link has nothing left to fall back on and refuses the command; one - * whose chain resolves has the landing's own denies behind it and warns. + * `unresolvable` is a loop, or a chain past the kernel's limit. Nothing is + * behind it to deny, and each link is a name that makes it reach a directory + * as soon as a command puts one there. A backend that cannot hold such a link + * refuses the command; for a chain that resolves it warns. */ chain: 'resolved' | 'unresolvable' /** The git directory entry, or the metadata file, whose chain goes * through it. */ source: string /** - * The link's own path, and a deny path of its own: that is what holds it - * on the backend which matches the name a rename or an unlink uses. The - * backend whose denies RESOLVE gets nothing from it — a bind at a link - * lands on what the link leads to, which for a symlinked directory - * component is a whole directory nothing asked to deny — and drops it: see - * `linuxGitDirTreeDenyPaths` in src/sandbox/linux-sandbox-utils.ts. + * The link's own path: a deny path for the backend that matches the name a + * rename or an unlink uses (macOS). A bind there would land on what the link + * leads to, so Linux drops it (`withoutChainHopLinks`). */ link: string /** - * The directory holding the link: what that backend has to deny WHOLE to - * hold it, as it denies the entry's own git directory whole. Undefined - * where such a deny already covers the link — a hop inside the git - * directory, or inside the directory holding a `.git/modules` entry. Where - * it is a write root or the working directory there is nothing to bind - * over it; the Linux backend's own note says what happens then. + * The directory holding the link, which Linux denies WHOLE to hold it. + * Undefined where a whole deny covers the link already. */ holder: string | undefined } @@ -254,52 +194,32 @@ export interface GitChainHop { /** Directories found under a `.git/modules`, and what could not be read. */ export interface SubmoduleScan { /** - * The submodule git directories, sorted. An entry the walk followed through - * a symlink is here as the path it was reached by; one whose target is not - * there yet is here as the path that target folds to, which is what a deny - * has to block a command from creating. + * Sorted. One reached through a symlink is here as the path it was reached + * by, and one whose target is not there yet as the path that target folds to. */ gitDirs: string[] /** - * Directories the walk could not see through: what lies under them is - * unknown, so they are denied whole rather than left writable with a git - * directory possibly inside. Two things produce one — a directory the walk - * could not list, and an entry whose target exists and could not be - * inspected — and the recorded path is the deepest ancestor this process can - * reach towards it, which can be the `.git/modules` root itself. Sorted and - * without duplicates, like `gitDirs`. - * - * A whole-directory deny is read-only for everything beneath it, a - * submodule's `objects`, `refs` and `index` included, so git writes inside a - * tree that trips one stop working. + * Directories the walk could not see through, denied whole: one it could not + * list, or the deepest one reachable towards a target it could not look at. + * Everything under one is read-only, so git writes there stop working. + * Sorted, no duplicates. */ unreadableDirs: string[] /** - * Directories holding an entry the walk followed through a SYMLINK, denied - * whole for the link's sake: the deny the target gets covers what a write - * through the link reaches, and the link's own path takes no mount of its - * own (putting one there means resolving it). A `.git/modules` is writable - * inside the sandbox, so a command that leaves the link alone but puts its - * own git directory in its place aims every pointer that named it at hooks - * the deny never saw. Denying the directory that holds it is the only - * handle on that, and it is a whole-directory deny like the above. Sorted - * and without duplicates. + * Directories holding an entry that is a SYMLINK, denied whole for the link's + * sake by the backend whose denies resolve: the target's deny does not stop a + * command from putting a git directory of its own in the link's place. + * Sorted, no duplicates. */ linkedEntryDirs: string[] - /** - * The hops BETWEEN an entry the walk followed through a symlink and what - * it lands on. One inside the directory holding that entry names no holder - * of its own: `linkedEntryDirs` denies that whole already. See - * {@link GitChainHop}. - */ + /** The hops between such an entry and what it lands on. */ chainHops: GitChainHop[] } /** * What a git directory's tree is denied by, in the pieces a backend with a - * ceiling on how many mounts it can carry degrades it in (Linux: - * src/sandbox/linux-deny-collapse.ts). Every piece is a precise deny: nothing - * here is collapsed, on either backend. + * ceiling on its mounts degrades it in (src/sandbox/linux-deny-collapse.ts). + * Nothing here is collapsed. */ export interface GitDirTreeDenies { /** The git directory's own hooks, config and redirect files. */ @@ -317,36 +237,26 @@ export interface GitDirTreeDenies { unreadableDirs: string[] /** Directories holding a symlinked entry; see {@link SubmoduleScan}. */ linkedEntryDirs: string[] - /** Every hop between a symlinked entry of this tree and what it leads to, - * the git directory's own entries, the walked `.git/modules` and - * `.git/worktrees` entries and the git directories under them alike, and - * the hops a worktree's `commondir` value walks. See {@link GitChainHop}. */ + /** Every hop met anywhere in this tree. */ chainHops: GitChainHop[] } /** - * The files inside a git directory that send git to a git directory or a - * config other than the one it opened, and what must stand in for one where - * it does not exist. + * The files in a git directory that send git to another git directory or + * config, and what must stand in for one that does not exist. * - * Denying a path that is not there means mounting something at it, and git - * reads whichever of these two it finds: it refuses to run at all against a - * `commondir` it cannot read, which both an empty file and a bound /dev/null - * are (git rejects a commondir it reads zero bytes from, and a bind mount - * carries nodev, so the device is unreadable). Each placeholder is what git - * concludes with the file absent: `.` makes git resolve the git directory it - * opened as its own common directory, and an empty `config.worktree` reads as - * no worktree config at all. + * Denying an absent path means mounting something at it, and git reads these + * two: it refuses to run at all against a `commondir` it cannot read, which + * both an empty file and a bound /dev/null are. Each placeholder is what git + * concludes with the file absent: `.` is the git directory itself, and an empty + * `config.worktree` is no worktree config. * - * That is not invisible. The file's mere existence sets git's - * `different_commondir`, so `git rev-parse --git-common-dir` and `--git-path` - * print the absolute real path where they printed a relative one, and a - * script that compares `--git-dir` with `--git-common-dir` to decide "this is - * a linked worktree" answers yes for an ordinary repository while the deny - * stands. No content avoids that: the alternative is git refusing to run. + * That is not invisible. The file's existence sets git's `different_commondir`, + * so `git rev-parse --git-common-dir` prints an absolute path, and a script + * that compares it with `--git-dir` takes an ordinary repository for a linked + * worktree while the deny stands. * - * The Linux backend writes them: see `writeGitRedirectMountPoint` in - * src/sandbox/linux-sandbox-utils.ts. + * The Linux backend writes them: `writeGitRedirectMountPoint`. */ const GIT_REDIRECT_FILES: ReadonlyArray<{ name: string @@ -361,15 +271,11 @@ const GIT_REDIRECT_FILES: ReadonlyArray<{ ] /** - * The paths inside a git directory through which a write becomes code the - * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, - * core.hooksPath and the like) unless the caller allows config writes, and - * the redirect files of {@link GIT_REDIRECT_FILES}, each under the same - * condition as the file it redirects. - * - * Path arithmetic alone, so that macOS can hand it a glob pattern in place of - * a directory. {@link gitDirDenies} is what a caller holding a real directory - * wants: the same paths, plus what an entry that is a symlink needs. + * The paths in a git directory through which a write becomes code the host's + * git runs: hooks/, config (core.fsmonitor, core.hooksPath and the like) unless + * the caller allows config writes, and {@link GIT_REDIRECT_FILES}, each under + * the condition of what it redirects. Path arithmetic alone, so that macOS can + * hand it a glob. {@link gitDirDenies} is for a directory that is there. */ export function gitDirDenyPaths( gitDir: string, @@ -388,20 +294,10 @@ export function gitDirDenyPaths( } /** - * The paths inside a linked worktree's git directory, - * `/worktrees/`, that are denied where it is reached from the - * repository that keeps it rather than through the worktree's own `.git` - * file. That file can lie outside everything the command may write — after - * `git worktree add ../wt` it does — while this directory is inside the - * repository's own, and is where the host's git, run in that worktree, starts - * from. - * - * `commondir` names the directory whose hooks and config that git uses, and - * `config.worktree` is config it reads: the files of - * {@link GIT_REDIRECT_FILES}, each under the condition it has there. `gitdir` - * names the worktree's checkout, which `git worktree remove` deletes and - * `git worktree prune` decides by. `hooks` and `config` are not among them: - * git reads neither from here while a `commondir` stands, and that is denied. + * The same for a linked worktree's git directory, `/worktrees/`, + * reached from the repository that keeps it: the redirect files, and `gitdir`, + * which names the checkout that `git worktree remove` deletes. Not `hooks` and + * `config`, which git does not read here while a `commondir` stands. */ function worktreeGitDirDenyPaths( worktreeGitDir: string, @@ -415,79 +311,44 @@ function worktreeGitDirDenyPaths( ] } -/** - * What a git directory is denied by once the entries it holds have been - * looked at, and what of that a read-only bind of the git directory would not - * cover. - */ +/** What a git directory is denied by once its entries have been looked at. */ export interface GitDirDenies { /** The deny paths, in the order the backends emit them. */ denyPaths: string[] /** - * The ones that lead OUT of the git directory: what a symlinked entry - * points at, and an entry this could not classify. A backend that degrades - * a git directory to one read-only bind of it keeps these and drops the - * rest, which that bind covers. + * The ones that lead OUT of the git directory, which a backend that degrades + * it to one read-only bind keeps. */ escapingDenyPaths: string[] /** - * The git directory itself, where an entry of it is a symlink — empty for - * an ordinary one. Only a backend whose denies RESOLVE has to carry it: - * see {@link SubmoduleScan.linkedEntryDirs}, which is the same answer for - * an entry of a `.git/modules`. + * The git directory itself where an entry of it is a symlink, else empty. See + * {@link SubmoduleScan.linkedEntryDirs}. */ linkedEntryDirs: string[] /** - * The hops between a symlinked entry of this git directory and what it - * leads to — and, from {@link gitFileDenies}, the ones in the path a - * pointer's value walks. Each hop's own path is in `denyPaths` already, and - * in `escapingDenyPaths` where no bind of the git directory covers it; what - * is here besides is the directory holding each, which is what the backend - * whose denies resolve has to deny whole to hold the link. See - * {@link GitChainHop}. + * The hops between a symlinked entry and what it leads to and, from {@link + * gitFileDenies}, those in the path a pointer's value walks. Each hop's own + * path is in `denyPaths` too. */ chainHops: GitChainHop[] } /** - * {@link gitDirDenyPaths} for a git directory that is really there, with what - * an entry which is itself a SYMLINK adds to it. - * - * A symlinked entry is two things, and one deny covers one of them: the - * target, where a write through the link lands, and the link itself, which - * sits inside a writable git directory where a command can unlink it and - * leave its own `hooks/` in its place for the host's git to run. Which of the - * two a deny path covers is the backend's: a Linux bind lands on what the - * path resolves to (so the target is covered and the link's own path keeps - * nothing), while a Seatbelt filter matches the path as an unlink or a rename - * names it (so the link is covered and the target is not). Both are therefore - * named — the entry and its target as deny paths, and the git directory in - * `linkedEntryDirs` for the backend that needs the link held by the directory - * around it. Denying a git directory whole leaves `git status`, `log` and - * `diff` working in that repository, while `git add`, `git commit` and - * anything else that writes the index or an object fail read-only until the - * link is gone. - * - * A chain with more than one link in it is more than two things, and the hops - * BETWEEN the entry and the landing are neither of them: the entry's deny - * holds the entry, the landing's deny holds the landing, and a hop in an - * ordinary directory of the work tree moves what the chain resolves to - * without touching either end. Each hop's own path is therefore a deny path - * as well, and `chainHops` carries the directory holding each for the backend - * that needs one — see {@link GitChainHop}. + * {@link gitDirDenyPaths} for a git directory that is there, with what an entry + * that is a SYMLINK adds. * - * `modules` and `worktrees` are checked for being a link as well, though - * neither is a deny path of its own: see the comment at the end of this - * function. + * Such an entry is two things: the target, where a write through it lands, and + * the link, which a command can unlink to leave its own `hooks/` in its place. + * A Linux bind lands on what the path resolves to and covers the first; a + * Seatbelt filter matches the name an unlink uses and covers the second. So + * both are named, and the git directory goes into `linkedEntryDirs` for Linux + * to deny whole: `git status` and `log` go on working there, `git add` and `git + * commit` fail read-only until the link is gone. Further links on the way are + * held as {@link GitChainHop} says. * - * An ordinary git directory costs one lstat per entry and is denied by exactly - * what it always was. `deadline` bounds the walk of the chain an entry that IS - * a symlink leads into (see {@link resolveChain}); past it this throws - * {@link SubmoduleWalkBudgetError}, as the `.git/modules` walk does. - * - * `entryPaths` is what is looked at, for a caller that denies other entries - * of the directory than these: a linked worktree's git directory reached from - * the repository that keeps it ({@link worktreeGitDirDenyPaths}). + * An ordinary git directory costs one lstat per entry. `deadline` bounds the + * walk of a chain ({@link resolveChain}). `entryPaths` is what is looked at, + * for {@link worktreeGitDirDenyPaths}. */ export function gitDirDenies( gitDir: string, @@ -515,21 +376,9 @@ export function gitDirDenies( chain: GitChainHop['chain'], ): void => { for (const hop of hops.slice(1)) { - // A deny path like any other, for the backend that holds a link by its - // own name; the one that resolves its denies drops it again. denyPaths.push(hop.link) - if (insideGitDir(hop.link)) { - chainHops.push({ - kind: 'entry', - chain, - source: entryPath, - link: hop.link, - holder: undefined, - }) - continue - } // No bind of the git directory covers it, so a degrade must not drop it. - escapingDenyPaths.push(hop.link) + if (!insideGitDir(hop.link)) escapingDenyPaths.push(hop.link) chainHops.push({ kind: 'entry', chain, @@ -557,22 +406,17 @@ export function gitDirDenies( holdChain(entryPath, entry.hops, 'resolved') break case 'unreachable': - // A loop, or a name no file can occupy: nothing is behind it to deny - // and nothing a command can create through it WHILE IT REACHES - // NOTHING. The entry's own link is held by the whole-directory deny, - // and every link the walk went through before it gave up is held like - // any other hop: each is a name a command can replace with a real - // directory, which makes the chain reach one and the host's git read - // what is put there. + // A loop, or a name no file can occupy: nothing is behind it to deny. + // The whole deny holds the entry, and each link the walk went through + // is held like any other hop: a directory put in its place makes the + // chain reach one. denyWhole = true holdChain(entryPath, entry.hops, 'unresolvable') break case 'unknown': - // There and not classifiable. Fail closed both ways: the deny the - // entry has anyway, kept through a degrade in case it is a link, and - // the whole-directory deny one would need. The hops behind it that - // COULD be read are held as a resolved chain's are; what lies past - // the one that could not is what the whole deny stands in for. + // There and not classifiable. Fail closed both ways: the entry's own + // deny, kept through a degrade in case it is a link, and the whole deny + // one would need. denyWhole = true denyPaths.push(entryPath) escapingDenyPaths.push(entryPath) @@ -580,16 +424,11 @@ export function gitDirDenies( break } } - // `modules` and `worktrees` are not deny paths - denying one whole would take - // every git directory under it read-only, and each of those has precise - // denies of its own - but each is an entry like the others in the one respect - // that matters here: remove the link, put a directory of your own where it - // was, and every git directory this repository keeps there is one nothing - // denied. So it is held the way a chain hop is, and for the same reason: its - // own name, which is what a rename or an unlink of a link uses, and the - // directory holding it - this git directory - denied whole for the backend - // whose denies resolve, where a bind at the link's own path would land on the - // tree it leads to. + // Neither is a deny path: each git directory under one has precise denies of + // its own. But remove one that is a link, put a directory where it was, and + // every git directory this repository keeps there is one nothing denied. So + // it is held as a chain hop is: by its own name, and by this git directory + // denied whole. for (const name of ['modules', 'worktrees']) { const keptPath = path.join(gitDir, name) const kept = gitDirEntry(keptPath, deadline) @@ -641,11 +480,8 @@ function gitDirEntry(entryPath: string, deadline: number): GitDirEntry { try { stats = fs.lstatSync(entryPath) } catch (err) { - // The shared classification rather than this file's narrower one: a - // component above the entry that is a loop, or a name too long for the - // filesystem, reaches no file here and leaves nothing to protect, while - // the narrower one exists for a `gitdir:` target and answers a different - // question. Anything else is a fact about the entry this cannot read. + // The shared classification: a loop or a name too long ABOVE the entry + // reaches no file here, and leaves nothing to protect. if (isAbsenceErrno(err)) return { kind: 'plain' } logForDebugging( `[Sandbox] Could not tell whether ${entryPath} is a symlink (${err}); denying the git directory holding it whole, and the entry as it stands`, @@ -654,10 +490,8 @@ function gitDirEntry(entryPath: string, deadline: number): GitDirEntry { return { kind: 'unknown', hops: [] } } if (!stats.isSymbolicLink()) return { kind: 'plain' } - // Walked from the directory holding the entry, itself resolved first, so - // that a symlink ABOVE the git directory - a /tmp, a home reached through a - // link - counts as where this repository lives and not as a hop of this - // chain. The landing is what walking the whole path from the root gives. + // From the directory holding the entry, itself resolved first: a symlink + // ABOVE the git directory is where this repository lives, not a hop. const chain = resolveChain( physicalPath(path.parse(entryPath).root, path.dirname(entryPath), deadline), path.basename(entryPath), @@ -685,10 +519,9 @@ function gitDirEntry(entryPath: string, deadline: number): GitDirEntry { } /** - * What must stand in for `denyPath` where it does not exist, or undefined for - * a path git does not read this way. Decided by the basename, so every - * spelling of one path - a tilde, a relative form, a trailing slash, a - * symlinked prefix - answers the same. See {@link GIT_REDIRECT_FILES}. + * What must stand in for `denyPath` where it does not exist, or undefined for a + * path git does not read this way. By the basename, so that every spelling of + * one path answers the same. See {@link GIT_REDIRECT_FILES}. */ export function gitRedirectPlaceholder(denyPath: string): string | undefined { const name = path.basename(denyPath) @@ -696,45 +529,20 @@ export function gitRedirectPlaceholder(denyPath: string): string | undefined { } /** - * Deny paths for a `.git` file, the `gitdir:` pointer of a linked worktree or - * submodule checkout: the file itself plus the hooks/ and config git reads - * through it (the named git directory's, and for a linked worktree its - * commondir's as well). + * What a `.git` file, the `gitdir:` pointer of a linked worktree or submodule + * checkout, is denied by: the file, the entries of the git directory it names + * and, for a linked worktree, of the one its `commondir` names, and every + * symlink the walk of either path went through. A `..` can land the kernel + * elsewhere than where the path folds to, so both directories are denied + * ({@link gitMetadataTargets}). * - * A `..` in either path can land the kernel somewhere other than where the - * path folds to on paper, so both directories are denied — see - * {@link gitMetadataTargets} — and every symlink the walk of the path went - * through is denied as well, since a command that retargets one of those - * moves the directory git opens without touching the file or the landing. + * What such a git directory keeps under its `modules` and `worktrees` is not + * enumerated here: `gitDirs` names each one reached, for the caller to hand to + * {@link gitDirTreeDenies}. * - * Throws {@link GitMetadataError} when the pointer or the `commondir` names - * something this cannot resolve the way git does; the wrap is then refused - * rather than applied with a deny list that may not cover the directory git - * uses. Throws {@link SubmoduleWalkBudgetError} where walking what they name - * takes longer than the `.git/modules` walk is given by default. - */ -export function gitFileDenyPaths( - gitFile: string, - allowGitConfig: boolean, -): string[] { - return gitFileDenies(gitFile, allowGitConfig).denyPaths -} - -/** - * {@link gitFileDenyPaths} with the whole-directory denies and the chain hops - * kept apart, as {@link gitDirDenies} keeps them: a git directory a pointer - * leads to is a git directory like any other, so an entry of it that is a - * symlink needs the directory holding the link denied whole on the backend - * whose denies resolve, and so does a link in the path the pointer's own - * value walks. What such a git directory keeps under its `modules` and its - * `worktrees` is not enumerated here: `gitDirs` names each one reached, for the - * caller to hand to {@link gitDirTreeDenies}. - * - * `deadline` bounds the walk of the pointer's value and of the `commondir`'s, - * each of which can run to the megabyte the file may hold, and of any chain - * an entry of the git directories they lead to starts. Past it this throws - * {@link SubmoduleWalkBudgetError}: a value not walked to its end names a git - * directory this never found, so there is no list to hand back. + * Throws {@link GitMetadataError} where the pointer or the `commondir` names + * something this cannot resolve the way git does, and {@link + * SubmoduleWalkBudgetError} past `deadline`. */ export function gitFileDenies( gitFile: string, @@ -748,10 +556,8 @@ export function gitFileDenies( gitDirs: [], } const denies = { ...gathered, escapingDenyPaths: [] } - // Looked at before the file is, whatever the file turns out to hold: a tree - // full of `.git` files is as many reads of up to a megabyte as a command - // cared to leave names for, and one that is no pointer starts no walk that - // would look at the clock for it. + // Before the file is read, whatever it holds: a tree full of `.git` files is + // as many reads of up to a megabyte as a command cared to leave names for. if (Date.now() > deadline) { throw new SubmoduleWalkBudgetError( `[Sandbox] The time given for working out what the .git files lead to ran out before ${gitFile} was read; refusing to sandbox on the ones read before it`, @@ -864,19 +670,11 @@ function gatherTargetDenies( } /** - * The links the walk of a `gitdir:` or `commondir` value went through, added - * to `gathered`. - * - * Such a VALUE is a path git walks as the kernel does, so a symlink in it is - * held as a symlinked entry is: the file naming it is denied and the git - * directory it reaches is denied, while a command that retargets a link - * between the two moves where git lands without touching either end. - * - * A value that reaches nothing names no git directory to deny, and the links - * the walk did go through are the whole of what a command can move. One - * warning says so, because a `.git` file is something a sandboxed command may - * create: refusing every later command, or denying the checkout whole for it, - * would make writing one a way to lock the session out of its own project. + * The links the walk of a `gitdir:` or `commondir` value went through, added to + * `gathered`. A value that reaches nothing names no git directory, and those + * links are all a command can move. One warning says so: a `.git` file is + * something a command may create, and refusing or denying the checkout whole + * for it would lock the session out of its own project. */ function holdValueChain( source: string, @@ -901,14 +699,11 @@ function holdValueChain( } /** - * What the `commondir` of `gitDir` leads to, added to `gathered`: the links - * its value walks, and the denies of each directory it names that is not in - * `heldAlready` — `gitDir` itself, for a value of `.`, and for a caller that - * came from the main git directory, that one. - * - * Throws {@link GitMetadataError} for a `commondir` that is there and whose - * target cannot be worked out: the directory whose hooks git runs through it - * is then unknown, and the denies gathered so far would leave it writable. + * What the `commondir` of `gitDir` leads to, added to `gathered`: the links its + * value walks, and the denies of each directory it names that is not in + * `heldAlready`. Throws {@link GitMetadataError} for one that is there and + * whose target cannot be worked out: the directory whose hooks git runs is then + * unknown. */ function gatherCommonDirDenies( gitDir: string, @@ -961,23 +756,19 @@ function gatherCommonDirDenies( } /** - * Every git directory a deny must cover once `gitDir` is one: its own hooks/ - * and config, the same for each submodule git directory under its `modules` - * (what a commit inside that submodule runs), what redirects git in each - * linked worktree's git directory under its `worktrees`, plus whatever the - * walks could not see through. A submodule keeps linked worktrees and a linked - * worktree keeps submodules, each under its own git directory, so the two - * walks call each other: see {@link keptSubmodules} and {@link keptWorktrees}. + * Every git directory a deny must cover once `gitDir` is one: its own entries, + * those of each submodule git directory under its `modules`, what redirects git + * in each linked worktree's under its `worktrees`, and whatever the walks could + * not see through. A submodule keeps linked worktrees and a linked worktree + * keeps submodules, so the two walks call each other ({@link keptSubmodules}, + * {@link keptWorktrees}). * - * `deadline` bounds the walks and, out of the same budget, the chains the - * entries of each git directory they found lead into (see - * {@link gitDirDenies}). `visited` is for a caller that enumerates several git - * directories, one of which may keep another: see {@link TreeWalk}. + * `deadline` bounds the walks and the chains they meet. `visited` is for a + * caller that enumerates several git directories, one of which may keep another + * ({@link TreeWalk}). * - * Kept apart rather than flattened so that a backend which cannot carry every - * mount can degrade the submodule denies and leave the rest alone; - * {@link gitDirTreeDenyPaths} is the flat form both backends emit when - * nothing has to be degraded. + * Kept in pieces so that a backend that cannot carry every mount can degrade + * some; {@link gitDirTreeDenyPaths} is the flat form. */ export function gitDirTreeDenies( gitDir: string, @@ -1021,10 +812,8 @@ export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { ...denies.submodules.flatMap(submodule => submodule.denyPaths), ...denies.worktrees.flatMap(worktree => worktree.denyPaths), ] - // A hop of an entry under `.git/modules` has no other list to sit in, while - // a hop of a git directory's OWN entry is already in that directory's deny - // paths: named once either way, since a duplicate literal buys a backend - // nothing. + // A hop of a `.git/modules` entry has no other list to sit in, and a hop of a + // git directory's own entry is among its deny paths already. const named = new Set(denyPaths) for (const hop of denies.chainHops) { if (named.has(hop.link)) continue @@ -1083,28 +872,20 @@ function keptSubmodules(gitDir: string, walk: TreeWalk): KeptGitDir[] { } /** - * The git directories of the linked worktrees `gitDir` keeps, one per entry - * of its `worktrees`, sorted by path, and what each is denied by - * ({@link worktreeGitDirDenyPaths}). With the submodules it keeps: those of a - * linked worktree have their git directories under the WORKTREE's `modules`, - * while their checkouts can lie outside everything the command may write. - * - * Every directory there is one, whatever it holds: git makes nothing else - * under `worktrees`, and an entry a command emptied — or made, for a `.git` - * file elsewhere to name — is one the host's git opens all the same. One - * level, since the name of a worktree's git directory is a single segment. - * An entry that is a symlink is followed and held as an entry of a - * `.git/modules` is ({@link walkEntry}), and a directory that cannot be - * listed is denied whole ({@link listDirectory}). + * The git directories of the linked worktrees `gitDir` keeps, one per entry of + * its `worktrees`, sorted, and what each is denied by ({@link + * worktreeGitDirDenyPaths}). With the submodules it keeps: their git + * directories are under the WORKTREE's `modules`, while their checkouts can lie + * outside everything the command may write. * - * Each one's `commondir` is followed as it is from a `.git` pointer: the - * host's git, run in that worktree, uses the hooks and config of whatever it - * names, which is this `gitDir` unless something has rewritten it. No bind of - * the worktree's git directory covers that, so it is marked as leading out. + * Every directory there is one, whatever it holds: git makes nothing else under + * `worktrees`, and one a command emptied or made is one the host's git opens + * all the same. An entry that is a symlink is followed and held ({@link + * walkEntry}). * - * Running out of time throws {@link SubmoduleWalkBudgetError} as the - * `.git/modules` walk does: a `worktrees` is as writable inside the sandbox as - * a `modules` is, and as easy to fill. + * Each one's `commondir` is followed as from a `.git` file. No bind of the + * worktree's git directory covers what it names, so that is marked as leading + * out. */ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { const { allowGitConfig, deadline } = walk @@ -1188,46 +969,30 @@ export function submoduleGitDirs( } /** - * Git directories of the submodules under `modulesDir` (a repository's - * .git/modules), nested submodules included, sorted. A submodule's name is - * its path, so one can sit several levels down (modules/vendor/lib), hence - * the walk. + * Git directories of the submodules under `modulesDir` (a `.git/modules`), + * nested ones included, sorted. A submodule's name is its path, so one can sit + * several levels down (modules/vendor/lib). * - * A directory recorded as a git directory is walked on all the same. What - * makes it one is what it holds ({@link looksLikeGitDir}), and a segment of a - * submodule's name (`vendor` of `vendor/lib`) is an ordinary directory a - * sandboxed command can write: a walk that stopped at the first directory - * which looks like a git directory would let one empty `HEAD` left in `vendor` - * take the denies off every submodule beneath it for the commands that - * follow. That goes for its `modules` too, which is where a git directory keeps - * its own submodules and may as well be the submodule `vendor/modules`: it is - * looked at like any other directory. git refuses a submodule whose git - * directory would sit inside another's, so in a tree git made there is nothing - * else past a git directory to find, and looking costs one listing of each - * directory in it. The ones where a repository is large are not walked into - * beyond that listing: see {@link GIT_OWN_DIRECTORIES}. `modulesDir` itself is - * never taken for a git directory whatever it holds. + * A directory recorded as a git directory is walked on all the same. A segment + * of a name (`vendor`) is a directory a command can write, and a walk that + * stopped at what {@link looksLikeGitDir} would let one `HEAD` left there take + * the denies off every submodule beneath it. That goes for its `modules` too: + * where a git directory keeps its own submodules, and maybe the submodule + * `vendor/modules`. Only {@link GIT_OWN_DIRECTORIES} are left at one listing. + * `modulesDir` itself is never taken for a git directory. * - * Every directory is visited once, keyed by where it really is, so a symlink - * pointing back into the tree ends the branch that reached it rather than - * looping - but a bind mount gives the same directory a second real path, and - * one pointed at its own ancestor has no end the visited set can see. The - * deadline is what bounds that, and running out of it throws - * {@link SubmoduleWalkBudgetError} rather than handing back a listing that - * stops somewhere unknown. Nothing bounds how DEEP a real tree may be: a - * submodule nested a hundred levels down has its hooks denied like any other, - * and the walk keeps its own stack so that such a tree cannot overflow this - * one's. + * Nothing bounds how deep a tree may be, and the walk keeps its own stack. A + * bind mount gives a directory a second real path, which the visited set cannot + * see, so the deadline is what bounds the walk. */ function walkModules(modulesDir: string, walk: TreeWalk): string[] { // The root counts as visited: an entry linked straight back to it is then the // same dead end as one linked to any other directory already walked. if (isVisited(modulesDir, walk)) return [] const gitDirs: string[] = [] - // Where the walk still has to look. A directory listed to see whether it is a - // git directory carries its entries with it, so that no directory is listed - // twice. `gitDir` says the directory was recorded as a git directory, so that - // some of its entries may be git's own. + // Where the walk still has to look. A directory that was listed carries its + // entries, and `gitDir` says it was recorded as a git directory, so that some + // of them may be git's own. const pending: Array<{ dir: string entries?: fs.Dirent[] @@ -1249,18 +1014,14 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { const child = path.join(item.dir, entry.name) let childEntries: fs.Dirent[] | undefined if (item.gitDir === true) { - // A link at `config`, `commondir` or `config.worktree` is read as - // this git directory's entry by what denies it, which holds both ends - // of it (see gitDirDenies). Following it here as well would walk the - // same chain a second time and take what it leads to for a submodule. + // Read as this git directory's entry by what denies it (gitDirDenies). + // Followed here as well, what it leads to would be taken for a + // submodule. if (entry.isSymbolicLink() && GIT_ENTRY_FILES.has(entry.name)) continue - // One of git's own directories by its name: walked on only where one - // listing of it shows something this file protects, which is what a - // submodule's git directory that has such a name shows and git's own - // directory never does. Listed before the entry is followed: following - // a symlink puts the directory holding it on the whole-directory - // denies (see walkEntry), and a git directory whose `objects` or - // `rr-cache` is a link to a shared store is an ordinary one. + // git's own by its name: walked on only where one listing shows what a + // submodule's git directory of that name has and git's own never does. + // Listed before it is followed: that would deny whole a git directory + // whose `objects` or `rr-cache` is a link to a shared store. if ( GIT_OWN_DIRECTORIES.has(entry.name) && (entry.isDirectory() || entry.isSymbolicLink()) @@ -1309,11 +1070,10 @@ interface TreeWalk { deadline: number /** * Every directory a walk has been into, by where it really is. One set for - * all of them: a link can lead from a `worktrees` back to the git directory - * that keeps it, and each walk would then find the other's tree anew, one - * spelling longer, for as long as a path can get. A caller that enumerates - * several git directories shares it between them, so that one which another - * keeps is not enumerated twice. + * all: a link can lead from a `worktrees` back to the git directory that + * keeps it, and each walk would find the other's tree anew, one spelling + * longer, for as long as a path can get. Shared between git directories, one + * that another keeps is not enumerated twice. */ visited: Set /** As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold @@ -1361,18 +1121,11 @@ function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { } /** - * What `entry` is worth doing with, following a symlink to a directory. - * - * A link that leads anywhere at all also puts the directory HOLDING it on the - * whole-directory denies: what a deny of the target covers is what a write - * through the link reaches, while the link itself takes no mount of its own, - * and a `.git/modules` is writable — see {@link SubmoduleScan.linkedEntryDirs}. - * A command can plant such a link and leave every submodule beside it - * read-only for the commands after it, which is the answer this makes to a - * command that plants git directories too. - * - * A chain that goes through further links is held hop by hop as a git - * directory's own entry is: see {@link GitChainHop}. + * What `entry` is worth doing with, following a symlink to a directory. A link + * that leads anywhere puts the directory HOLDING it on the whole-directory + * denies ({@link SubmoduleScan.linkedEntryDirs}), and further links on the way + * are held hop by hop ({@link GitChainHop}). A command can plant one and leave + * every submodule beside it read-only for the commands after it. */ function walkEntry( entry: fs.Dirent, @@ -1417,32 +1170,20 @@ function walkEntry( } if (isUnusablePathError(err)) return { kind: 'skip' } if ((err as NodeJS.ErrnoException).code === 'ELOOP') { - // A link that resolves back to itself reaches no directory at all, so - // there is nothing behind it to deny and nothing a command can put - // there without replacing the link, which the next wrap's walk sees. - // The link's own path cannot carry a read-only bind either: bubblewrap - // resolves a mount destination, and resolving this one is what just - // failed. Denying the directory that HOLDS it is the one answer that - // would be worse than none - a single link a command can write would - // take every submodule beside it read-only. The links the loop goes - // THROUGH are left alone for a reason of their own: a `.git/modules` is - // writable in the sandbox, so putting a directory at one of them to - // make this entry reach it is no shorter a way to a planted submodule - // git directory than creating one outright, which is the recorded limit - // of scanning before a command rather than after it. A git directory's - // own entries are the other case: there, the whole-directory deny holds - // the entry and the hops are all that is left open. + // Nothing is behind it to deny, and no bind can be put at it. Denying the + // directory that HOLDS it would let one link take every submodule beside + // it read-only. The links it goes through are left alone too: putting a + // directory at one is no shorter a way to a planted git directory under + // a writable `.git/modules` than creating one outright. logForDebugging( `[Sandbox] ${entryPath} is a symlink loop, which leads to nothing to deny: ${err}`, { level: 'warn' }, ) return { kind: 'skip' } } - // Something is there and could not be inspected. What is behind the link - // is failed closed on the deepest directory that can be reached TOWARDS - // IT, rather than on the directory holding the link, which would take - // every submodule beside it over one entry; the link itself is a link - // like any other, and its holder is denied for it. + // Something is there and could not be looked at. Failed closed on the + // deepest directory reachable TOWARDS it, not on the one holding the link, + // which is denied for the link's own sake. const denied = deepestReachableAncestor(chain.landing) ?? entryPath walk.unreadableDirs.add(denied) walk.linkedEntryDirs.add(holder) @@ -1456,11 +1197,9 @@ function walkEntry( } /** - * What a directory a `gitdir:` or `commondir` names is denied by. An existing - * directory that is not a git directory is left alone: file content must not - * be able to point the deny list at, say, a Rails `config/`. An absent one is - * still denied, so the sandboxed command cannot create the target and fill it - * with hooks before the host's git first uses it. + * What a directory a `gitdir:` or a `commondir` names is denied by. One that is + * no git directory is left alone. An absent one is denied, so that a command + * cannot create it and fill it with hooks. */ function gitDirTargetDenies( target: string, @@ -1523,16 +1262,11 @@ function gitDirKind(dir: string): GitDirKind { } /** - * Whether `dir` sits where git keeps a git directory of its own making: a - * linked worktree's, `.git/worktrees/`, or a submodule's, anywhere under - * a `.git/modules` — the repository's, or the one a linked worktree keeps - * for its own submodules under `.git/worktrees//modules`. - * - * Asked of the path the kernel lands on and not of the one a pointer spells: - * a command can make `x/.git` a symlink to any directory, and a value - * written through it has this shape while what it reaches is an ordinary - * directory of the project. A directory that really is at such a path got - * there by being created or moved there, which file content cannot do. + * Whether `dir` sits where git keeps a git directory of its own making: + * `.git/worktrees/`, or anywhere under a `.git/modules`, a linked + * worktree's own included. Asked of the path the kernel lands on: a command can + * make `x/.git` a symlink to any directory, and a value written through it has + * this shape. */ function isGitOwnedLocation(dir: string): boolean { const parts = physicalDenyPath(dir).split('/') @@ -1549,8 +1283,8 @@ function isGitOwnedLocation(dir: string): boolean { /** * The contents of `file`, or what stopped this from reading it the way git - * does. The path is one a sandboxed command may create: a FIFO there would - * block the host on every later wrap (hence O_NONBLOCK and the type check). + * does. A command may create the path: a FIFO there would block every later + * wrap, hence O_NONBLOCK and the type check. */ function readGitMetadataFile(file: string): GitMetadata { // O_NONBLOCK is POSIX-only; this file's callers are the Linux and macOS @@ -1606,22 +1340,18 @@ function parseGitdirPointer( } /** - * The path a git metadata file names, by git's rules rather than a line - * reader's: `\n` and `\r` are stripped from the END OF THE FILE (so a newline - * inside the path is part of it), nothing is trimmed, and what is left is a C - * string and ends at the first NUL. `read_gitfile_gently` and - * `get_common_dir_noenv` in git's setup.c both read this way. + * The path a git metadata file names, by git's rules (`read_gitfile_gently`, + * `get_common_dir_noenv`): `\n` and `\r` are stripped from the END OF THE FILE, + * nothing else is trimmed, and what is left is a C string that ends at the + * first NUL. * - * This re-implements that parsing instead of asking git, because the library - * has to work where git is not installed, a wrap would otherwise spawn a - * process per pointer per command, and running git inside a checkout the - * sandboxed command can write is the hazard these denies exist to contain; - * test/sandbox/git-pointer-parity.test.ts is what keeps the two in step, - * resolving a corpus of pointer shapes both ways and comparing. + * Re-implemented instead of asking git: the library has to work without git, + * and running git in a checkout the command can write is the hazard these + * denies contain. test/sandbox/git-pointer-parity.test.ts keeps the two in + * step. * - * Throws {@link GitMetadataError} for a path whose bytes are not valid UTF-8: - * a JavaScript string of them names a different file than git opens, so - * there is no path here to deny. + * Throws {@link GitMetadataError} for bytes that are not valid UTF-8: a string + * of them names another file than git opens. */ function gitMetadataPath(contents: Buffer, file: string): string | undefined { let end = contents.length @@ -1661,24 +1391,16 @@ interface GitMetadataTargets { } /** - * The git directories a `gitdir:` or `commondir` value read in `base` leads - * to: the path as it folds on paper, which is the spelling these denies have - * always used, and — when a `..` in it could send the kernel elsewhere — the - * directory the kernel actually reaches. git hands the two strings to stat - * joined and unnormalised, so a symlink is followed before a later `..` - * applies and `a/link/../b` need not be `a/b`; run against real git, it opens - * the kernel's landing and refuses when only the lexical one is a git - * directory (test/sandbox/git-pointer-parity.test.ts pins that). Both are - * denied when they differ: denying more than git follows costs one bind. + * The git directories a `gitdir:` or `commondir` value read in `base` leads to: + * the path as it folds on paper and, where a `..` in it could send the kernel + * elsewhere, the directory the kernel reaches. git hands the joined string to + * stat, so a symlink is followed before a later `..` applies, and it opens the + * kernel's landing. Both are denied where they differ. * - * A value whose walk reaches no end names NO git directory. git opens nothing - * through it — it gives up on the same loop, and the lexical fold is not a - * fallback it takes — while the deepest directory a fail-closed deny could - * still reach towards it is usually the checkout itself, and denying that - * whole would take the project read-only over a `.git` file a sandboxed - * command is allowed to create. The links the walk did go through come back - * as hops all the same: putting a directory in one of their places is what - * makes such a chain reach anything at all. + * A value whose walk reaches no end names NO git directory: git opens nothing + * through it, and the deepest directory a fail-closed deny could reach is + * usually the checkout. The links it went through come back as hops all the + * same. */ function gitMetadataTargets( base: string, @@ -1708,29 +1430,15 @@ function gitMetadataTargets( } /** - * Where the kernel lands walking `target` from `base`, symlinks followed as - * it meets them, and every symlink it went through on the way. - * `path.resolve` folds `..` lexically, and `fs.realpathSync` folds its - * argument the same way before resolving it, so neither answers this; a walk - * also reaches a tail that does not exist yet, which realpath cannot. A - * component that cannot be walked — missing, unreadable, or a loop past the - * hop limit — ends it, since the kernel cannot traverse one either and - * nothing beyond it can redirect the path; the rest is taken as written and - * classified by {@link gitDirTargetDenies} like any other target. - * - * The hops are what a caller protecting the landing still has to hold: each - * one is a name a command able to write the directory around it can point - * somewhere else, which moves the landing without touching either end. + * Where the kernel lands walking `target` from `base`, and every symlink it + * went through. `path.resolve` and `fs.realpathSync` fold `..` on paper, and a + * walk also reaches a tail that does not exist yet. A component that cannot be + * walked ends it, and the rest is taken as written. * * The hop limit bounds how many links are followed and nothing bounds how long - * each is: a link's target runs to a filesystem's four thousand bytes and a - * pointer's value to the megabyte its file may hold, every component of either - * is an lstat, and a command can leave as many of them as it likes. `deadline` - * is what bounds that, looked at as the components go by, and running out of - * it throws {@link SubmoduleWalkBudgetError} as the `.git/modules` walk does: - * a landing that was never reached is not one to work a deny out from. A link - * whose target is no text throws {@link GitMetadataError}, as a value that is - * none does. + * each is, so `deadline` does: past it this throws {@link + * SubmoduleWalkBudgetError}. A link whose target is no text throws {@link + * GitMetadataError}, as a value that is none does. */ function resolveChain( base: string, @@ -1782,22 +1490,14 @@ function resolveChain( current = next continue } - // `current` is where the walk really is, so the hop is recorded under - // the name the kernel reached it by rather than the one it was spelled - // from: that is the name an unlink or a rename of the link uses. A loop - // arrives at the same link over and over and it is one name, so the hop - // count is not what bounds the walk. + // Under the name the kernel reached it by, which is the one an unlink or a + // rename uses. A loop arrives at the same link over and over: one hop. if (!named.has(next)) { named.add(next) hops.push({ link: next, holder: current }) } - // Past the kernel's own limit the chain reaches nothing: git gives up on - // it exactly as this does, so there is nothing behind it to protect and - // the landing is only where the walk stopped. The links it went THROUGH - // are a different matter and come back — each is a name a command able to - // write the directory around it can point at a real directory, which - // makes the chain reach one and every deny worked out for it wrong — so - // the caller holds them and says so where it cannot. + // Past the kernel's own limit the chain reaches nothing, for git as for + // this. The links it went THROUGH come back for the caller to hold. if (++followed > MAX_SYMLINK_RESOLUTION_DEPTH) { return { landing: next, hops, resolved: false } } @@ -1823,19 +1523,10 @@ function abridged(value: string, limit = 200): string { } /** - * Where the kernel lands walking `denyPath` from the root: the spelling a - * backend needs whose filters are matched against the path an operation - * RESOLVED to rather than the one it was spelled from (macOS Seatbelt — see - * `gitDiskDenyEntries` in src/sandbox/macos-sandbox-utils.ts). `denyPath` - * itself where nothing on the way is a symlink, which is every path of an - * ordinary repository. - * - * `fs.realpathSync` does not answer this: it throws on a path that is not - * there, and an absent `hooks` or `config.worktree` is exactly what these - * denies exist to stop a command from creating. - * - * Throws {@link SubmoduleWalkBudgetError} past `deadline`, which bounds the - * walk as it bounds every other (see {@link resolveChain}). + * Where the kernel lands walking `denyPath` from the root: the spelling for a + * backend whose filters are matched against the path an operation RESOLVED to + * (`gitDiskDenyEntries` on macOS). `fs.realpathSync` throws on a path that is + * not there, and an absent `hooks` is what these denies are for. */ export function physicalDenyPath( denyPath: string, @@ -1845,16 +1536,11 @@ export function physicalDenyPath( } /** - * The deepest ancestor of `target` (itself included) this process can still - * stat as a directory. Denying that directory fails closed when the path - * below it cannot be inspected: nothing under it is writable in the sandbox. - * - * Symlinks are followed, so a link to a directory that cannot be listed is - * answered with the link's own path rather than with the directory holding - * it — the Linux deny loop puts the bind on the real target (see - * `resolveSymlinkedDenyPath`), and answering with the parent would let one - * unreadable directory anyone can plant a link to take every entry beside it - * read-only. + * The deepest ancestor of `target`, itself included, that this process can stat + * as a directory. Denying it fails closed where what lies below cannot be + * inspected. Symlinks are followed, so a link to an unlistable directory is + * answered with the link's own path, which the Linux deny loop binds at its + * target, not with the directory holding it. */ function deepestReachableAncestor(target: string): string | undefined { for (let dir = target; ; dir = path.dirname(dir)) { diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 93acdc179..2410adfff 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -21,7 +21,7 @@ import { } from 'node:path' import { GitMetadataError, - gitFileDenyPaths, + gitFileDenies, } from '../../src/sandbox/mandatory-deny-paths.js' import { isLinux, isWindows } from '../helpers/platform.js' @@ -283,7 +283,7 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { ): Verdict { let denyPaths: string[] try { - denyPaths = gitFileDenyPaths(pointer, false) + denyPaths = gitFileDenies(pointer, false).denyPaths } catch (err) { if (!(err instanceof GitMetadataError)) throw err // Refusing to sandbox covers everything, including what git follows. @@ -681,7 +681,7 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { // So the deny list has to carry that directory's hooks — and the link // that decides which directory it is, since a command that retargets it // moves where the next git run lands. - const denyPaths = gitFileDenyPaths(pointer, false) + const denyPaths = gitFileDenies(pointer, false).denyPaths expect(denyPaths).toContain(join(physical, 'hooks')) expect(denyPaths).toContain(hop) expect(assertParity('a .. after a symlink', pointer, checkout)).toBe( @@ -704,7 +704,9 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { writeFileSync(pointer, 'gitdir: hop/../evil\n') expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() - expect(gitFileDenyPaths(pointer, false)).toContain(join(lexical, 'hooks')) + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(lexical, 'hooks'), + ) }) it('opens nothing through a pointer value that runs into a loop', () => { @@ -729,7 +731,7 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() - const denyPaths = gitFileDenyPaths(pointer, false) + const denyPaths = gitFileDenies(pointer, false).denyPaths expect(denyPaths).toEqual([pointer, first, second]) expect(denyPaths).not.toContain(checkout) }) @@ -742,7 +744,9 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { writeFileSync(pointer, 'gitdir: \0elsewhere\n') expect(gitResolves(checkout, '--absolute-git-dir')).toBe(checkout) - expect(gitFileDenyPaths(pointer, false)).toContain(join(checkout, 'hooks')) + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(checkout, 'hooks'), + ) }) it('runs a hook again as soon as a looping hooks link reaches a directory', () => { @@ -827,7 +831,9 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { expect( runGit(checkout, ['rev-parse', '--absolute-git-dir']), ).not.toBeUndefined() - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) }, ) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 52e921a82..acd6f475c 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -58,7 +58,6 @@ import { gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenies, - gitFileDenyPaths, gitRedirectPlaceholder, submoduleGitDirs, } from '../../src/sandbox/mandatory-deny-paths.js' @@ -67,7 +66,7 @@ import { collapseFurther, collapsedDenyPaths, describeCollapse, - repositorySubmodules, + degradableGitDirs, } from '../../src/sandbox/linux-deny-collapse.js' import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' import { @@ -3748,7 +3747,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denies.denyPaths).toContain(shared) expect(denies.linkedEntryDirs).toEqual([gitDir]) // The list a backend that needs none of that emits is what it was. - expect(gitFileDenyPaths(pointer, false)).toEqual(denies.denyPaths) + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) }, ) @@ -3823,7 +3822,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const gitDir = makeGitDir(join(dir, 'gitdir')) const pointer = makePointer('checkout', '../gitdir') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -3835,7 +3834,7 @@ describe('Git metadata deny paths - Unit Tests', () => { writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') const pointer = makePointer('wt-checkout', worktreeGitDir) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), ...gitDirDenyPaths(main, false), @@ -3848,13 +3847,13 @@ describe('Git metadata deny paths - Unit Tests', () => { mkdirSync(join(dir, 'app', 'config'), { recursive: true }) const pointer = makePointer(join('app', 'tools'), '..') - expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) }) it('blocks the git directory a dangling pointer names from being filled in', () => { const pointer = makePointer('checkout', '../not-created-yet') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(join(dir, 'not-created-yet'), false), ]) @@ -3870,7 +3869,7 @@ describe('Git metadata deny paths - Unit Tests', () => { // git strips them from the end of the whole file, so this is a pointer // it follows; reading less of the file than git does would leave the // target's hooks and config out of the deny list. - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -3884,7 +3883,7 @@ describe('Git metadata deny paths - Unit Tests', () => { // Nothing is trimmed but the newline, so the pointer names a directory // that does not exist — denied against being created, not confused for // the real git directory next to it. - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(padded, false), ]) @@ -3900,7 +3899,7 @@ describe('Git metadata deny paths - Unit Tests', () => { `gitdir: ${gitDir}${' '.repeat(9000)}\n`, ) - expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) }) it('follows a pointer of the largest size git accepts, and no larger', () => { @@ -3913,7 +3912,7 @@ describe('Git metadata deny paths - Unit Tests', () => { head + '\n'.repeat(maxSize - head.length), ) expect(statSync(atBound).size).toBe(maxSize) - expect(gitFileDenyPaths(atBound, false)).toEqual([ + expect(gitFileDenies(atBound, false).denyPaths).toEqual([ atBound, ...gitDirDenyPaths(gitDir, false), ]) @@ -3924,7 +3923,7 @@ describe('Git metadata deny paths - Unit Tests', () => { head + '\n'.repeat(maxSize + 1 - head.length), ) expect(statSync(pastBound).size).toBe(maxSize + 1) - expect(gitFileDenyPaths(pastBound, false)).toEqual([pastBound]) + expect(gitFileDenies(pastBound, false).denyPaths).toEqual([pastBound]) }) it.if(!isWindows)('takes a path that spans lines whole, as git does', () => { @@ -3933,7 +3932,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const gitDir = makeGitDir(join(dir, 'two\nlines')) const pointer = writePointer('checkout', `gitdir: ${gitDir}\n`) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -3946,7 +3945,7 @@ describe('Git metadata deny paths - Unit Tests', () => { `gitdir: ${gitDir}\0/../elsewhere\n`, ) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -3956,7 +3955,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const gitDir = makeGitDir(join(dir, 'gitdir')) const pointer = writePointer('checkout', `gitdir: ${gitDir}\r\n`) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -3976,7 +3975,9 @@ describe('Git metadata deny paths - Unit Tests', () => { ]), ) - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) }) it("follows a commondir padded past the pointer's own bound", () => { @@ -3988,7 +3989,7 @@ describe('Git metadata deny paths - Unit Tests', () => { ) const pointer = makePointer('wt-checkout', worktreeGitDir) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), ...gitDirDenyPaths(main, false), @@ -4003,7 +4004,7 @@ describe('Git metadata deny paths - Unit Tests', () => { writeFileSync(join(worktreeGitDir, 'commondir'), ` ${main}\n`) const pointer = makePointer('wt-checkout', worktreeGitDir) - const denyPaths = gitFileDenyPaths(pointer, false) + const denyPaths = gitFileDenies(pointer, false).denyPaths expect(denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), @@ -4043,7 +4044,7 @@ describe('Git metadata deny paths - Unit Tests', () => { holder: join(dir, 'checkout'), }, ]) - expect(gitFileDenyPaths(pointer, false)).toEqual(denies.denyPaths) + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) }, ) @@ -4054,7 +4055,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, join(dir, 'checkout', 'hop'), ...gitDirDenyPaths(lexical, false), @@ -4336,7 +4337,9 @@ describe('Git metadata deny paths - Unit Tests', () => { ) const pointer = makePointer('wt-checkout', worktreeGitDir) - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) }) /** @@ -4525,7 +4528,9 @@ describe('Git metadata deny paths - Unit Tests', () => { chmodSync(commondir, 0o000) const pointer = makePointer('wt-checkout', worktreeGitDir) try { - expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) } finally { chmodSync(commondir, 0o644) } @@ -4545,7 +4550,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const pointer = makePointer('wt-checkout', worktreeGitDir) chmodSync(worktreeGitDir, 0o000) try { - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, worktreeGitDir, ]) @@ -4607,7 +4612,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) const pointer = makePointer('checkout', '../gitdir') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -4621,7 +4626,7 @@ describe('Git metadata deny paths - Unit Tests', () => { const pointer = join(dir, 'checkout', '.git') expect(spawnSync('mkfifo', [pointer]).status).toBe(0) - expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) }, ) @@ -5312,7 +5317,7 @@ describe.if(isSupportedPlatform)( const gitDir = makeGitDir(join(dir, 'repo', '.git')) const denies = gitDirTreeDenies(gitDir, false) - expect(repositorySubmodules(denies)).toBeUndefined() + expect(degradableGitDirs(denies)).toEqual([]) expect(gitDirTreeDenyPaths(denies)).not.toContain( join(gitDir, 'modules'), ) @@ -5332,7 +5337,7 @@ describe.if(isSupportedPlatform)( const denies = gitDirTreeDenies(gitDir, false) expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(repositorySubmodules(denies)).toBeUndefined() + expect(degradableGitDirs(denies)).toEqual([]) }, ) @@ -5348,15 +5353,13 @@ describe.if(isSupportedPlatform)( const link = join(gitDir, 'modules', 'b') symlinkSync(outside, link) - const repository = repositorySubmodules( - gitDirTreeDenies(gitDir, false), - ) - expect(repository).toBeDefined() + const tree = gitDirTreeDenies(gitDir, false) const plan: SubmoduleDenyPlan = { - denyPaths: gitDirTreeDenyPaths(gitDirTreeDenies(gitDir, false)), - repositories: repository === undefined ? [] : [repository], + denyPaths: gitDirTreeDenyPaths(tree), + repositories: degradableGitDirs(tree), chainHops: [], } + expect(plan.repositories).toHaveLength(1) const denies = collapsedDenyPaths(plan, { wholeGitDirs: 2, diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index efcc75ca5..26d17fefb 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -32,7 +32,6 @@ import { gitDirTreeDenies, gitDirTreeDenyPaths, gitFileDenies, - gitFileDenyPaths, } from '../../src/sandbox/mandatory-deny-paths.js' import * as denyCollapse from '../../src/sandbox/linux-deny-collapse.js' import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' @@ -182,7 +181,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), // And the commondir is still followed: the hooks a commit in the @@ -203,7 +202,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), ...gitDirDenyPaths(bare, false), @@ -382,7 +381,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { rmSync(join(worktreeGitDir, name)) } - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), ]) @@ -398,7 +397,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const pointer = join(checkout, '.git') writeFileSync(pointer, 'gitdir: ../../.git/modules/vendor/lib\n') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(submodule, false), ]) @@ -418,7 +417,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const pointer = join(project, 'y', '.git') writeFileSync(pointer, 'gitdir: ../x/.git/worktrees/a\n') - const denyPaths = gitFileDenyPaths(pointer, false) + const denyPaths = gitFileDenies(pointer, false).denyPaths expect(denyPaths).toContain(pointer) expect( @@ -508,7 +507,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { join(dir, 'wt'), ) - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(worktreeGitDir, false), ...gitDirDenyPaths(bare, false), @@ -532,7 +531,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const pointer = join(dir, 'checkout', '.git') writeFileSync(pointer, 'gitdir: ../store\n') - expect(gitFileDenyPaths(pointer, allowGitConfig)).toEqual([ + expect(gitFileDenies(pointer, allowGitConfig).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, allowGitConfig), ]) @@ -549,7 +548,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const pointer = join(dir, 'checkout', '.git') writeFileSync(pointer, 'gitdir: ../store\n') - expect(gitFileDenyPaths(pointer, false)).toEqual([ + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ pointer, ...gitDirDenyPaths(gitDir, false), ]) @@ -988,7 +987,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(gitDirTreeDenyPaths(denies)).toEqual( gitDirDenyPaths(mainGitDir, false), ) - expect(denyCollapse.repositoryWorktrees(denies)).toBeUndefined() + expect(denyCollapse.degradableGitDirs(denies)).toEqual([]) }) }) @@ -996,12 +995,11 @@ describe.if(!isWindows)('Linked worktree git directories', () => { /** The plan a wrap builds for one repository that has no submodules. */ function planFor(mainGitDir: string): SubmoduleDenyPlan { const denies = gitDirTreeDenies(mainGitDir, false) - expect(denyCollapse.repositorySubmodules(denies)).toBeUndefined() - const worktrees = denyCollapse.repositoryWorktrees(denies) - if (worktrees === undefined) throw new Error('no linked worktrees found') + const repositories = denyCollapse.degradableGitDirs(denies) + expect(repositories).toMatchObject([{ linkedWorktrees: true }]) return { denyPaths: gitDirTreeDenyPaths(denies), - repositories: [worktrees], + repositories, chainHops: denies.chainHops, } } From e992a350ca4c1a4766b8bbc45acf3697cb3fe103 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:36:16 +0000 Subject: [PATCH 135/141] tests: repair the cases the review found wanting, drop what proves nothing Repaired: - 'gives a walk made after the scan a budget of its own' needed two things to happen inside one millisecond and failed about once in a hundred runs. The clock now jumps as the stand-in scan ends. - The case for the clock being looked at inside one wide directory made fifty thousand links and went by the wall clock. Three hundred files and a clock that jumps after the first look. - The work tree guard's cases counted every `--ro-bind X X`, ancestor pins among them, so most passed with every whole-directory deny removed. They look above the bind that makes the working directory writable. - 'holds a modules directory that is a symlink' asserted on a directory the link does not lead to. Dropped: two live cases of seven hundred submodules each, one of which never degraded anything and the other repeats unit cases; the live half of a third; a parity case that called nothing of the library. Folded: twelve one-write cases into a table, one platform condition for the unit cases, one afterEach in place of seven try/finally. These suites take 85 s here, down from 132 s. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- test/sandbox/git-pointer-parity.test.ts | 45 - test/sandbox/linux-work-tree-guard.test.ts | 27 +- test/sandbox/mandatory-deny-paths.test.ts | 1857 +++++++---------- test/sandbox/mandatory-deny-worktrees.test.ts | 18 +- 4 files changed, 793 insertions(+), 1154 deletions(-) diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts index 2410adfff..e8b7a1d35 100644 --- a/test/sandbox/git-pointer-parity.test.ts +++ b/test/sandbox/git-pointer-parity.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import { spawnSync } from 'node:child_process' import { - existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -749,50 +748,6 @@ describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { ) }) - it('runs a hook again as soon as a looping hooks link reaches a directory', () => { - // The other half of what a loop means, against real git: while - // `.git/hooks` runs into one, git finds no hook there and commits - // without it - and the moment one of the LINKS is replaced by a - // directory, with the entry and the git directory untouched, the hook - // inside it is what git runs. That is why every link a looping chain - // went through is denied: they are the whole of what makes it reach - // anything. - const caseDir = join(root, `pointer-${caseCount++}`) - const checkout = join(caseDir, 'checkout') - mkdirSync(checkout, { recursive: true }) - expect(runGit(checkout, ['init', '-q', '-b', 'main', '.'])).toBeDefined() - runGit(checkout, ['config', 'user.email', 'parity@example.invalid']) - runGit(checkout, ['config', 'user.name', 'Parity']) - const links = join(checkout, 'links') - mkdirSync(links, { recursive: true }) - symlinkSync('b', join(links, 'a')) - symlinkSync('a', join(links, 'b')) - rmSync(join(checkout, '.git', 'hooks'), { recursive: true, force: true }) - symlinkSync('../links/a', join(checkout, '.git', 'hooks')) - const ran = join(caseDir, 'hook-ran') - writeFileSync(join(checkout, 'f.txt'), 'one\n') - runGit(checkout, ['add', 'f.txt']) - - expect( - runGit(checkout, ['commit', '-q', '-m', 'first'], { runHooks: true }), - ).toBeDefined() - expect(existsSync(ran)).toBe(false) - - // One of the two links, replaced by a directory holding a hook. - rmSync(join(links, 'a')) - mkdirSync(join(links, 'a'), { recursive: true }) - writeFileSync(join(links, 'a', 'pre-commit'), `#!/bin/sh\ntouch ${ran}\n`, { - mode: 0o755, - }) - writeFileSync(join(checkout, 'f.txt'), 'two\n') - runGit(checkout, ['add', 'f.txt']) - - expect( - runGit(checkout, ['commit', '-q', '-m', 'second'], { runHooks: true }), - ).toBeDefined() - expect(existsSync(ran)).toBe(true) - }, 120_000) - // Linux only: the target has to exist for git to follow it, and macOS // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The // refusal itself is platform-independent and covered everywhere by the diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts index c39bbfa12..c9ff9be1b 100644 --- a/test/sandbox/linux-work-tree-guard.test.ts +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -23,6 +23,20 @@ import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' import { isLinux } from '../helpers/platform.js' +/** + * Every `--ro-bind X X` that denies, by what it binds: those above the bind that + * makes the working directory writable. An ancestor pin spells the same words + * beneath it. + */ +function readOnlyBinds(command: string): string[] { + const cwd = process.cwd() + const madeWritable = command.indexOf(` --bind ${cwd} ${cwd} `) + expect(madeWritable).toBeGreaterThan(-1) + return [...command.slice(madeWritable).matchAll(/--ro-bind (\S+) (\S+)/g)] + .filter(([, source, dest]) => source === dest) + .map(([, source]) => source!) +} + /** * A deny path is bound where it LANDS: bubblewrap resolves a destination, so * a git directory entry that is itself a link to the checkout @@ -83,13 +97,6 @@ describe.if(isLinux)('a git entry that is a link to the work tree', () => { }) } - /** Every `--ro-bind X X` of the wrapped command, by what it binds. */ - function readOnlyBinds(command: string): string[] { - return [...command.matchAll(/--ro-bind (\S+) (\S+)/g)] - .filter(([, source, dest]) => source === dest) - .map(([, source]) => source!) - } - function landing(denyPath: string): string { try { return realpathSync(denyPath) @@ -339,12 +346,6 @@ describe.if(isLinux)('a git entry whose link chain is a loop', () => { }) } - function readOnlyBinds(command: string): string[] { - return [...command.matchAll(/--ro-bind (\S+) (\S+)/g)] - .filter(([, source, dest]) => source === dest) - .map(([, source]) => source!) - } - it('refuses for an entry of the working directory own repository', async () => { const checkout = makeCheckout('repo') makeLoop(checkout) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index acd6f475c..8a8d6c4ef 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -377,43 +377,98 @@ describe.if(isSupportedPlatform)( }) describe('Nested repositories, submodules and worktree pointers', () => { - it("blocks writes to a nested repository's .git/config even when gitignored", async () => { - const result = await runSandboxedWrite( - 'nested/.git/config', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('nested/.git/config', 'utf8')).toBe( - ORIGINAL_CONTENT, - ) - }) - - it("blocks writes to a nested repository's existing hook at the default depth", async () => { - const result = await runSandboxedWrite( - 'nested/.git/hooks/pre-commit', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( - ORIGINAL_CONTENT, - ) - }) - - it("blocks a nested repository's hooks when only its HEAD is within the scan depth", async () => { - // With allowGitConfig the scan does not look for .git/config, and the - // hook files lie one level past the default depth. - const result = await runSandboxedWrite( - 'nested/.git/hooks/pre-commit', - MODIFIED_CONTENT, - { allowGitConfig: true }, - ) + /** + * One write each, refused: `left` is what is at the path afterwards. On + * Linux the mount point of an absent deny path stays until the clean-up, + * bubblewrap's own empty file or what the wrap wrote for git to read; on + * macOS nothing is created. + */ + it.each<{ + what: string + file: string + left: string | { absent: true } | { mountPoint: string } + opts?: SandboxRunOptions + }>([ + { + what: "a nested repository's config, gitignored or not", + file: 'nested/.git/config', + left: ORIGINAL_CONTENT, + }, + { + what: "a nested repository's existing hook, at the default depth", + file: 'nested/.git/hooks/pre-commit', + left: ORIGINAL_CONTENT, + }, + { + // The scan does not go by .git/config then, and the hook files lie + // one level past the default depth. + what: "a nested repository's hook with config writes allowed", + file: 'nested/.git/hooks/pre-commit', + left: ORIGINAL_CONTENT, + opts: { allowGitConfig: true }, + }, + { + what: 'a new hook in a nested repository', + file: 'nested/.git/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a submodule's config under .git/modules", + file: '.git/modules/lib/config', + left: ORIGINAL_CONTENT, + }, + { + what: "a new hook in a submodule's git directory", + file: '.git/modules/lib/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a new hook in a nested repository's submodule", + file: 'nested/.git/modules/dep/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a submodule checkout's .git file", + file: 'lib/.git', + left: 'gitdir: ../.git/modules/lib', + }, + { + // git reads hooks and config through it, so a write here moves every + // other deny to a directory of the command's choosing. + what: "a commondir in the repository's git directory", + file: '.git/commondir', + left: { mountPoint: '.\n' }, + }, + { + what: "a commondir in a submodule's git directory", + file: '.git/modules/lib/commondir', + left: { mountPoint: '.\n' }, + }, + { + what: "a commondir in a nested repository's", + file: 'nested/.git/commondir', + left: { mountPoint: '.\n' }, + }, + { + // Read on top of .git/config wherever extensions.worktreeConfig is on. + what: '.git/config.worktree', + file: '.git/config.worktree', + left: { mountPoint: '' }, + }, + ])('blocks writing $what', async ({ file, left, opts }) => { + const result = await runSandboxedWrite(file, MODIFIED_CONTENT, opts) expect(result.success).toBe(false) - expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( - ORIGINAL_CONTENT, - ) + const isMade = + typeof left === 'string' || ('mountPoint' in left && isLinux) + expect(existsSync(file)).toBe(isMade) + if (isMade) { + expect(readFileSync(file, 'utf8')).toBe( + typeof left === 'string' + ? left + : (left as { mountPoint: string }).mountPoint, + ) + } }) it('finds a nested repository with no file directly inside its .git', async () => { @@ -439,16 +494,6 @@ describe.if(isSupportedPlatform)( } }) - it('blocks creating a new hook in a nested repository', async () => { - const result = await runSandboxedWrite( - 'nested/.git/hooks/post-checkout', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(existsSync('nested/.git/hooks/post-checkout')).toBe(false) - }) - it('keeps the rest of a nested repository writable', async () => { const result = await runSandboxedWrite( 'nested/src/ok.txt', @@ -482,52 +527,6 @@ describe.if(isSupportedPlatform)( }, ) - it("blocks writes to a submodule's config under .git/modules", async () => { - const result = await runSandboxedWrite( - '.git/modules/lib/config', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.git/modules/lib/config', 'utf8')).toBe( - ORIGINAL_CONTENT, - ) - }) - - it("blocks creating a hook in a submodule's git directory", async () => { - const result = await runSandboxedWrite( - '.git/modules/lib/hooks/post-checkout', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(existsSync('.git/modules/lib/hooks/post-checkout')).toBe(false) - }) - - it("blocks creating a hook in a nested repository's submodule", async () => { - const result = await runSandboxedWrite( - 'nested/.git/modules/dep/hooks/post-checkout', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(existsSync('nested/.git/modules/dep/hooks/post-checkout')).toBe( - false, - ) - }) - - it("blocks repointing a submodule checkout's .git file", async () => { - const result = await runSandboxedWrite( - 'lib/.git', - 'gitdir: /tmp/elsewhere', - ) - - expect(result.success).toBe(false) - expect(readFileSync('lib/.git', 'utf8')).toBe( - 'gitdir: ../.git/modules/lib', - ) - }) - it('blocks removing an existing .git pointer file', async () => { const result = await runSandboxed('rm -f lib/.git', { readConfig: { denyOnly: [READ_DENY_DIR] }, @@ -569,47 +568,6 @@ describe.if(isSupportedPlatform)( } }) - it("blocks creating a commondir in the repository's git directory", async () => { - // git reads hooks and config through commondir, so a write here - // moves every deny below to a directory of the command's choosing. - const result = await runSandboxedWrite('.git/commondir', 'decoy') - - expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, '.git', 'commondir'), '.\n') - }) - - it("blocks creating a commondir in a submodule's git directory", async () => { - const result = await runSandboxedWrite( - '.git/modules/lib/commondir', - 'decoy', - ) - - expect(result.success).toBe(false) - expectNotWritten( - join(TEST_DIR, '.git', 'modules', 'lib', 'commondir'), - '.\n', - ) - }) - - it("blocks creating a nested repository's commondir", async () => { - const result = await runSandboxedWrite('nested/.git/commondir', 'decoy') - - expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, 'nested', '.git', 'commondir'), '.\n') - }) - - it('blocks creating .git/config.worktree', async () => { - // Read instead of .git/config wherever extensions.worktreeConfig is - // on, which `git sparse-checkout init` turns on. - const result = await runSandboxedWrite( - '.git/config.worktree', - 'fsmonitor = touch pwned', - ) - - expect(result.success).toBe(false) - expectNotWritten(join(TEST_DIR, '.git', 'config.worktree')) - }) - it('allows .git/config.worktree when allowGitConfig is true', async () => { try { const result = await runSandboxedWrite( @@ -3177,8 +3135,10 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { expect(hasGitConfigPattern).toBe(true) }) }) -describe('Git metadata deny paths - Unit Tests', () => { +// Read by the Linux and macOS backends alone. +describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { let dir: string + const savedCwd = process.cwd() beforeEach(() => { // Real, so a temporary directory that is itself reached through a @@ -3187,6 +3147,8 @@ describe('Git metadata deny paths - Unit Tests', () => { }) afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) rmSync(dir, { recursive: true, force: true }) }) @@ -3222,120 +3184,105 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'denies a git directory whole where an entry is a symlink, and what the link leads to', - () => { - // The deny of the entry lands where the link points - the Linux bind - // resolves the path, a Seatbelt filter matches where the write goes - - // so the link's own path carries nothing, and it sits in a directory - // the sandbox can write. The git directory whole is the handle on it. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const shared = join(dir, 'repo', 'shared-hooks') - mkdirSync(shared, { recursive: true }) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(shared, join(gitDir, 'hooks')) + it('denies a git directory whole where an entry is a symlink, and what the link leads to', () => { + // The deny of the entry lands where the link points - the Linux bind + // resolves the path, a Seatbelt filter matches where the write goes - + // so the link's own path carries nothing, and it sits in a directory + // the sandbox can write. The git directory whole is the handle on it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const shared = join(dir, 'repo', 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toEqual([ - join(gitDir, 'hooks'), - shared, - join(gitDir, 'commondir'), - join(gitDir, 'config'), - join(gitDir, 'config.worktree'), - ]) - // What a read-only bind of the git directory would not cover, and so - // what a degrade of it has to keep. - expect(denies.escapingDenyPaths).toEqual([shared]) - // What holds the link, for the backend whose denies resolve. - expect(denies.linkedEntryDirs).toEqual([gitDir]) - }, - ) + expect(denies.denyPaths).toEqual([ + join(gitDir, 'hooks'), + shared, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + // What a read-only bind of the git directory would not cover, and so + // what a degrade of it has to keep. + expect(denies.escapingDenyPaths).toEqual([shared]) + // What holds the link, for the backend whose denies resolve. + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }) - it.if(!isWindows)( - 'leaves an ordinary git directory exactly as it was', - () => { - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - writeFileSync(join(gitDir, 'config'), '[core]\n') + it('leaves an ordinary git directory exactly as it was', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + writeFileSync(join(gitDir, 'config'), '[core]\n') - expect(gitDirDenies(gitDir, false)).toEqual({ - denyPaths: gitDirDenyPaths(gitDir, false), - escapingDenyPaths: [], - linkedEntryDirs: [], - chainHops: [], - }) - }, - ) + expect(gitDirDenies(gitDir, false)).toEqual({ + denyPaths: gitDirDenyPaths(gitDir, false), + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) - it.if(!isWindows)( - 'keeps a link inside the git directory out of the escaping set', - () => { - // hooks -> hooks.real, both under the git directory: the bind that - // denies the directory whole covers the target as well, so a degrade - // has nothing to keep. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - mkdirSync(join(gitDir, 'hooks.real'), { recursive: true }) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(join(gitDir, 'hooks.real'), join(gitDir, 'hooks')) + it('keeps a link inside the git directory out of the escaping set', () => { + // hooks -> hooks.real, both under the git directory: the bind that + // denies the directory whole covers the target as well, so a degrade + // has nothing to keep. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'hooks.real'), { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks.real'), join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.escapingDenyPaths).toEqual([]) - }, - ) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + }) - it.if(!isWindows)( - 'denies where a DANGLING entry lands, so the target cannot be created', - () => { - // The link is there and its target is not: a command can create the - // target and the host's git runs what it finds there. Denying the - // landing is what blocks creating it. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const landing = join(dir, 'repo', 'not-there-yet') - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(landing, join(gitDir, 'hooks')) + it('denies where a DANGLING entry lands, so the target cannot be created', () => { + // The link is there and its target is not: a command can create the + // target and the host's git runs what it finds there. Denying the + // landing is what blocks creating it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'not-there-yet') + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(landing, join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toContain(landing) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.escapingDenyPaths).toEqual([landing]) - }, - ) + expect(denies.denyPaths).toContain(landing) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([landing]) + }) - it.if(!isWindows)( - 'follows a chain of dangling links to where a write through it would land, and holds the hop', - () => { - // The landing's deny blocks creating it and the entry's deny holds the - // entry; the link in between is held by neither, and a command that - // points it elsewhere moves where the chain lands without touching - // either end. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const landing = join(dir, 'repo', 'end') - const middle = join(dir, 'repo', 'middle') - symlinkSync(landing, middle) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(middle, join(gitDir, 'hooks')) + it('follows a chain of dangling links to where a write through it would land, and holds the hop', () => { + // The landing's deny blocks creating it and the entry's deny holds the + // entry; the link in between is held by neither, and a command that + // points it elsewhere moves where the chain lands without touching + // either end. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'end') + const middle = join(dir, 'repo', 'middle') + symlinkSync(landing, middle) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(middle, join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toContain(landing) - expect(denies.denyPaths).toContain(middle) - expect(denies.escapingDenyPaths).toContain(middle) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'resolved', - source: join(gitDir, 'hooks'), - link: middle, - holder: join(dir, 'repo'), - }, - ]) - }, - ) + expect(denies.denyPaths).toContain(landing) + expect(denies.denyPaths).toContain(middle) + expect(denies.escapingDenyPaths).toContain(middle) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: middle, + holder: join(dir, 'repo'), + }, + ]) + }) - it.if(!isWindows)('carries every hop of a two-link chain', () => { + it('carries every hop of a two-link chain', () => { // hooks -> ../hooks-link -> .githooks: the entry and the landing are // denied, and the link between them is a name of its own. const gitDir = makeGitDir(join(dir, 'repo', '.git')) @@ -3369,69 +3316,63 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'names a symlinked directory component on the way as a hop', - () => { - // hooks -> ../shared/hooks with shared -> shared-real: the hop is a - // directory in the middle of the path, not a second link at the end, - // and retargeting it moves the chain just the same. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const real = join(dir, 'repo', 'shared-real') - mkdirSync(join(real, 'hooks'), { recursive: true }) - const hop = join(dir, 'repo', 'shared') - symlinkSync('shared-real', hop) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync('../shared/hooks', join(gitDir, 'hooks')) + it('names a symlinked directory component on the way as a hop', () => { + // hooks -> ../shared/hooks with shared -> shared-real: the hop is a + // directory in the middle of the path, not a second link at the end, + // and retargeting it moves the chain just the same. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const real = join(dir, 'repo', 'shared-real') + mkdirSync(join(real, 'hooks'), { recursive: true }) + const hop = join(dir, 'repo', 'shared') + symlinkSync('shared-real', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../shared/hooks', join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toContain(join(real, 'hooks')) - expect(denies.denyPaths).toContain(hop) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'resolved', - source: join(gitDir, 'hooks'), - link: hop, - holder: join(dir, 'repo'), - }, - ]) - }, - ) + expect(denies.denyPaths).toContain(join(real, 'hooks')) + expect(denies.denyPaths).toContain(hop) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }) - it.if(!isWindows)( - 'leaves a hop inside the git directory to the whole-directory deny', - () => { - // hooks -> hooks-link -> ../.githooks, with the hop under the git - // directory: the bind that denies the directory whole already holds - // it, so it needs no directory of its own and a degrade may drop it. - // Its own path is still named, for the backend that has no such bind. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const landing = join(dir, 'repo', '.githooks') - const hop = join(gitDir, 'hooks-link') - mkdirSync(landing, { recursive: true }) - symlinkSync('../.githooks', hop) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync('hooks-link', join(gitDir, 'hooks')) + it('leaves a hop inside the git directory to the whole-directory deny', () => { + // hooks -> hooks-link -> ../.githooks, with the hop under the git + // directory: the bind that denies the directory whole already holds + // it, so it needs no directory of its own and a degrade may drop it. + // Its own path is still named, for the backend that has no such bind. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const hop = join(gitDir, 'hooks-link') + mkdirSync(landing, { recursive: true }) + symlinkSync('../.githooks', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('hooks-link', join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toContain(hop) - expect(denies.escapingDenyPaths).toEqual([landing]) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'resolved', - source: join(gitDir, 'hooks'), - link: hop, - holder: undefined, - }, - ]) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - }, - ) + expect(denies.denyPaths).toContain(hop) + expect(denies.escapingDenyPaths).toEqual([landing]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: undefined, + }, + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }) - it.if(!isWindows && process.getuid?.() !== 0)( + it.if(process.getuid?.() !== 0)( 'holds the hops of a chain it could read where a later one cannot be', () => { // The chain runs into a directory this process cannot search, so what @@ -3467,79 +3408,73 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) - it.if(!isWindows)( - 'denies the git directory whole for an entry that is a symlink loop, and nothing behind it', - () => { - // A loop reaches nothing: there is nothing to deny at the other end, - // and nothing a command can create through it. Replacing the link is - // what the whole-directory deny is for. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(join(gitDir, 'hooks'), join(gitDir, 'hooks')) + it('denies the git directory whole for an entry that is a symlink loop, and nothing behind it', () => { + // A loop reaches nothing: there is nothing to deny at the other end, + // and nothing a command can create through it. Replacing the link is + // what the whole-directory deny is for. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks'), join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toEqual([ - join(gitDir, 'commondir'), - join(gitDir, 'config'), - join(gitDir, 'config.worktree'), - ]) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.escapingDenyPaths).toEqual([]) - // A link onto itself is the one link of its own chain, and the whole - // deny of the directory holding it covers that: no hop is left over. - expect(denies.chainHops).toEqual([]) - }, - ) + expect(denies.denyPaths).toEqual([ + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + // A link onto itself is the one link of its own chain, and the whole + // deny of the directory holding it covers that: no hop is left over. + expect(denies.chainHops).toEqual([]) + }) - it.if(!isWindows)( - 'holds the links a looping entry goes through, which are what makes it loop', - () => { - // hooks -> ../links/a, a -> b, b -> a. The entry reaches nothing while - // that stands, and the whole-directory deny holds the entry, but the - // two links are ordinary work-tree names: a command that puts a - // directory at either one makes the chain reach it, and the host's git - // runs what is inside. Every link the walk visited before it gave up is - // therefore a hop, named once however many times the loop met it. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const links = join(dir, 'repo', 'links') - mkdirSync(links, { recursive: true }) - symlinkSync('b', join(links, 'a')) - symlinkSync('a', join(links, 'b')) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync('../links/a', join(gitDir, 'hooks')) + it('holds the links a looping entry goes through, which are what makes it loop', () => { + // hooks -> ../links/a, a -> b, b -> a. The entry reaches nothing while + // that stands, and the whole-directory deny holds the entry, but the + // two links are ordinary work-tree names: a command that puts a + // directory at either one makes the chain reach it, and the host's git + // runs what is inside. Every link the walk visited before it gave up is + // therefore a hop, named once however many times the loop met it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const links = join(dir, 'repo', 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../links/a', join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toContain(join(links, 'a')) - expect(denies.denyPaths).toContain(join(links, 'b')) - // Neither is under the git directory, so a degrade to one bind of it - // must not drop them. - expect(denies.escapingDenyPaths).toEqual([ - join(links, 'a'), - join(links, 'b'), - ]) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'unresolvable', - source: join(gitDir, 'hooks'), - link: join(links, 'a'), - holder: links, - }, - { - kind: 'entry', - chain: 'unresolvable', - source: join(gitDir, 'hooks'), - link: join(links, 'b'), - holder: links, - }, - ]) - }, - ) + expect(denies.denyPaths).toContain(join(links, 'a')) + expect(denies.denyPaths).toContain(join(links, 'b')) + // Neither is under the git directory, so a degrade to one bind of it + // must not drop them. + expect(denies.escapingDenyPaths).toEqual([ + join(links, 'a'), + join(links, 'b'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'a'), + holder: links, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'b'), + holder: links, + }, + ]) + }) - it.if(!isWindows)('holds the hops before a loop it runs into', () => { + it('holds the hops before a loop it runs into', () => { // Two ordinary hops and then the loop: the chain gives up in the middle, // and the links it walked to get there are held like any others. const gitDir = makeGitDir(join(dir, 'repo', '.git')) @@ -3565,42 +3500,39 @@ describe('Git metadata deny paths - Unit Tests', () => { ) }) - it.if(!isWindows)( - 'leaves a loop confined to the git directory to the whole-directory deny', - () => { - // Both links live under the git directory, which is denied whole for - // the entry anyway: each hop names no holder of its own, and nothing a - // command can write is left over. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - symlinkSync('inner-b', join(gitDir, 'inner-a')) - symlinkSync('inner-a', join(gitDir, 'inner-b')) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync('inner-a', join(gitDir, 'hooks')) + it('leaves a loop confined to the git directory to the whole-directory deny', () => { + // Both links live under the git directory, which is denied whole for + // the entry anyway: each hop names no holder of its own, and nothing a + // command can write is left over. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync('inner-b', join(gitDir, 'inner-a')) + symlinkSync('inner-a', join(gitDir, 'inner-b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('inner-a', join(gitDir, 'hooks')) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.escapingDenyPaths).toEqual([]) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'unresolvable', - source: join(gitDir, 'hooks'), - link: join(gitDir, 'inner-a'), - holder: undefined, - }, - { - kind: 'entry', - chain: 'unresolvable', - source: join(gitDir, 'hooks'), - link: join(gitDir, 'inner-b'), - holder: undefined, - }, - ]) - }, - ) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-a'), + holder: undefined, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-b'), + holder: undefined, + }, + ]) + }) - it.if(!isWindows)('holds a modules directory that is a symlink', () => { + it('holds a modules directory that is a symlink', () => { // `modules` is no deny path of its own - denying it whole would take // every submodule git directory under it read-only - but removing the // link and putting a directory in its place aims every submodule this @@ -3608,7 +3540,7 @@ describe('Git metadata deny paths - Unit Tests', () => { // its own name for the backend that matches a rename, and this git // directory denied whole for the one whose denies resolve. const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const elsewhere = join(dir, 'nm') + const elsewhere = join(dir, 'repo', 'nm') mkdirSync(elsewhere, { recursive: true }) symlinkSync('../nm', join(gitDir, 'modules')) @@ -3630,80 +3562,68 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'leaves an ordinary modules directory out of the denies entirely', - () => { - // The other half: a real `modules` is where every submodule git - // directory lives, and each of those is denied precisely. Naming it - // would take them all read-only. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - mkdirSync(join(gitDir, 'modules', 'lib'), { recursive: true }) + it('leaves an ordinary modules directory out of the denies entirely', () => { + // The other half: a real `modules` is where every submodule git + // directory lives, and each of those is denied precisely. Naming it + // would take them all read-only. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'modules', 'lib'), { recursive: true }) - const denies = gitDirDenies(gitDir, false) + const denies = gitDirDenies(gitDir, false) - expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) - expect(denies.linkedEntryDirs).toEqual([]) - expect(denies.chainHops).toEqual([]) - }, - ) + expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + expect(denies.linkedEntryDirs).toEqual([]) + expect(denies.chainHops).toEqual([]) + }) - it.if(!isWindows)( - "holds a submodule's own modules directory, and the chain past it", - () => { - // The shape the whole of this exists for: a submodule git directory - // whose own `modules` leads out of the repository through a link. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) - const links = join(dir, 'repo', 'links') - mkdirSync(join(dir, 'repo', 'nm', 'bar'), { recursive: true }) - mkdirSync(links, { recursive: true }) - symlinkSync('../nm', join(links, 'hop')) - symlinkSync('../../../links/hop', join(submodule, 'modules')) - - const denies = gitDirDenies(submodule, false) - - expect(denies.denyPaths).toContain(join(submodule, 'modules')) - expect(denies.linkedEntryDirs).toEqual([submodule]) - // The link in the middle is a hop like any other, held by the - // directory it sits in. - expect(denies.chainHops.map(hop => hop.link)).toEqual([ - join(submodule, 'modules'), - join(links, 'hop'), - ]) - expect(denies.chainHops.map(hop => hop.holder)).toEqual([ - undefined, - links, - ]) - }, - ) + it("holds a submodule's own modules directory, and the chain past it", () => { + // The shape the whole of this exists for: a submodule git directory + // whose own `modules` leads out of the repository through a link. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) + const links = join(dir, 'repo', 'links') + mkdirSync(join(dir, 'repo', 'nm', 'bar'), { recursive: true }) + mkdirSync(links, { recursive: true }) + symlinkSync('../nm', join(links, 'hop')) + symlinkSync('../../../links/hop', join(submodule, 'modules')) - it.if(!isWindows)( - 'holds the hops of an entry whose chain names something too long to exist', - () => { - // ENAMETOOLONG, not a loop: the chain reaches nothing all the same, and - // the link it went through is a name a command can point elsewhere. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const hop = join(dir, 'repo', 'hop') - symlinkSync(`../${'n'.repeat(300)}`, hop) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync('../hop', join(gitDir, 'hooks')) + const denies = gitDirDenies(submodule, false) - const denies = gitDirDenies(gitDir, false) + expect(denies.denyPaths).toContain(join(submodule, 'modules')) + expect(denies.linkedEntryDirs).toEqual([submodule]) + // The link in the middle is a hop like any other, held by the + // directory it sits in. + expect(denies.chainHops.map(hop => hop.link)).toEqual([ + join(submodule, 'modules'), + join(links, 'hop'), + ]) + expect(denies.chainHops.map(hop => hop.holder)).toEqual([undefined, links]) + }) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - expect(denies.chainHops).toEqual([ - { - kind: 'entry', - chain: 'unresolvable', - source: join(gitDir, 'hooks'), - link: hop, - holder: join(dir, 'repo'), - }, - ]) - }, - ) + it('holds the hops of an entry whose chain names something too long to exist', () => { + // ENAMETOOLONG, not a loop: the chain reaches nothing all the same, and + // the link it went through is a name a command can point elsewhere. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const hop = join(dir, 'repo', 'hop') + symlinkSync(`../${'n'.repeat(300)}`, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../hop', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }) - it.if(!isWindows && process.getuid?.() !== 0)( + it.if(process.getuid?.() !== 0)( 'takes an entry it cannot look at for one that needs both denies', () => { // lstat refused: whether it is a link is unknown, so the entry keeps @@ -3729,85 +3649,76 @@ describe('Git metadata deny paths - Unit Tests', () => { }, ) - it.if(!isWindows)( - 'denies whole a git directory a pointer leads to, where an entry of it is a symlink', - () => { - // A pointer's target is a git directory like any other: the link - // inside it is replaceable where nothing holds the directory around - // it, and the backend whose denies resolve needs that held. - const gitDir = makeGitDir(join(dir, 'gitdir')) - const shared = join(dir, 'shared-hooks') - mkdirSync(shared, { recursive: true }) - rmSync(join(gitDir, 'hooks'), { recursive: true }) - symlinkSync(shared, join(gitDir, 'hooks')) - const pointer = makePointer('checkout', '../gitdir') + it('denies whole a git directory a pointer leads to, where an entry of it is a symlink', () => { + // A pointer's target is a git directory like any other: the link + // inside it is replaceable where nothing holds the directory around + // it, and the backend whose denies resolve needs that held. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const shared = join(dir, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const pointer = makePointer('checkout', '../gitdir') - const denies = gitFileDenies(pointer, false) + const denies = gitFileDenies(pointer, false) - expect(denies.denyPaths).toContain(shared) - expect(denies.linkedEntryDirs).toEqual([gitDir]) - // The list a backend that needs none of that emits is what it was. - expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) - }, - ) + expect(denies.denyPaths).toContain(shared) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + // The list a backend that needs none of that emits is what it was. + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) + }) - it.if(!isWindows)( - 'denies the directory holding a symlinked modules entry, and where a dangling one lands', - () => { - // The walk follows the link and denies the git directory it reaches; - // the link itself stays replaceable in a writable `modules`, and a - // pointer that names it would then find whatever was put in its place. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const modules = join(gitDir, 'modules') - const outside = makeGitDir(join(dir, 'elsewhere')) - mkdirSync(modules, { recursive: true }) - symlinkSync(outside, join(modules, 'lib')) - const landing = join(dir, 'repo', 'not-there-yet') - symlinkSync(landing, join(modules, 'gone')) - - const scan = submoduleGitDirs(modules) - - expect(scan.linkedEntryDirs).toEqual([modules]) - expect(scan.gitDirs).toEqual([join(modules, 'lib'), landing].sort()) - const tree = gitDirTreeDenies(gitDir, false) - expect(tree.linkedEntryDirs).toEqual([modules]) - expect(gitDirTreeDenyPaths(tree)).toContain(join(landing, 'hooks')) - }, - ) + it('denies the directory holding a symlinked modules entry, and where a dangling one lands', () => { + // The walk follows the link and denies the git directory it reaches; + // the link itself stays replaceable in a writable `modules`, and a + // pointer that names it would then find whatever was put in its place. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, join(modules, 'lib')) + const landing = join(dir, 'repo', 'not-there-yet') + symlinkSync(landing, join(modules, 'gone')) - it.if(!isWindows)( - 'holds the hops between a symlinked modules entry and the git directory it reaches', - () => { - // modules/lib -> ../../mid -> elsewhere: the deny of `modules` holds - // the entry and the git directory it reaches keeps its own denies, but - // the link in between sits in the work tree and moves the whole chain. - const gitDir = makeGitDir(join(dir, 'repo', '.git')) - const modules = join(gitDir, 'modules') - const outside = makeGitDir(join(dir, 'elsewhere')) - const hop = join(dir, 'repo', 'mid') - mkdirSync(modules, { recursive: true }) - symlinkSync(outside, hop) - symlinkSync(hop, join(modules, 'lib')) + const scan = submoduleGitDirs(modules) - const scan = submoduleGitDirs(modules) + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.gitDirs).toEqual([join(modules, 'lib'), landing].sort()) + const tree = gitDirTreeDenies(gitDir, false) + expect(tree.linkedEntryDirs).toEqual([modules]) + expect(gitDirTreeDenyPaths(tree)).toContain(join(landing, 'hooks')) + }) - expect(scan.linkedEntryDirs).toEqual([modules]) - expect(scan.chainHops).toEqual([ - { - kind: 'entry', - chain: 'resolved', - source: join(modules, 'lib'), - link: hop, - holder: join(dir, 'repo'), - }, - ]) - // The hop's own path has no other list to sit in, so the flat form is - // where a backend reads it. - const tree = gitDirTreeDenies(gitDir, false) - expect(gitDirTreeDenyPaths(tree)).toContain(hop) - expect(tree.chainHops).toEqual(scan.chainHops) - }, - ) + it('holds the hops between a symlinked modules entry and the git directory it reaches', () => { + // modules/lib -> ../../mid -> elsewhere: the deny of `modules` holds + // the entry and the git directory it reaches keeps its own denies, but + // the link in between sits in the work tree and moves the whole chain. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + const hop = join(dir, 'repo', 'mid') + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, hop) + symlinkSync(hop, join(modules, 'lib')) + + const scan = submoduleGitDirs(modules) + + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(modules, 'lib'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + // The hop's own path has no other list to sit in, so the flat form is + // where a backend reads it. + const tree = gitDirTreeDenies(gitDir, false) + expect(gitDirTreeDenyPaths(tree)).toContain(hop) + expect(tree.chainHops).toEqual(scan.chainHops) + }) it('gives the files git reads a placeholder that is no redirect', () => { // `.` is the git directory itself, which is where git looks when there @@ -3926,7 +3837,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(gitFileDenies(pastBound, false).denyPaths).toEqual([pastBound]) }) - it.if(!isWindows)('takes a path that spans lines whole, as git does', () => { + it('takes a path that spans lines whole, as git does', () => { // Only the newline bytes at the END of the file are stripped, so one in // the middle is part of the directory name. const gitDir = makeGitDir(join(dir, 'two\nlines')) @@ -4013,42 +3924,39 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(denyPaths).not.toContain(join(main, 'hooks')) }) - it.if(!isWindows)( - 'denies where a .. after a symlink lands, the lexical path and the link between', - () => { - // checkout/hop is real/side, so the kernel reads hop/../evil as - // real/evil while folding it on paper gives checkout/evil. git opens - // the first; denying only the second leaves its hooks writable. The - // link itself is a third thing: retargeting it moves where git lands - // without touching the pointer file or either directory. - const physical = makeGitDir(join(dir, 'real', 'evil')) - mkdirSync(join(dir, 'real', 'side'), { recursive: true }) - const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') - const hop = join(dir, 'checkout', 'hop') - symlinkSync(join(dir, 'real', 'side'), hop) - - const denies = gitFileDenies(pointer, false) - - expect(denies.denyPaths).toEqual([ - pointer, - hop, - ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), - ...gitDirDenyPaths(physical, false), - ]) - expect(denies.chainHops).toEqual([ - { - kind: 'pointer', - chain: 'resolved', - source: pointer, - link: hop, - holder: join(dir, 'checkout'), - }, - ]) - expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) - }, - ) + it('denies where a .. after a symlink lands, the lexical path and the link between', () => { + // checkout/hop is real/side, so the kernel reads hop/../evil as + // real/evil while folding it on paper gives checkout/evil. git opens + // the first; denying only the second leaves its hooks writable. The + // link itself is a third thing: retargeting it moves where git lands + // without touching the pointer file or either directory. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + const hop = join(dir, 'checkout', 'hop') + symlinkSync(join(dir, 'real', 'side'), hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) + }) - it.if(!isWindows)('denies both when both are git directories', () => { + it('denies both when both are git directories', () => { const lexical = makeGitDir(join(dir, 'checkout', 'evil')) const physical = makeGitDir(join(dir, 'real', 'evil')) mkdirSync(join(dir, 'real', 'side'), { recursive: true }) @@ -4063,7 +3971,7 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)('walks a chain of symlinks as the kernel does', () => { + it('walks a chain of symlinks as the kernel does', () => { // first is second, second is an absolute path to real/side/deeper. Both // links are on the way to what git opens, and each is held. const physical = makeGitDir(join(dir, 'real', 'evil')) @@ -4091,37 +3999,34 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'takes the rest of a path as written past what is not there', - () => { - // The kernel cannot traverse a missing directory, so nothing after it - // redirects the path: gone/x is denied against being created, and so - // is the lexical checkout/x. The link it did read is held all the same. - const pointer = writePointer('checkout', 'gitdir: hop/../x\n') - const hop = join(dir, 'checkout', 'hop') - symlinkSync('gone/deeper', hop) - - const denies = gitFileDenies(pointer, false) - - expect(denies.denyPaths).toEqual([ - pointer, - hop, - ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), - ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), - ]) - expect(denies.chainHops).toEqual([ - { - kind: 'pointer', - chain: 'resolved', - source: pointer, - link: hop, - holder: join(dir, 'checkout'), - }, - ]) - }, - ) + it('takes the rest of a path as written past what is not there', () => { + // The kernel cannot traverse a missing directory, so nothing after it + // redirects the path: gone/x is denied against being created, and so + // is the lexical checkout/x. The link it did read is held all the same. + const pointer = writePointer('checkout', 'gitdir: hop/../x\n') + const hop = join(dir, 'checkout', 'hop') + symlinkSync('gone/deeper', hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), + ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) - it.if(!isWindows)('denies what it can reach when symlinks loop', () => { + it('denies what it can reach when symlinks loop', () => { const pointer = writePointer('checkout', 'gitdir: loopA/../evil\n') symlinkSync(join(dir, 'checkout', 'loopB'), join(dir, 'checkout', 'loopA')) symlinkSync(join(dir, 'checkout', 'loopA'), join(dir, 'checkout', 'loopB')) @@ -4157,32 +4062,29 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'never widens a looping pointer value to a write root', - () => { - // The shape a sandboxed command can plant in one write: a .git file - // whose value runs into a loop in the checkout itself. Nothing here - // names the checkout or any directory above it, so the commands after - // it still write the project; the pointer file and the loop's links - // are what they cannot touch. - const pointer = writePointer('checkout', 'gitdir: loop/gd\n') - symlinkSync('loop2', join(dir, 'checkout', 'loop')) - symlinkSync('loop', join(dir, 'checkout', 'loop2')) - - const denies = gitFileDenies(pointer, false) - - expect(denies.denyPaths).toEqual([ - pointer, - join(dir, 'checkout', 'loop'), - join(dir, 'checkout', 'loop2'), - ]) - expect(denies.denyPaths).not.toContain(join(dir, 'checkout')) - expect(denies.denyPaths).not.toContain(dir) - expect(denies.linkedEntryDirs).toEqual([]) - }, - ) + it('never widens a looping pointer value to a write root', () => { + // The shape a sandboxed command can plant in one write: a .git file + // whose value runs into a loop in the checkout itself. Nothing here + // names the checkout or any directory above it, so the commands after + // it still write the project; the pointer file and the loop's links + // are what they cannot touch. + const pointer = writePointer('checkout', 'gitdir: loop/gd\n') + symlinkSync('loop2', join(dir, 'checkout', 'loop')) + symlinkSync('loop', join(dir, 'checkout', 'loop2')) + + const denies = gitFileDenies(pointer, false) - it.if(!isWindows)('resolves a commondir the same way', () => { + expect(denies.denyPaths).toEqual([ + pointer, + join(dir, 'checkout', 'loop'), + join(dir, 'checkout', 'loop2'), + ]) + expect(denies.denyPaths).not.toContain(join(dir, 'checkout')) + expect(denies.denyPaths).not.toContain(dir) + expect(denies.linkedEntryDirs).toEqual([]) + }) + + it('resolves a commondir the same way', () => { const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) const physical = makeGitDir(join(dir, 'real', 'common')) const hop = join(worktreeGitDir, 'hop') @@ -4213,64 +4115,58 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows)( - 'resolves a .. against the directory the pointer file is really in', - () => { - // The checkout is reached through a symlink, so `..` pops the - // directory the kernel is in and not the one the path was spelled - // from — nested/target, not dir/target. - const physical = makeGitDir(join(dir, 'nested', 'target')) - mkdirSync(join(dir, 'nested', 'checkout'), { recursive: true }) - symlinkSync(join(dir, 'nested', 'checkout'), join(dir, 'link')) - const pointer = join(dir, 'link', '.git') - writeFileSync(pointer, 'gitdir: ../target\n') - - const denies = gitFileDenies(pointer, false) - - expect(denies.denyPaths).toEqual([ - pointer, - ...gitDirDenyPaths(join(dir, 'target'), false), - ...gitDirDenyPaths(physical, false), - ]) - // The link ABOVE the pointer file is where this checkout lives, not a - // hop of the chain its value walks: the walk starts where the kernel - // already is, so nothing here is held for it. - expect(denies.chainHops).toEqual([]) - }, - ) + it('resolves a .. against the directory the pointer file is really in', () => { + // The checkout is reached through a symlink, so `..` pops the + // directory the kernel is in and not the one the path was spelled + // from — nested/target, not dir/target. + const physical = makeGitDir(join(dir, 'nested', 'target')) + mkdirSync(join(dir, 'nested', 'checkout'), { recursive: true }) + symlinkSync(join(dir, 'nested', 'checkout'), join(dir, 'link')) + const pointer = join(dir, 'link', '.git') + writeFileSync(pointer, 'gitdir: ../target\n') - it.if(!isWindows)( - 'holds a symlinked directory component of a value with no .. in it', - () => { - // `gitdir: linkdir/gd` reaches the git directory through `linkdir`, - // and git opens what the link leads to. Nothing else in the deny list - // names the link, so without this a command retargets it and every - // later git run in this checkout reads a git directory of its choosing. - makeGitDir(join(dir, 'realdir', 'gd')) - const pointer = writePointer('checkout', 'gitdir: linkdir/gd\n') - const hop = join(dir, 'checkout', 'linkdir') - symlinkSync(join(dir, 'realdir'), hop) - - const denies = gitFileDenies(pointer, false) - - expect(denies.denyPaths).toEqual([ - pointer, - hop, - ...gitDirDenyPaths(join(hop, 'gd'), false), - ]) - expect(denies.chainHops).toEqual([ - { - kind: 'pointer', - chain: 'resolved', - source: pointer, - link: hop, - holder: join(dir, 'checkout'), - }, - ]) - }, - ) + const denies = gitFileDenies(pointer, false) - it.if(!isWindows)('holds a link of a value that leads nowhere yet', () => { + expect(denies.denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'target'), false), + ...gitDirDenyPaths(physical, false), + ]) + // The link ABOVE the pointer file is where this checkout lives, not a + // hop of the chain its value walks: the walk starts where the kernel + // already is, so nothing here is held for it. + expect(denies.chainHops).toEqual([]) + }) + + it('holds a symlinked directory component of a value with no .. in it', () => { + // `gitdir: linkdir/gd` reaches the git directory through `linkdir`, + // and git opens what the link leads to. Nothing else in the deny list + // names the link, so without this a command retargets it and every + // later git run in this checkout reads a git directory of its choosing. + makeGitDir(join(dir, 'realdir', 'gd')) + const pointer = writePointer('checkout', 'gitdir: linkdir/gd\n') + const hop = join(dir, 'checkout', 'linkdir') + symlinkSync(join(dir, 'realdir'), hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(hop, 'gd'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) + + it('holds a link of a value that leads nowhere yet', () => { // `mid` is there and what it names is not: the deny goes where the path // lands, so a command cannot fill that git directory in, and the link is // held so it cannot be aimed at one that already exists either. @@ -4296,7 +4192,7 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows && process.getuid?.() !== 0)( + it.if(process.getuid?.() !== 0)( 'holds the link it read where what lies past it cannot be', () => { // The chain runs into a directory this process cannot search, so the @@ -4363,13 +4259,8 @@ describe('Git metadata deny paths - Unit Tests', () => { /** `linuxGetCwdMandatoryDenyPaths` from `checkout`. */ function denyPathsIn(checkout: string, allowWritePaths: string[]) { - const originalCwd = process.cwd() process.chdir(checkout) - try { - return linuxGetCwdMandatoryDenyPaths(false, allowWritePaths) - } finally { - process.chdir(originalCwd) - } + return linuxGetCwdMandatoryDenyPaths(false, allowWritePaths) } it('never denies the working directory a pointer of its own names', () => { @@ -4476,23 +4367,18 @@ describe('Git metadata deny paths - Unit Tests', () => { ) const pointer = makePointer('wt-checkout', worktreeGitDir) const checkout = join(dir, 'wt-checkout') - const originalCwd = process.cwd() process.chdir(checkout) - try { - expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( - GitMetadataError, - ) + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) - const monitored = linuxGetMonitorCwdDenyPaths(false) + const monitored = linuxGetMonitorCwdDenyPaths(false) - expect(monitored).toContain(pointer) - expect(monitored).toContain(join(checkout, '.bashrc')) - // A pointer file is itself the deny; the hooks and config of a git - // directory that is not there are not denies the wrapper makes. - expect(monitored).not.toContain(join(pointer, 'hooks')) - } finally { - process.chdir(originalCwd) - } + expect(monitored).toContain(pointer) + expect(monitored).toContain(join(checkout, '.bashrc')) + // A pointer file is itself the deny; the hooks and config of a git + // directory that is not there are not denies the wrapper makes. + expect(monitored).not.toContain(join(pointer, 'hooks')) }, ) @@ -4502,17 +4388,12 @@ describe('Git metadata deny paths - Unit Tests', () => { // never see refused. const checkout = join(dir, 'no-repo') mkdirSync(checkout, { recursive: true }) - const originalCwd = process.cwd() process.chdir(checkout) - try { - const monitored = linuxGetMonitorCwdDenyPaths(false) + const monitored = linuxGetMonitorCwdDenyPaths(false) - expect(monitored).toContain(join(checkout, '.bashrc')) - expect(monitored).not.toContain(join(checkout, '.git', 'hooks')) - expect(monitored).not.toContain(join(checkout, '.git', 'config')) - } finally { - process.chdir(originalCwd) - } + expect(monitored).toContain(join(checkout, '.bashrc')) + expect(monitored).not.toContain(join(checkout, '.git', 'hooks')) + expect(monitored).not.toContain(join(checkout, '.git', 'config')) }) it.if(process.getuid?.() !== 0)( @@ -4575,24 +4456,19 @@ describe('Git metadata deny paths - Unit Tests', () => { Buffer.from('\n'), ]), ) - const originalCwd = process.cwd() process.chdir(join(dir, 'checkout')) - try { - const error = await wrapCommandWithSandboxLinux({ - command: 'echo hi', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, - }).catch((e: unknown) => e) + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + }).catch((e: unknown) => e) - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe( - 'deny_git_metadata_unreadable', - ) - expect((error as Error).message).toContain(pointer) - } finally { - process.chdir(originalCwd) - } + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_git_metadata_unreadable', + ) + expect((error as Error).message).toContain(pointer) }, ) @@ -4605,30 +4481,24 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(error.code).toBe('git_metadata_unreadable') }) - it.if(!isWindows)( - 'does not block on a FIFO left where a git directory keeps its commondir', - () => { - const gitDir = makeGitDir(join(dir, 'gitdir')) - expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) - const pointer = makePointer('checkout', '../gitdir') + it('does not block on a FIFO left where a git directory keeps its commondir', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) + const pointer = makePointer('checkout', '../gitdir') - expect(gitFileDenies(pointer, false).denyPaths).toEqual([ - pointer, - ...gitDirDenyPaths(gitDir, false), - ]) - }, - ) + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) - it.if(!isWindows)( - 'does not block on a FIFO left where a pointer file goes', - () => { - mkdirSync(join(dir, 'checkout'), { recursive: true }) - const pointer = join(dir, 'checkout', '.git') - expect(spawnSync('mkfifo', [pointer]).status).toBe(0) + it('does not block on a FIFO left where a pointer file goes', () => { + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + expect(spawnSync('mkfifo', [pointer]).status).toBe(0) - expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) - }, - ) + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + }) it('recognises a submodule git directory without a HEAD', () => { const gitDir = join(dir, 'modules', 'lib') @@ -4650,7 +4520,7 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(scan.gitDirs.sort()).toEqual([outer, inner].sort()) }) - it.if(!isWindows)('follows a symlinked entry under modules', () => { + it('follows a symlinked entry under modules', () => { const gitDir = makeGitDir(join(dir, 'elsewhere')) mkdirSync(join(dir, 'modules'), { recursive: true }) symlinkSync(gitDir, join(dir, 'modules', 'lib')) @@ -4660,7 +4530,7 @@ describe('Git metadata deny paths - Unit Tests', () => { ]) }) - it.if(!isWindows && process.getuid?.() !== 0)( + it.if(process.getuid?.() !== 0)( 'denies a directory under modules it could not list', () => { const locked = join(dir, 'modules', 'locked') @@ -4682,40 +4552,37 @@ describe('Git metadata deny paths - Unit Tests', () => { * bounds the walk is the time it is given, and what stops it looping is * where each directory really is. */ - it.if(!isWindows)( - 'walks a chain hundreds of directories deep, and denies every level', - () => { - // Submodules each nested inside the one above, two directories of - // `/modules` per level: deep enough to overflow a recursive walk's - // own stack on some runtimes. How many levels fit is what the platform's - // longest path leaves room for once the temporary directory has had its - // share - macOS stops at 1024 bytes, and a path past it cannot be opened - // at all, while Windows stops at 260 and is left out here. - const budget = getPlatform() === 'macos' ? 950 : 4000 - const levels = Math.floor((budget - dir.length) / '/x/modules'.length) - expect(levels).toBeGreaterThan(50) - - const gitDirs: string[] = [] - let at = join(dir, 'modules') - for (let level = 0; level < levels; level++) { - at = join(at, 'x') - makeGitDir(at) - gitDirs.push(at) - at = join(at, 'modules') - } + it('walks a chain hundreds of directories deep, and denies every level', () => { + // Submodules each nested inside the one above, two directories of + // `/modules` per level: deep enough to overflow a recursive walk's + // own stack on some runtimes. How many levels fit is what the platform's + // longest path leaves room for once the temporary directory has had its + // share - macOS stops at 1024 bytes, and a path past it cannot be opened + // at all, while Windows stops at 260 and is left out here. + const budget = getPlatform() === 'macos' ? 950 : 4000 + const levels = Math.floor((budget - dir.length) / '/x/modules'.length) + expect(levels).toBeGreaterThan(50) + + const gitDirs: string[] = [] + let at = join(dir, 'modules') + for (let level = 0; level < levels; level++) { + at = join(at, 'x') + makeGitDir(at) + gitDirs.push(at) + at = join(at, 'modules') + } - const scan = submoduleGitDirs(join(dir, 'modules')) - expect(scan.unreadableDirs).toEqual([]) - expect(scan.gitDirs).toEqual([...gitDirs].sort()) - const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) - for (const gitDir of gitDirs) { - expect(denies).toContain(join(gitDir, 'hooks')) - expect(denies).toContain(join(gitDir, 'config')) - } - }, - ) + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.unreadableDirs).toEqual([]) + expect(scan.gitDirs).toEqual([...gitDirs].sort()) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const gitDir of gitDirs) { + expect(denies).toContain(join(gitDir, 'hooks')) + expect(denies).toContain(join(gitDir, 'config')) + } + }) - it.if(!isWindows)('ends at a symlink pointing back into the tree', () => { + it('ends at a symlink pointing back into the tree', () => { // modules/a/back -> modules, which the walk would follow for ever if it // did not record where each directory really is. mkdirSync(join(dir, 'modules', 'a'), { recursive: true }) @@ -4730,46 +4597,39 @@ describe('Git metadata deny paths - Unit Tests', () => { }) }) - it.if(!isWindows)( - 'denies nothing for a symlink loop, which leads nowhere', - () => { - // A loop cannot be stat'ed at all (ELOOP) and reaches no directory, so - // there is nothing behind it to deny - and denying what HOLDS it would - // hand every command a way to take every submodule beside it read-only. - mkdirSync(join(dir, 'modules'), { recursive: true }) - symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) - symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) - const gitDir = makeGitDir(join(dir, 'modules', 'lib')) - - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ - gitDirs: [gitDir], - unreadableDirs: [], - linkedEntryDirs: [], - chainHops: [], - }) - }, - ) + it('denies nothing for a symlink loop, which leads nowhere', () => { + // A loop cannot be stat'ed at all (ELOOP) and reaches no directory, so + // there is nothing behind it to deny - and denying what HOLDS it would + // hand every command a way to take every submodule beside it read-only. + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) + symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) - it.if(!isWindows)( - 'costs one command nothing to plant thousands of loops', - () => { - mkdirSync(join(dir, 'modules'), { recursive: true }) - for (let i = 0; i < 2800; i++) { - symlinkSync(`s${i}`, join(dir, 'modules', `s${i}`)) - } - const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ - gitDirs: [gitDir], - unreadableDirs: [], - linkedEntryDirs: [], - chainHops: [], - }) - }, - 30000, - ) + it('costs one command nothing to plant thousands of loops', () => { + mkdirSync(join(dir, 'modules'), { recursive: true }) + for (let i = 0; i < 2800; i++) { + symlinkSync(`s${i}`, join(dir, 'modules', `s${i}`)) + } + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, 30000) - it.if(!isWindows && process.getuid?.() !== 0)( + it.if(process.getuid?.() !== 0)( 'denies where a symlinked entry leads, and the directory holding the link', () => { // Two links into a directory this process cannot search. What is BEHIND @@ -4821,28 +4681,25 @@ describe('Git metadata deny paths - Unit Tests', () => { expect(error?.message).toContain(join(dir, 'modules')) }) - it('stops inside one very wide directory rather than at the end of it', () => { - // The deadline is looked at as the entries go by, not once per - // directory: a `.git/modules` a command filled with fifty thousand - // entries is one directory, and none of these is one to walk into, so a - // walk that only checked the clock when it took a directory off its - // stack would work through every one of them before looking again. + it('looks at the clock inside one wide directory, not only between directories', () => { + // A `.git/modules` a command filled is one directory, and none of these + // entries is one to walk into. const modules = join(dir, 'modules') mkdirSync(modules, { recursive: true }) - writeFileSync(join(dir, 'target'), 'x') - for (let i = 0; i < 50000; i++) { - symlinkSync(join(dir, 'target'), join(modules, `s${i}`)) - } - - const started = Date.now() - expect(() => submoduleGitDirs(modules, Date.now() + 50)).toThrow( - SubmoduleWalkBudgetError, + for (let i = 0; i < 300; i++) writeFileSync(join(modules, `f${i}`), '') + const deadline = Date.now() + 60000 + let looks = 0 + const now = spyOn(Date, 'now').mockImplementation(() => + looks++ === 0 ? deadline - 1 : deadline + 1, ) - expect(Date.now() - started).toBeLessThan(2000) - // Removed here rather than by the hook after it: fifty thousand entries - // take longer to take away on some filesystems than a hook is given. - rmSync(modules, { recursive: true, force: true }) - }, 120000) + try { + expect(() => submoduleGitDirs(modules, deadline)).toThrow( + SubmoduleWalkBudgetError, + ) + } finally { + now.mockRestore() + } + }) it.if(isLinux)( 'refuses the wrap where the modules walk runs out of its time', @@ -4856,28 +4713,20 @@ describe('Git metadata deny paths - Unit Tests', () => { makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(4, '0')}`)) } - const originalCwd = process.cwd() process.chdir(checkout) - try { - const error = await wrapCommandWithSandboxLinux({ - command: 'echo hi', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - // The walk shares the scan's budget, so one millisecond of it is - // all the walk gets. - ripgrepConfig: { command: 'rg', timeoutMs: 1 }, - }).catch((e: unknown) => e) - - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe( - 'deny_scan_failed', - ) - expect((error as Error).message).toContain(join(gitDir, 'modules')) - } finally { - cleanupBwrapMountPoints({ force: true }) - process.chdir(originalCwd) - } + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + // The walk shares the scan's budget, so one millisecond of it is + // all the walk gets. + ripgrepConfig: { command: 'rg', timeoutMs: 1 }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe('deny_scan_failed') + expect((error as Error).message).toContain(join(gitDir, 'modules')) }, 30000, ) @@ -4885,23 +4734,26 @@ describe('Git metadata deny paths - Unit Tests', () => { it.if(isLinux)( 'gives a walk made after the scan a budget of its own', async () => { - // The scan has a timeout of its own and may use all of it. The walk of - // a nested repository the scan found runs after that and takes its own - // budget from the clock then: what a slow scan left would refuse a - // repository whose submodules a walk given the time gets through in - // microseconds. One millisecond, which no scan can even be started - // inside, is enough to tell the two apart. + // The scan has a timeout of its own and may use all of it. What a slow one + // left would refuse a repository whose submodules a walk gets through in + // microseconds. Here the clock jumps past the first budget as the scan + // ends. const checkout = join(dir, 'repo') makeGitDir(join(checkout, '.git')) const nestedGitDir = makeGitDir(join(checkout, 'nested', '.git')) const nestedSub = makeGitDir(join(nestedGitDir, 'modules', 'lib')) // A stand-in for ripgrep that reports the nested repository and exits. const scan = join(dir, 'scan.sh') + const scanned = join(dir, 'scanned') writeFileSync( scan, - `#!/bin/sh\nprintf '%s\\0' ${JSON.stringify(join(nestedGitDir, 'config'))}\n`, + `#!/bin/sh\nprintf '%s\\0' ${JSON.stringify(join(nestedGitDir, 'config'))}\n: > ${JSON.stringify(scanned)}\n`, { mode: 0o755 }, ) + const realNow = Date.now + const now = spyOn(Date, 'now').mockImplementation( + () => realNow() + (existsSync(scanned) ? 60000 : 0), + ) const originalCwd = process.cwd() process.chdir(checkout) @@ -4911,10 +4763,11 @@ describe('Git metadata deny paths - Unit Tests', () => { needsNetworkRestriction: false, readConfig: undefined, writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - ripgrepConfig: { command: scan, timeoutMs: 1 }, + ripgrepConfig: { command: scan, timeoutMs: 30000 }, }) expect(command).toContain(join(nestedSub, 'hooks')) } finally { + now.mockRestore() cleanupBwrapMountPoints({ force: true }) process.chdir(originalCwd) } @@ -4938,66 +4791,55 @@ describe('Git metadata deny paths - Unit Tests', () => { // directory's own submodules covers is pinned as well. const deeper = makeGitDir(join(deep, 'modules', 'lib')) - const originalCwd = process.cwd() process.chdir(checkout) - try { - const wrap = (command: string): Promise => - wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - }) - - // No deny covering the git directory whole, which would take its - // objects, refs and index with it. An ancestor pin spells that same - // self-bind, so where it sits is what tells the two apart: pins are - // spliced in before the write root's own bind, which makes the tree - // writable again, and a deny bind is emitted after it. - const command = await wrap('true') - const writeRootBind = indexOfMount( + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ command, - '--bind', - checkout, - checkout, - ) - expect(writeRootBind).toBeGreaterThan(-1) - expect(lastIndexOfMount(command, '--ro-bind', deep, deep)).toBeLessThan( - writeRootBind, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + // No deny covering the git directory whole, which would take its + // objects, refs and index with it. An ancestor pin spells that same + // self-bind, so where it sits is what tells the two apart: pins are + // spliced in before the write root's own bind, which makes the tree + // writable again, and a deny bind is emitted after it. + const command = await wrap('true') + const writeRootBind = indexOfMount(command, '--bind', checkout, checkout) + expect(writeRootBind).toBeGreaterThan(-1) + expect(lastIndexOfMount(command, '--ro-bind', deep, deep)).toBeLessThan( + writeRootBind, + ) + expect(command).toContain(`--ro-bind ${join(deep, 'hooks')} `) + // Its own submodule is denied by path too, and neither it nor the + // `modules` directory holding it is denied whole: a bind there would + // take that submodule's objects, refs and index with it. Only the + // ancestor pins spell those, and they sit before the write root's + // own bind. + expect(command).toContain(`--ro-bind ${join(deeper, 'hooks')} `) + expect( + lastIndexOfMount(command, '--ro-bind', deeper, deeper), + ).toBeLessThan(writeRootBind) + const modules = join(deep, 'modules') + expect( + lastIndexOfMount(command, '--ro-bind', modules, modules), + ).toBeLessThan(writeRootBind) + + // Where bwrap can run, prove it: what git needs writable in that + // submodule still is, and what makes a write into code is not. + if (bwrapCanNamespace()) { + const result = spawnSync( + await wrap( + `sh -c 'echo o > ${join(deep, 'objects', 'x')} && ` + + `! echo h > ${join(deep, 'hooks', 'x')} && ` + + `! echo c > ${join(deep, 'config')} && ` + + `! echo h > ${join(deeper, 'hooks', 'x')} && ` + + `echo SRT_DEEP_OK'`, + ), + { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, ) - expect(command).toContain(`--ro-bind ${join(deep, 'hooks')} `) - // Its own submodule is denied by path too, and neither it nor the - // `modules` directory holding it is denied whole: a bind there would - // take that submodule's objects, refs and index with it. Only the - // ancestor pins spell those, and they sit before the write root's - // own bind. - expect(command).toContain(`--ro-bind ${join(deeper, 'hooks')} `) - expect( - lastIndexOfMount(command, '--ro-bind', deeper, deeper), - ).toBeLessThan(writeRootBind) - const modules = join(deep, 'modules') - expect( - lastIndexOfMount(command, '--ro-bind', modules, modules), - ).toBeLessThan(writeRootBind) - - // Where bwrap can run, prove it: what git needs writable in that - // submodule still is, and what makes a write into code is not. - if (bwrapCanNamespace()) { - const result = spawnSync( - await wrap( - `sh -c 'echo o > ${join(deep, 'objects', 'x')} && ` + - `! echo h > ${join(deep, 'hooks', 'x')} && ` + - `! echo c > ${join(deep, 'config')} && ` + - `! echo h > ${join(deeper, 'hooks', 'x')} && ` + - `echo SRT_DEEP_OK'`, - ), - { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, - ) - expect(result.stdout).toContain('SRT_DEEP_OK') - } - } finally { - cleanupBwrapMountPoints({ force: true }) - process.chdir(originalCwd) + expect(result.stdout).toContain('SRT_DEEP_OK') } }, 60000, @@ -5014,26 +4856,21 @@ describe('Git metadata deny paths - Unit Tests', () => { makeGitDir(deep) const deeper = makeGitDir(join(deep, 'modules', 'lib')) - const originalCwd = process.cwd() process.chdir(checkout) - try { - // Profile generation is pure string building, so this runs anywhere. - const profile = wrapCommandWithSandboxMacOS({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - }) - expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) - expect(profile).toContain(`(subpath "${join(deeper, 'hooks')}")`) - // No deny covering either git directory whole, which would take its - // objects, refs and index with it. Seatbelt has no argument cap, so - // nothing is ever collapsed there. - expect(profile).not.toContain(`(subpath "${deep}")`) - expect(profile).not.toContain(`(subpath "${deeper}")`) - } finally { - process.chdir(originalCwd) - } + // Profile generation is pure string building, so this runs anywhere. + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) + expect(profile).toContain(`(subpath "${join(deeper, 'hooks')}")`) + // No deny covering either git directory whole, which would take its + // objects, refs and index with it. Seatbelt has no argument cap, so + // nothing is ever collapsed there. + expect(profile).not.toContain(`(subpath "${deep}")`) + expect(profile).not.toContain(`(subpath "${deeper}")`) }) }) @@ -5685,69 +5522,6 @@ describe.if(isSupportedPlatform)( 300000, ) - it.if(LIVE)( - 'holds both halves of a symlinked entry in a collapsed submodule', - async () => { - // Seven hundred submodule git directories, so the tail is collapsed - // to one read-only bind each. Two of the collapsed ones have a hooks - // that is a symlink: one to a directory inside the write root, one - // to a path that is not there yet. The bind covers the link and not - // what it leads to, and both halves have to hold. - const { checkout } = makeSuperproject(700) - const gitDir = join(checkout, '.git') - const planted = join(checkout, 'planted') - const landing = join(checkout, 'not-there-yet') - mkdirSync(planted, { recursive: true }) - // Names sorting last, so the collapse takes these first. - const live = makeGitDir(join(gitDir, 'modules', 'zz-live')) - const dangling = makeGitDir(join(gitDir, 'modules', 'zz-dangling')) - for (const [sub, target] of [ - [live, planted], - [dangling, landing], - ] as const) { - rmSync(join(sub, 'hooks'), { recursive: true }) - symlinkSync(target, join(sub, 'hooks')) - } - process.chdir(checkout) - - const command = await wrapCommandWithSandboxLinux({ - command: - 'echo BOOTED; ' + - `! echo x > ${join(planted, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + - `! mkdir -p ${landing} || echo LANDING_CREATABLE; ` + - `! echo x > ${join(landing, 'post-commit')} || echo LANDING_WRITABLE; ` + - `! rm -f ${join(live, 'hooks')} || echo LINK_UNLINKABLE; ` + - `! mv ${join(live, 'hooks')} ${join(live, 'hooks.moved')} || echo LINK_RENAMABLE; ` + - `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + - 'echo DONE', - needsNetworkRestriction: false, - allowAllUnixSockets: true, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 120000, - cwd: checkout, - }) - - expect(result.stdout).toContain('BOOTED') - expect(result.stdout).toContain('DONE') - expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') - expect(result.stdout).not.toContain('LANDING_CREATABLE') - expect(result.stdout).not.toContain('LANDING_WRITABLE') - expect(result.stdout).not.toContain('LINK_UNLINKABLE') - expect(result.stdout).not.toContain('LINK_RENAMABLE') - expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') - expect(existsSync(join(planted, 'post-commit'))).toBe(false) - // The landing itself is the wrap's own mount point, which is what - // blocks creating it; nothing of the command's is inside it. - expect(existsSync(join(landing, 'post-commit'))).toBe(false) - }, - 300000, - ) - it.if(LIVE)( 'holds a symlinked hooks and a symlinked modules entry where nothing is degraded', async () => { @@ -5812,81 +5586,6 @@ describe.if(isSupportedPlatform)( 300000, ) - it.if(LIVE)( - 'lets a nested repository take its first submodule while the wrap is degrading', - async () => { - // Nothing is denied for a repository that has no `.git/modules`, so - // there is no mount point planted where one would go and git can - // still make it - under a wrap degrading the working directory's own - // seven hundred submodules at the same time. - const git = Bun.which('git') as string - const { checkout } = makeSuperproject(703) - const run = (...args: string[]): void => { - const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) - expect(r.status).toBe(0) - } - const source = join(dir, 'source') - const nested = join(checkout, 'nested') - for (const repo of [source, nested]) { - mkdirSync(repo, { recursive: true }) - run('-c', 'init.defaultBranch=main', 'init', '-q', repo) - writeFileSync(join(repo, 'f.txt'), 'x\n') - run('-C', repo, 'add', 'f.txt') - run( - '-C', - repo, - '-c', - 'user.name=t', - '-c', - 'user.email=t@t', - '-c', - 'commit.gpgsign=false', - '-c', - 'core.hooksPath=/dev/null', - 'commit', - '-q', - '-m', - 'one', - ) - } - process.chdir(checkout) - - const command = await wrapCommandWithSandboxLinux({ - command: - 'echo BOOTED; ' + - `${git} -c protocol.file.allow=always -c user.name=t -c user.email=t@t ` + - `-C ${nested} submodule add -q ${source} sub || echo SUBMODULE_ADD_FAILED; ` + - 'echo DONE', - needsNetworkRestriction: false, - allowAllUnixSockets: true, - // The submodule is recorded in the nested repository's config, - // which is denied unless the caller allows git config writes. - allowGitConfig: true, - readConfig: undefined, - writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, - }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 120000, - cwd: checkout, - }) - - expect(result.stdout).toContain('BOOTED') - expect(result.stdout).toContain('DONE') - expect(result.stdout + result.stderr).not.toContain( - 'SUBMODULE_ADD_FAILED', - ) - expect(existsSync(join(nested, '.git', 'modules', 'sub'))).toBe(true) - // And nothing was left on the host where the deny would have been. - cleanupBwrapMountPoints({ force: true }) - expect(statSync(join(nested, '.git', 'modules')).isDirectory()).toBe( - true, - ) - }, - 300000, - ) - /** * A chain between a git directory entry and what it leads to is held hop * by hop: each hop's own path is a deny path, and on this backend, whose diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index 26d17fefb..21b9abafa 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -1071,14 +1071,7 @@ describe.if(!isWindows)('Linked worktree git directories', () => { const collapsed = worktreeGitDirs[worktreeGitDirs.length - 1] as string process.chdir(main) - const command = await wrap( - 'echo BOOTED; ' + - `echo x > ${join(collapsed, 'commondir')} || echo COLLAPSED_HELD; ` + - `echo x > ${join(precise, 'commondir')} || echo PRECISE_HELD; ` + - `echo x > ${join(precise, 'ORIG_HEAD')} && echo PRECISE_REST_WRITTEN; ` + - 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', - [main], - ) + const command = await wrap('true', [main]) const words = mountWords(command) expect(words.length).toBeLessThan(9000) @@ -1092,15 +1085,6 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(mounted(join(precise, 'commondir'))).toBe(true) expect(mounted(join(collapsed, 'commondir'))).toBe(false) expect(mounted(collapsed)).toBe(true) - - if (LIVE) { - const output = run(command, main) - expect(output).toContain('BOOTED') - expect(output).toContain('COLLAPSED_HELD') - expect(output).toContain('PRECISE_HELD') - expect(output).toContain('PRECISE_REST_WRITTEN') - expect(output).toContain('WORK_TREE_WRITTEN') - } }, 300000, ) From 2e7e8e7e6b91b607aec3649d7a4abf3d6f3fdfee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 05:26:09 +0000 Subject: [PATCH 136/141] mend what the last round of fixes broke A review of b163513..e992a35 found that those fixes had brought in four regressions and a lock-out. Each is closed here, with a case that fails without its fix. - One set of visited directories served every walk, whatever a directory was visited as. A command could link a `modules` into a `worktrees`, to have it visited there first, as what is denied the least, and the walk that would have denied its submodules then skipped it. The other way round cost a linked worktree its `gitdir` deny. A visit as one thing no longer stands in for a visit as another, and a `modules` is walked from the top even where it was walked into from elsewhere. - With the `modules` of a git directory looked at like any other directory, a `HEAD` left in it made its entries git's own by name, and hid the nested submodule `lfs/client`. It is still taken for what it holds, and its entries are all names again. - A link whose target is not valid UTF-8 refused the command wherever it was met. Under a `.git/modules` or a `.git/worktrees`, where a command can make one, it costs what any link there does: the directory holding it, whole. A directory there whose NAME is no text was passed over in silence, and is answered the same way. - An empty `commondir` had the git directory denied whole at once. Making the file and writing it are two steps, so that was also what a second command wrapped at the same moment found, and its `git commit` then failed. It is looked at again on the next plan. What a killed wrap left is collected in between and made anew; only what stays empty has the directory denied whole. - What a git directory behind a `.git` file keeps is enumerated only where the command may write some of it. In a linked worktree with the default write roots every command walked the whole repository for nothing, and one stale sibling worktree refused them all. - A step of the collapse counts only what takes a mount, so it goes on past entries that give nothing back instead of giving up on stepping. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ --- README.md | 8 +- src/sandbox/linux-deny-collapse.ts | 21 ++-- src/sandbox/linux-sandbox-utils.ts | 60 +++++++--- src/sandbox/macos-sandbox-utils.ts | 11 +- src/sandbox/mandatory-deny-paths.ts | 89 +++++++++++--- src/sandbox/sandbox-utils.ts | 16 ++- .../linux-git-redirect-placeholder.test.ts | 92 ++++++++++---- .../mandatory-deny-modules-walk.test.ts | 38 ++++++ .../mandatory-deny-time-budget.test.ts | 43 ++++++- test/sandbox/mandatory-deny-worktrees.test.ts | 113 +++++++++++++++++- 10 files changed, 414 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index cd53e67b2..ccb165de4 100644 --- a/README.md +++ b/README.md @@ -1186,9 +1186,9 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **What is denied in one:** `hooks/` and `commondir`, and unless `allowGitConfig` is on `config` and `config.worktree`. `commondir` moves the hooks and config git uses to another directory, and `config.worktree` is read on top of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). In a linked worktree's git directory that is reached from the repository that keeps it, it is `commondir`, `gitdir` and `config.worktree`: git reads no hooks and no `config` there while a `commondir` stands. - **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. -- **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other, with what they keep: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. +- **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other. So is what they keep, where the command may write any of it: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. - **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. -- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed or that holds a name or a link that is not valid UTF-8, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. - **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. - **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. - **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. @@ -1210,11 +1210,11 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - The file is a mount point like any other (see "Write denies on paths that do not exist yet (Linux)" above): named in the manifest of every wrap that relies on it, and removed once no running sandbox does, while it still holds what was written there. Only a manifest makes it one. A `commondir` or a `config.worktree` that no manifest names is somebody's own whatever it holds: it is bound read-only where it is and is never removed. - For as long as the file is there, git inside the sandbox and git on the host both read a redirect git accepts, but not what they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. - The git directory can be one a command already running writes. So nothing found there is ever written over, and the file is made only in the directory the wrap looked at, never wherever its path leads by then. Where the name has been taken meanwhile (a link, another file, a FIFO), or the directory swapped for a link, that is left as found and the wrap plans its mounts again on what is there by then. -- Where the git directory takes no file from this process (its mode, a read-only or immutable file system, a full disk), it is bound read-only whole instead, and the command can write nothing in it (`git add`, `git commit`). The same goes for one that holds an empty `commondir` a manifest names, which is what bubblewrap makes where the file went from under a command about to start: it goes with the next clean-up. That bind is the only mount the wrap puts inside the directory, since a mount point for another absent entry could not be made there, or could not be removed again. The directory's mode is asked first and does not always tell: where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). +- Where the git directory takes no file from this process (its mode, a read-only or immutable file system, a full disk), it is bound read-only whole instead, and the command can write nothing in it (`git add`, `git commit`). The same goes for one that holds an empty `commondir` which a running process's manifest names, which is what bubblewrap makes where the file went from under a command about to start. One that a killed process left is collected, and made anew. That bind is the only mount the wrap puts inside the directory, since a mount point for another absent entry could not be made there, or could not be removed again. The directory's mode is asked first and does not always tell: where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). **Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is an entry of a git directory or lies on the way to one. On macOS a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names. On macOS a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. - `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). - `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed is the working directory, or holds it or a write root. diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 7344db61c..3dfb0afea 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -170,12 +170,15 @@ export function collapsedDenyPaths( * where nothing is left to degrade, which is where the profile is refused with * `too_many_arguments`. What a step really gives back is the caller's to * measure. The arithmetic decides how many entries one step takes, so that a - * repository with thousands of submodules is not rebuilt once for each. + * repository with thousands of submodules is not rebuilt once for each. An entry + * none of whose deny paths `takesAMount` (all outside the write allowlist) gives + * nothing back, and the step goes on past it. */ export function collapseFurther( plan: SubmoduleDenyPlan, level: CollapseLevel, mountsOver: number, + takesAMount: (denyPath: string) => boolean = () => true, ): CollapseLevel | undefined { const order = collapsibleGitDirs(plan) if (level.wholeGitDirs < order.length) { @@ -183,9 +186,10 @@ export function collapseFurther( order.length - level.wholeGitDirs, mountsOver, at => { - const submodule = order[at] - if (submodule === undefined) return 0 - return submodule.denyPaths.length - submodule.escapingDenyPaths.length + const escaping = new Set(order[at]?.escapingDenyPaths) + return (order[at]?.denyPaths ?? []).filter( + denyPath => !escaping.has(denyPath) && takesAMount(denyPath), + ).length }, ) return { ...level, wholeGitDirs: level.wholeGitDirs + taken } @@ -194,9 +198,12 @@ export function collapseFurther( const taken = stepBack( plan.repositories.length - level.wholeModulesDirs, mountsOver, - // Each submodule under it is one bind by now, and the one bind of + // Each git directory under it is one bind by now, and the one bind of // `modules` replaces the lot. - at => (plan.repositories[at]?.gitDirs.length ?? 1) - 1, + at => + (plan.repositories[at]?.gitDirs ?? []).filter(kept => + takesAMount(kept.gitDir), + ).length - 1, ) return { ...level, wholeModulesDirs: level.wholeModulesDirs + taken } } @@ -241,7 +248,7 @@ export function collapseFloor( // many more of those lie ahead is a command's to choose. if (denies >= deniesBefore) return collapseEverything(plan) deniesBefore = denies - const degraded = collapseFurther(plan, level, denies - mounts) + const degraded = collapseFurther(plan, level, denies - mounts, takesAMount) if (degraded === undefined) return level level = degraded } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index a45a1c6c8..def89c284 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -46,6 +46,7 @@ import { isAtOrUnder, isStrictlyUnder, coversWriteTree, + meetsWriteRoots, properAncestors, nameLocation, getDangerousDirectories, @@ -360,14 +361,14 @@ function takesAFileFromHere(dir: string): boolean { * directory is held by a descriptor that followed no link, and nothing there is * written over: the file is made exclusively, or found as another wrap made it. * - * `changed` where the directory or the name is no longer what the plan saw; - * `refused` where the directory takes no file from this process after all, or - * what a killed wrap left there is no use to git. The caller plans again either - * way. + * `changed` where the directory or the name is no longer what the plan saw, + * `refused` where the directory takes no file from this process after all, and + * `empty` where a mount point is there that holds nothing and should. The caller + * plans again on any of them. */ function writeGitRedirectMountPoint( dest: string, -): 'held' | 'changed' | 'refused' { +): 'held' | 'changed' | 'refused' | 'empty' { const placeholder = gitRedirectPlaceholder(dest) ?? '' let fd: number | undefined try { @@ -386,7 +387,7 @@ function writeGitRedirectMountPoint( if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err } if (kindOfMountPoint(file) !== 'file') return 'changed' - return fs.lstatSync(file).size === placeholder.length ? 'held' : 'refused' + return fs.lstatSync(file).size === placeholder.length ? 'held' : 'empty' } catch (err) { if (refusesAFile(err)) return 'refused' if (isAbsenceErrno(err)) return 'changed' @@ -777,7 +778,7 @@ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, allowWritePaths: readonly string[] = [], ): string[] { - const plan = cwdMandatoryDenyPlan(allowGitConfig, undefined) + const plan = cwdMandatoryDenyPlan(allowGitConfig, undefined, allowWritePaths) return withoutWorkTreeDenies( [ ...collapsedDenyPaths(plan, NO_COLLAPSE), @@ -793,6 +794,7 @@ export function linuxGetCwdMandatoryDenyPaths( function cwdMandatoryDenyPlan( allowGitConfig: boolean, deadline: number | undefined, + allowWritePaths: readonly string[], visited = new Set(), ): SubmoduleDenyPlan { const cwd = process.cwd() @@ -837,7 +839,10 @@ function cwdMandatoryDenyPlan( .map(hop => hop.source) // A command in a linked worktree is commonly let write the repository's // git directory, and what that keeps is as much the host's git's to run. + // Where it may write none of it there is nothing to deny, and a walk that + // refuses over a sibling worktree would protect nothing. for (const gitDir of pointer.gitDirs) { + if (!meetsWriteRoots(gitDir, allowWritePaths)) continue addGitDirTree(plan, gitDirTreeDenies(gitDir, allowGitConfig, walk)) } } else if (dotGitStat !== undefined) { @@ -1195,6 +1200,7 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, + allowWritePaths: readonly string[] = [], abortSignal?: AbortSignal, ): Promise { const cwd = process.cwd() @@ -1215,7 +1221,7 @@ async function linuxGetMandatoryDenyPaths( // back through, so it is the last to lose its precise denies. const visited = new Set() const plan = asProfileRefusal(() => - cwdMandatoryDenyPlan(allowGitConfig, deadline, visited), + cwdMandatoryDenyPlan(allowGitConfig, deadline, allowWritePaths, visited), ) const denyPaths = plan.denyPaths @@ -1414,7 +1420,9 @@ async function linuxGetMandatoryDenyPaths( ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), ) plan.chainHops.push(...pointer.chainHops) - pointer.gitDirs.forEach(denyGitDir) + pointer.gitDirs + .filter(gitDir => meetsWriteRoots(gitDir, allowWritePaths)) + .forEach(denyGitDir) } } @@ -3115,8 +3123,9 @@ type FilesystemArgs = { * complete. A pass no shorter than the one before is the last that is stepped * to. * - * Only the pass that is settled on is published. `gitDirsThatRefusedAFile` is - * the caller's, for all the plans of one wrap. + * Only the pass that is settled on is published. `gitDirsThatRefusedAFile` and + * `foundEmpty` are the caller's, for all the plans of one wrap (see + * writeGitRedirectMountPoints). */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -3130,6 +3139,7 @@ async function generateFilesystemArgs( commandKey?: string, wordsAvailable: number = Number.POSITIVE_INFINITY, gitDirsThatRefusedAFile: Set = new Set(), + foundEmpty: Set = new Set(), ): Promise { const startedIn = workingDirectory() const build = ( @@ -3144,29 +3154,35 @@ async function generateFilesystemArgs( mandatoryDenyPaths, isSettledOn, gitDirsThatRefusedAFile, + foundEmpty, startedIn, abortSignal, commandKey, ) if (writeConfig === undefined) return build([], () => true) + const writeRootsNow = (): string[] => + (writeConfig.allowOnly ?? []).map(p => + normalizePathForSandbox(p, { literal: true }), + ) const found = await linuxGetMandatoryDenyPaths( ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, + writeRootsNow(), abortSignal, ) // INVARIANT: one plan, one working directory. A relative entry is resolved // against it on both sides of the await above. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() - const writeRoots = (writeConfig.allowOnly ?? []).map(p => - normalizePathForSandbox(p, { literal: true }), - ) + const writeRoots = writeRootsNow() const plan = guardedDenyPlan(found, writeRoots) + const takesAMount = (denyPath: string): boolean => + writeRoots.some(allowed => isAtOrUnder(denyPath, allowed)) let level = collapseFloor( plan, Math.floor(wordsAvailable / BWRAP_WORDS_PER_MOUNT), - denyPath => writeRoots.some(allowed => isAtOrUnder(denyPath, allowed)), + takesAMount, ) // What the pass before this one came to, once there has been one. let wordsBefore: number | undefined @@ -3182,6 +3198,7 @@ async function generateFilesystemArgs( plan, level, Math.ceil((words - wordsAvailable) / BWRAP_WORDS_PER_MOUNT), + takesAMount, ) // A pass no shorter than the one before took entries that cost this // profile nothing, such as git directories no write root contains. How @@ -3236,6 +3253,7 @@ async function buildFilesystemArgs( mandatoryDenyPaths: string[], isSettledOn: (words: number) => boolean, gitDirsThatRefusedAFile: Set, + foundEmpty: Set, startedIn: string | undefined, abortSignal: AbortSignal | undefined, commandKey: string | undefined, @@ -4838,9 +4856,17 @@ async function buildFilesystemArgs( for (const dest of gitRedirectMountPoints) { if (!landed.has(dest)) continue const gitDir = path.dirname(dest) - const outcome = gitDirsThatRefusedAFile.has(gitDir) + let outcome = gitDirsThatRefusedAFile.has(gitDir) ? 'refused' : writeGitRedirectMountPoint(dest) + if (outcome === 'empty' && foundEmpty.has(dest)) { + // Twice: what bubblewrap made where the file had gone from under a + // command about to start. It is no use to git and is not written over. + outcome = 'refused' + } + // Once: another wrap may be between making the file and writing it, or a + // killed one left it, and letting go of this plan's manifest collects it. + if (outcome === 'empty') foundEmpty.add(dest) if (outcome === 'refused') gitDirsThatRefusedAFile.add(gitDir) held &&= outcome === 'held' } @@ -5274,6 +5300,7 @@ export async function wrapCommandWithSandboxLinux( // ========== FILESYSTEM RESTRICTIONS ========== let filesystem: Awaited> const gitDirsThatRefusedAFile = new Set() + const foundEmpty = new Set() for (let left = MOUNT_PLAN_ATTEMPTS; filesystem === undefined; left--) { if (left === 0) { throw new LinuxSandboxProfileError( @@ -5297,6 +5324,7 @@ export async function wrapCommandWithSandboxLinux( // already built above and the few that come after the mounts. BWRAP_MAX_ARGS - bwrapArgs.length - BWRAP_TRAILING_WORDS, gitDirsThatRefusedAFile, + foundEmpty, ).catch((error: unknown) => { if (!(error instanceof MountPlanFailed)) throw error return undefined diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index d2b5243e0..46fe2e5b5 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -22,6 +22,7 @@ import { getDangerousDirectories, isAbsenceErrno, coversWriteTree, + meetsWriteRoots, } from './sandbox-utils.js' import { gitDirDenyPaths, @@ -195,9 +196,13 @@ export function macGetMandatoryDenyEntries( // (gitPointerFilter), which cannot follow them. const pointer = gitFileDenies(dotGit, allowGitConfig, deadline) const visited = new Set() - const trees = pointer.gitDirs.map(gitDir => - gitDirTreeDenies(gitDir, allowGitConfig, { deadline, visited }), - ) + // Only where the command may write some of it: else there is nothing to deny, + // and a walk that refuses over a sibling worktree would protect nothing. + const trees = pointer.gitDirs + .filter(gitDir => meetsWriteRoots(gitDir, allowWritePaths)) + .map(gitDir => + gitDirTreeDenies(gitDir, allowGitConfig, { deadline, visited }), + ) return withOnDisk( [...own, ...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 71f03f519..a12eaae5a 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -231,7 +231,7 @@ export interface GitDirTreeDenies { /** `/worktrees`, whether or not anything is under it. */ worktreesDir: string /** One per linked worktree's git directory under `worktreesDir`, sorted by - * path; see {@link worktreeGitDirs}. */ + * path; see {@link keptWorktrees}. */ worktrees: Array<{ gitDir: string } & GitDirDenies> /** What neither walk could see through; see {@link SubmoduleScan}. */ unreadableDirs: string[] @@ -898,13 +898,14 @@ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { `[Sandbox] The walk of ${worktreesDir} ran out of the time it was given with ${entries.length - at} of its ${entries.length} entries still to look at; refusing to sandbox on the linked worktrees it did reach`, ) } + if (isNoText(entry, worktreesDir, walk)) continue const child = path.join(worktreesDir, entry.name) const found = walkEntry(entry, child, walk, deadline) if (found.kind === 'skip') continue // Where a link leads to nothing is denied as a git directory that is not // there yet, as the `.git/modules` walk denies one. const worktree = found.kind === 'dangling' ? found.landing : child - if (isVisited(worktree, walk)) continue + if (isVisited('worktree', worktree, walk)) continue // Denied whole where it cannot be listed, and git, the same user's, reads // no `commondir` in there either: nothing to refuse the command over. if ( @@ -986,9 +987,10 @@ export function submoduleGitDirs( * see, so the deadline is what bounds the walk. */ function walkModules(modulesDir: string, walk: TreeWalk): string[] { - // The root counts as visited: an entry linked straight back to it is then the - // same dead end as one linked to any other directory already walked. - if (isVisited(modulesDir, walk)) return [] + if (isVisited('root', modulesDir, walk)) return [] + // An entry linked straight back to it is then the same dead end as one linked + // to any other directory already walked. + isVisited('walked', modulesDir, walk) const gitDirs: string[] = [] // Where the walk still has to look. A directory that was listed carries its // entries, and `gitDir` says it was recorded as a git directory, so that some @@ -1011,6 +1013,7 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { `[Sandbox] The walk of ${modulesDir} ran out of the time it was given while listing ${item.dir}, with ${pending.length} directories still to look at; refusing to sandbox on the submodule git directories it did reach`, ) } + if (isNoText(entry, item.dir, walk)) continue const child = path.join(item.dir, entry.name) let childEntries: fs.Dirent[] | undefined if (item.gitDir === true) { @@ -1043,10 +1046,12 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { // Where a link leads to nothing is denied as a git directory that is // not there yet, so a command cannot fill one in for the host's git // to find. Nothing is walked: there is nothing there to walk. - if (!isVisited(found.landing, walk)) gitDirs.push(found.landing) + if (!isVisited('walked', found.landing, walk)) { + gitDirs.push(found.landing) + } continue } - if (isVisited(child, walk)) continue + if (isVisited('walked', child, walk)) continue childEntries ??= listDirectory(child, walk) if (childEntries === undefined) continue @@ -1054,8 +1059,14 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { if (isGitDir) gitDirs.push(child) // Anything else is a segment of a submodule name (`vendor` of // `vendor/lib`) - and a directory that looks like a git directory can - // be one as well, so what else it holds is looked at either way. - pending.push({ dir: child, entries: childEntries, gitDir: isGitDir }) + // be one as well, so what else it holds is looked at either way. In the + // `modules` of a git directory every entry is a submodule's name, as in + // `modulesDir`, whatever a command left beside them. + pending.push({ + dir: child, + entries: childEntries, + gitDir: isGitDir && !(item.gitDir === true && entry.name === 'modules'), + }) } } return gitDirs.sort() @@ -1084,11 +1095,23 @@ interface TreeWalk { chainHops: GitChainHop[] } -/** Whether a walk has been into `dir` before. It has from now on. */ -function isVisited(dir: string, walk: TreeWalk): boolean { - const real = realPathOrSelf(dir) - if (walk.visited.has(real)) return true - walk.visited.add(real) +/** + * Whether a walk has been into `dir` before AS that. It has from now on. + * + * THREAT: a command can link any directory into a `modules` or a `worktrees`, + * to have it visited there first. What a directory is denied by depends on what + * it is visited as: a linked worktree's has no `hooks` denied, and what a + * `modules` holds is all submodule names only where the walk starts from it. So + * a visit as one thing never stands in for a visit as another. + */ +function isVisited( + as: 'root' | 'walked' | 'worktree', + dir: string, + walk: TreeWalk, +): boolean { + const key = `${as}:${realPathOrSelf(dir)}` + if (walk.visited.has(key)) return true + walk.visited.add(key) return false } @@ -1102,6 +1125,21 @@ type WalkEntry = /** Nothing to walk and nothing to deny. */ | { kind: 'skip' } +/** + * Whether the name of `entry`, of `dir`, may be bytes that are no text. The + * string made of those names nothing, so no deny can be aimed at it, and `dir` + * is denied whole. + */ +function isNoText(entry: fs.Dirent, dir: string, walk: TreeWalk): boolean { + if (!entry.name.includes('\uFFFD')) return false + walk.unreadableDirs.add(dir) + logForDebugging( + `[Sandbox] An entry of ${dir} has a name that is not valid UTF-8, denying ${dir} whole`, + { level: 'warn' }, + ) + return true +} + /** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { try { @@ -1136,11 +1174,24 @@ function walkEntry( if (entry.isDirectory()) return { kind: 'directory' } if (!entry.isSymbolicLink()) return { kind: 'skip' } const holder = path.dirname(entryPath) - const chain = resolveChain( - physicalPath(path.parse(entryPath).root, holder, deadline), - entry.name, - deadline, - ) + let chain: ResolvedChain + try { + chain = resolveChain( + physicalPath(path.parse(entryPath).root, holder, deadline), + entry.name, + deadline, + ) + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + // Where it leads cannot be named. A command can make one here, so it costs + // what any link here does and not every command after it. + walk.linkedEntryDirs.add(holder) + logForDebugging( + `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, denying ${holder} whole`, + { level: 'warn' }, + ) + return { kind: 'skip' } + } // The first hop is the entry, whose holder joins `linkedEntryDirs` below; // the rest lie between it and what it lands on, and that deny reaches // neither them nor the directories they sit in. diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 2420fda90..9a55a340b 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,7 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf } from '../utils/real-path.js' +import { realPathOf, realPathOrSelf } from '../utils/real-path.js' import { denyGlobRegex, globToRegex, @@ -94,6 +94,20 @@ export function coversWriteTree( ) } +/** + * Whether a command may write anything at or under `dir`: it lies in a path the + * caller allows writes to, or holds one. Both as they really are. + */ +export function meetsWriteRoots( + dir: string, + allowWritePaths: readonly string[], +): boolean { + const real = realPathOrSelf(dir) + return allowWritePaths + .map(realPathOrSelf) + .some(allowed => isAtOrUnder(real, allowed) || isAtOrUnder(allowed, real)) +} + /** `p` lies strictly beneath `dir` (isAtOrUnder, excluding `dir` itself). */ export function isStrictlyUnder(p: string, dir: string): boolean { return p !== dir && isAtOrUnder(p, dir) diff --git a/test/sandbox/linux-git-redirect-placeholder.test.ts b/test/sandbox/linux-git-redirect-placeholder.test.ts index d01cba5c8..febd721fb 100644 --- a/test/sandbox/linux-git-redirect-placeholder.test.ts +++ b/test/sandbox/linux-git-redirect-placeholder.test.ts @@ -30,7 +30,6 @@ import { } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { - aDeadProcess, type Process, processAt, refusal, @@ -244,32 +243,75 @@ describe.if(isLinux)('The mount point of a file git reads back', () => { if (isSomebodys) expect(readFileSync(dest, 'utf8')).toBe(before.contents) }) - it.each([ - { whose: 'a killed wrap', maker: aDeadProcess, stays: false }, - { whose: 'a wrap of a running process', maker: initProcess, stays: true }, - ])( - 'denies the git directory whole where $whose left an empty commondir', - async ({ maker, stays }) => { - // What bubblewrap's own ensure_file() makes where the file has gone from - // under a command that then starts. It is no use to git and is not - // written over, so it takes no bind of its own. It goes as any mount - // point does, once no manifest of a running process names it. - const checkout = makeCheckout('repo') - const gitDir = join(checkout, '.git') - const commondir = join(gitDir, 'commondir') - writeFileSync(commondir, '', { mode: 0o444 }) - writeManifest(runtime.manifestDir(), [commondir], maker()) + // What bubblewrap's own ensure_file() makes where the file has gone from under + // a command that then starts. It is no use to git, and is never written over. + it('makes anew the empty commondir a killed wrap left', async () => { + // It goes as any mount point does once no running process's manifest names + // it, which is when the plan that found it lets go of its own. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '', { mode: 0o444 }) + writeManifest(runtime.manifestDir(), [commondir]) - const command = await wrapIn(checkout) + const command = await wrapIn(checkout) - expect(isDeniedWhole(command, checkout, gitDir)).toBe(true) - expect(lastMountAt(command, commondir)).toBeUndefined() - cleanupBwrapMountPoints({ force: true }) - expect(existsSync(commondir)).toBe(stays) - if (stays) expect(readFileSync(commondir, 'utf8')).toBe('') - expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) - }, - ) + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('denies the git directory whole where the empty commondir stays', async () => { + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + writeFileSync(commondir, '', { mode: 0o444 }) + writeManifest(runtime.manifestDir(), [commondir], initProcess()) + + const command = await wrapIn(checkout) + + expect(isDeniedWhole(command, checkout, gitDir)).toBe(true) + expect(lastMountAt(command, commondir)).toBeUndefined() + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('') + expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) + }) + + it('takes a commondir another wrap is about to write for no leftover', async () => { + // Made and written are two steps. Found between them, it is empty. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + let manifest: string | undefined + + const { command } = await wrapSwappingBefore( + 'writeFileSync', + checkout, + gitDir, + () => { + manifest = writeManifest( + runtime.manifestDir(), + [commondir], + initProcess(), + ) + writeFileSync(commondir, '', { mode: 0o444 }) + // By the time this wrap has planned again the other has written it. + setImmediate(() => { + chmodSync(commondir, 0o644) + writeFileSync(commondir, '.\n') + chmodSync(commondir, 0o444) + }) + }, + ) + + expect(manifest).toBeDefined() + expect(isDeniedWhole(command, checkout, gitDir)).toBe(false) + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + }) it('goes by the file, not by the spelling of the deny entry', async () => { // A caller's own deny for the same file comes first and wins the dedup. Its diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 1dbef314e..acd2c727b 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -182,6 +182,44 @@ describe('The .git/modules walk beneath what looks like a git directory', () => ]) }) + it('takes every entry of the modules of a git directory for a name', () => { + // As in the directory it was asked to walk, whatever a command left beside + // them: taken for git's own, `lfs` would hide the submodule `lfs/client`. + const lib = makeGitDir(join(modules, 'lib')) + const nested = makeGitDir(join(lib, 'modules', 'lfs', 'client')) + writeFileSync(join(lib, 'modules', 'HEAD'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + join(lib, 'modules'), + nested, + ]) + }) + + it.if(!isWindows)( + 'denies whole the directory holding what cannot be named', + () => { + // A link whose target, and a directory whose name, is no text. A command + // can make either here, so it costs what any link here does, and not + // every command after it. + const linked = join(modules, 'linked') + const named = join(modules, 'named') + mkdirSync(linked, { recursive: true }) + mkdirSync(named) + symlinkSync(Buffer.from([0x2e, 0x2e, 0x2f, 0xff]), join(linked, 'x')) + mkdirSync( + Buffer.concat([Buffer.from(`${named}/caf`), Buffer.from([0xe9])]), + ) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [], + unreadableDirs: [named], + linkedEntryDirs: [linked], + chainHops: [], + }) + }, + ) + it('does not walk into what git itself keeps in a git directory', () => { // A real submodule git directory with everything in it that is large: // loose objects fanned out over 256 directories, refs and their logs, an diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index 9bfaef7bf..da8969689 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -267,7 +267,48 @@ describe.if(isLinux)('Passes over a profile that does not fit', () => { }) expect(level).toEqual(collapseEverything(plan)) - expect(asked).toBeLessThanOrEqual(2 * plan.denyPaths.length) + expect(asked).toBeLessThanOrEqual(4 * plan.denyPaths.length) + }) + + it('steps past entries that take no mount to those that do', () => { + // Served last, so taken first: seventy git directories the command may not + // write. Degrading them gives nothing back, and is no reason to degrade + // every one of those it may. + const repository = ( + modulesDir: string, + count: number, + ): SubmoduleDenyPlan['repositories'][number] => ({ + modulesDir, + wholeDirDenies: [], + gitDirs: Array.from({ length: count }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: ['hooks', 'config'].map(name => + join(modulesDir, `s${i}`, name), + ), + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })), + }) + const repositories = [ + repository(join(dir, 'repo', '.git', 'modules'), 10), + repository(join(dir, 'elsewhere', '.git', 'modules'), 70), + ] + const plan: SubmoduleDenyPlan = { + denyPaths: repositories.flatMap(each => + each.gitDirs.flatMap(kept => kept.denyPaths), + ), + repositories, + chainHops: [], + } + + // Twenty deny paths that take a mount, and room for eighteen: two of the + // ten, each two paths for one bind, make up for it. + expect( + collapseFloor(plan, 18, denyPath => + denyPath.startsWith(join(dir, 'repo')), + ), + ).toEqual({ wholeGitDirs: 72, wholeModulesDirs: 0 }) }) it('resolves each path once to say what was degraded', () => { diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index 21b9abafa..2b724c68f 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -312,6 +312,16 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) }) + it.if(isLinux)('carries it where a write root lies inside', () => { + const { checkout, kept } = repositoryThatKeepsMore() + const modules = join(dir, 'main', '.git', 'modules') + process.chdir(checkout) + + expect(linuxGetCwdMandatoryDenyPaths(false, [checkout, modules])).toEqual( + expect.arrayContaining(kept), + ) + }) + it('carries what they keep into the macOS entries', () => { const { checkout, kept } = repositoryThatKeepsMore() process.chdir(checkout) @@ -324,6 +334,27 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(literals.length).toBe(new Set(literals).size) }) + it('walks nothing of a repository the command may write none of', () => { + // The default for a linked worktree. There is nothing to deny there, and + // one stale sibling would refuse every command in every worktree. + const { checkout, kept } = repositoryThatKeepsMore() + writeFileSync( + join(dir, 'main', '.git', 'worktrees', 'other', 'commondir'), + Buffer.from([0xff]), + ) + process.chdir(checkout) + + const denied = [ + ...(isLinux ? linuxGetCwdMandatoryDenyPaths(false, [checkout]) : []), + ...macGetMandatoryDenyEntries(false, [checkout]).map( + entry => entry.path, + ), + ] + + expect(denied).toContain(join(dir, 'main', '.git', 'hooks')) + expect(denied.filter(denyPath => kept.includes(denyPath))).toEqual([]) + }) + it.if(isLinux)( 'denies what the git directory behind a nested pointer keeps', async () => { @@ -343,6 +374,26 @@ describe.if(!isWindows)('Linked worktree git directories', () => { }, ) + it.if(isLinux)( + 'walks nothing behind a nested pointer that the command may write none of', + async () => { + const store = makeGitDir(join(dir, 'store')) + const stale = makeLinkedWorktree(store, 'w', join(dir, 'w')) + writeFileSync( + join(stale.worktreeGitDir, 'commondir'), + Buffer.from([0xff]), + ) + mkdirSync(join(dir, 'project', 'checkout'), { recursive: true }) + writeFileSync( + join(dir, 'project', 'checkout', '.git'), + 'gitdir: ../../store\n', + ) + process.chdir(join(dir, 'project')) + + expect(await wrap('true', [join(dir, 'project')])).toBeTypeOf('string') + }, + ) + it.if(isLinux)( 'enumerates once a git directory reached both ways', async () => { @@ -365,7 +416,9 @@ describe.if(!isWindows)('Linked worktree git directories', () => { .finally(() => list.mockRestore()) expect( - listed.filter(listedDir => listedDir === join(submodule, 'modules')), + listed.filter( + listedDir => listedDir === join(submodule, 'modules', 'nested'), + ), ).toHaveLength(1) }, ) @@ -933,6 +986,64 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) }) + it('takes no visit as one thing for a visit as another', () => { + // A command can link any directory into a `worktrees` or a `modules`, to + // have it visited there first, as what is denied the least. + const visited = new Set() + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const other = makeGitDir(join(dir, 'main', 'sub', '.git')) + const submodule = makeGitDir(join(other, 'modules', 'vendor', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree(other, 'w', join(dir, 'w')) + mkdirSync(join(mainGitDir, 'worktrees')) + mkdirSync(join(mainGitDir, 'modules')) + symlinkSync(join(other, 'modules'), join(mainGitDir, 'worktrees', 'x')) + symlinkSync(join(other, 'worktrees'), join(mainGitDir, 'modules', 'y')) + + gitDirTreeDenies(mainGitDir, false, { visited }) + const denyPaths = gitDirTreeDenyPaths( + gitDirTreeDenies(other, false, { visited }), + ) + + expect(denyPaths).toEqual( + expect.arrayContaining([ + ...gitDirDenyPaths(submodule, false), + ...heldFiles(worktreeGitDir), + ]), + ) + }) + + it('walks a modules from the top though it was walked into from elsewhere', () => { + // Linked into another `modules`, with a `HEAD` left in it, it is a git + // directory there and its `lfs` git's own. Where the walk starts from it, + // `lfs` is the first segment of a submodule's name. + const visited = new Set() + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const other = makeGitDir(join(dir, 'main', 'sub', '.git')) + const submodule = makeGitDir(join(other, 'modules', 'lfs', 'client')) + writeFileSync(join(other, 'modules', 'HEAD'), '') + mkdirSync(join(mainGitDir, 'modules')) + symlinkSync(join(other, 'modules'), join(mainGitDir, 'modules', 'x')) + + gitDirTreeDenies(mainGitDir, false, { visited }) + + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(other, false, { visited })), + ).toEqual(expect.arrayContaining(gitDirDenyPaths(submodule, false))) + }) + + it('denies a worktrees whole that holds a name that is no text', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktrees = join(mainGitDir, 'worktrees') + mkdirSync(worktrees) + mkdirSync( + Buffer.concat([Buffer.from(`${worktrees}/caf`), Buffer.from([0xe9])]), + ) + + expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ + worktrees, + ]) + }) + it('holds a worktrees that is a link, as it holds a modules that is one', () => { // Remove the link, put a directory where it was, and every `.git` file // that names a worktree's git directory through it opens a planted one. From 48e04eb29893717b8bc353c006bf8f06c753be45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:14:07 +0000 Subject: [PATCH 137/141] walk each git directory from where it is, whatever links lead there A review of the last round found that the set of visited directories, shared between repositories and keyed by what a directory was visited as, still let a planted link have a real directory visited first and hide it. One rule instead. A directory that is where its name says, with no link on the way from the root of the walk, is only stood in for by a walk that came to it so. A visit through a link is cut short after the first. That ends a link that leads back up, and keeps many links or many .git files into one directory from costing as many walks of it. - The entries of the modules of a git directory are names: only a file makes a git directory of it, so a nested submodule named hooks does not. - A link whose target is not valid UTF-8 refuses where something is behind it, and has the directory holding it denied whole, on both platforms, where nothing is. - A git directory and a linked worktree are denied under each name that leads to them, which on macOS is what holds the link. - macOS: the trees behind the working directory's .git file are enumerated wherever a write root is a pattern. - A repository none of whose git directories takes a mount gives nothing back, rather than taking one from what the next gives. - The cases that make a name that is not valid UTF-8 run on Linux only: the macOS file systems take no such name. Left out, and in the README: where a commondir with a `..` in it folds to on paper when it is read under a second name. Where the kernel lands is denied whichever name led there. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ Co-authored-by: ronleizrowice --- README.md | 8 +- src/sandbox/linux-deny-collapse.ts | 9 +- src/sandbox/linux-sandbox-utils.ts | 4 +- src/sandbox/macos-sandbox-utils.ts | 12 +- src/sandbox/mandatory-deny-paths.ts | 113 ++++---- .../mandatory-deny-modules-walk.test.ts | 114 ++++++-- .../mandatory-deny-time-budget.test.ts | 35 +++ test/sandbox/mandatory-deny-worktrees.test.ts | 264 ++++++++++++------ 8 files changed, 394 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index ccb165de4..caeb7d062 100644 --- a/README.md +++ b/README.md @@ -1187,12 +1187,12 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **What is denied in one:** `hooks/` and `commondir`, and unless `allowGitConfig` is on `config` and `config.worktree`. `commondir` moves the hooks and config git uses to another directory, and `config.worktree` is read on top of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). In a linked worktree's git directory that is reached from the repository that keeps it, it is `commondir`, `gitdir` and `config.worktree`: git reads no hooks and no `config` there while a `commondir` stands. - **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. - **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other. So is what they keep, where the command may write any of it: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. -- **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. -- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed or that holds a name or a link that is not valid UTF-8, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. The `modules` of a git directory is one by the three files alone: a directory in it is a submodule's name, `hooks` and `objects` too. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. - **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. - **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. - **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. -- **Time is what bounds the walks.** None has a depth bound, and each directory is visited once, by its real path. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. +- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as (submodule names, a git directory's own entries, linked worktrees), on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. - **A directory that looks like a git directory does not end the `.git/modules` walk.** A segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, and a `HEAD` left in it would otherwise take the denies off `vendor/lib`. It is denied as a git directory and what else it holds is walked as well, its `modules` included. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and is walked after all where that listing shows a `config` or a `hooks`. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A mount takes three of bubblewrap's 9000 words, and a submodule git directory asks for five mounts, seven where it is checked out. Rather than refuse every command in a repository with hundreds of them, the wrap counts what the profile comes to and, while that is past the cap, degrades from the end: the working directory's own repository is served first, then the nested ones in sorted order, and within a repository the linked worktrees go before the submodules. @@ -1214,7 +1214,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names. On macOS a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with something behind it. On macOS a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. - `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). - `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed is the working directory, or holds it or a write root. diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts index 3dfb0afea..e748913be 100644 --- a/src/sandbox/linux-deny-collapse.ts +++ b/src/sandbox/linux-deny-collapse.ts @@ -201,9 +201,12 @@ export function collapseFurther( // Each git directory under it is one bind by now, and the one bind of // `modules` replaces the lot. at => - (plan.repositories[at]?.gitDirs ?? []).filter(kept => - takesAMount(kept.gitDir), - ).length - 1, + Math.max( + (plan.repositories[at]?.gitDirs ?? []).filter(kept => + takesAMount(kept.gitDir), + ).length - 1, + 0, + ), ) return { ...level, wholeModulesDirs: level.wholeModulesDirs + taken } } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index def89c284..fd66f59f0 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -795,7 +795,7 @@ function cwdMandatoryDenyPlan( allowGitConfig: boolean, deadline: number | undefined, allowWritePaths: readonly string[], - visited = new Set(), + visited = new Map(), ): SubmoduleDenyPlan { const cwd = process.cwd() const plan: SubmoduleDenyPlan = { @@ -1219,7 +1219,7 @@ async function linuxGetMandatoryDenyPaths( // The working directory's own repository is the first the collapse works // back through, so it is the last to lose its precise denies. - const visited = new Set() + const visited = new Map() const plan = asProfileRefusal(() => cwdMandatoryDenyPlan(allowGitConfig, deadline, allowWritePaths, visited), ) diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 46fe2e5b5..6f34e4bb7 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -120,6 +120,7 @@ export interface MacOSSandboxParams { export function macGetMandatoryDenyEntries( allowGitConfig = false, allowWritePaths: readonly string[] = [], + hasWritePatterns = false, ): PathEntry[] { const cwd = normalizePathForSandbox(process.cwd(), { literal: true }) const entries: PathEntry[] = [] @@ -195,14 +196,14 @@ export function macGetMandatoryDenyEntries( // file. Nested pointer files are matched by vnode type instead // (gitPointerFilter), which cannot follow them. const pointer = gitFileDenies(dotGit, allowGitConfig, deadline) - const visited = new Set() // Only where the command may write some of it: else there is nothing to deny, - // and a walk that refuses over a sibling worktree would protect nothing. + // and a walk that refuses over a sibling worktree would protect nothing. What + // a write root that is a pattern reaches is not worked out. const trees = pointer.gitDirs - .filter(gitDir => meetsWriteRoots(gitDir, allowWritePaths)) - .map(gitDir => - gitDirTreeDenies(gitDir, allowGitConfig, { deadline, visited }), + .filter( + gitDir => hasWritePatterns || meetsWriteRoots(gitDir, allowWritePaths), ) + .map(gitDir => gitDirTreeDenies(gitDir, allowGitConfig, { deadline })) return withOnDisk( [...own, ...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], @@ -1179,6 +1180,7 @@ function generateWriteRules( // The write roots that are names, a path marked literal among them. A // pattern names no directory that a deny could take whole. writeRoots.flatMap(root => (root.glob ? [] : [root.path])), + writeRoots.some(root => root.glob), ), ] denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index a12eaae5a..1ce2c3375 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -764,8 +764,9 @@ function gatherCommonDirDenies( * {@link keptWorktrees}). * * `deadline` bounds the walks and the chains they meet. `visited` is for a - * caller that enumerates several git directories, one of which may keep another - * ({@link TreeWalk}). + * caller that enumerates several git directories and puts what comes back + * together, so that what one of them keeps of another, or links to, is not + * walked once for each ({@link isCutShort}). * * Kept in pieces so that a backend that cannot carry every mount can degrade * some; {@link gitDirTreeDenyPaths} is the flat form. @@ -773,13 +774,15 @@ function gatherCommonDirDenies( export function gitDirTreeDenies( gitDir: string, allowGitConfig: boolean, - options: { deadline?: number; visited?: Set } = {}, + options: { deadline?: number; visited?: Map } = {}, ): GitDirTreeDenies { const walk: TreeWalk = { allowGitConfig, deadline: options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, - visited: options.visited ?? new Set(), + root: gitDir, + realRoot: realPathOrSelf(gitDir), + visited: options.visited ?? new Map(), unreadableDirs: new Set(), linkedEntryDirs: new Set(), chainHops: [], @@ -890,6 +893,7 @@ function keptSubmodules(gitDir: string, walk: TreeWalk): KeptGitDir[] { function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { const { allowGitConfig, deadline } = walk const worktreesDir = path.join(gitDir, 'worktrees') + if (isCutShort('worktrees', worktreesDir, walk)) return [] const kept: KeptGitDir[] = [] const entries = listDirectory(worktreesDir, walk) ?? [] for (const [at, entry] of entries.entries()) { @@ -905,7 +909,6 @@ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { // Where a link leads to nothing is denied as a git directory that is not // there yet, as the `.git/modules` walk denies one. const worktree = found.kind === 'dangling' ? found.landing : child - if (isVisited('worktree', worktree, walk)) continue // Denied whole where it cannot be listed, and git, the same user's, reads // no `commondir` in there either: nothing to refuse the command over. if ( @@ -956,7 +959,9 @@ export function submoduleGitDirs( const walk: TreeWalk = { allowGitConfig: false, deadline, - visited: new Set(), + root: modulesDir, + realRoot: realPathOrSelf(modulesDir), + visited: new Map(), unreadableDirs: new Set(), linkedEntryDirs: new Set(), chainHops: [], @@ -987,11 +992,8 @@ export function submoduleGitDirs( * see, so the deadline is what bounds the walk. */ function walkModules(modulesDir: string, walk: TreeWalk): string[] { - if (isVisited('root', modulesDir, walk)) return [] - // An entry linked straight back to it is then the same dead end as one linked - // to any other directory already walked. - isVisited('walked', modulesDir, walk) - const gitDirs: string[] = [] + if (isCutShort('names', modulesDir, walk)) return [] + const gitDirs = new Set() // Where the walk still has to look. A directory that was listed carries its // entries, and `gitDir` says it was recorded as a git directory, so that some // of them may be git's own. @@ -1046,30 +1048,30 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { // Where a link leads to nothing is denied as a git directory that is // not there yet, so a command cannot fill one in for the host's git // to find. Nothing is walked: there is nothing there to walk. - if (!isVisited('walked', found.landing, walk)) { - gitDirs.push(found.landing) - } + gitDirs.add(found.landing) continue } - if (isVisited('walked', child, walk)) continue - childEntries ??= listDirectory(child, walk) if (childEntries === undefined) continue - const isGitDir = looksLikeGitDir(childEntries) - if (isGitDir) gitDirs.push(child) + // In the `modules` of a git directory every entry is a submodule's name, + // as in `modulesDir`, whatever a command left beside them: `hooks` is one + // too. So only a file makes a git directory of it. + const holdsNames = item.gitDir === true && entry.name === 'modules' + const isGitDir = looksLikeGitDir( + holdsNames ? childEntries.filter(e => !e.isDirectory()) : childEntries, + ) + // By each name that leads to it: on macOS it is what is denied beneath a + // link that holds the link. + if (isGitDir) gitDirs.add(child) // Anything else is a segment of a submodule name (`vendor` of // `vendor/lib`) - and a directory that looks like a git directory can - // be one as well, so what else it holds is looked at either way. In the - // `modules` of a git directory every entry is a submodule's name, as in - // `modulesDir`, whatever a command left beside them. - pending.push({ - dir: child, - entries: childEntries, - gitDir: isGitDir && !(item.gitDir === true && entry.name === 'modules'), - }) + // be one as well, so what else it holds is looked at either way. + const gitDir = isGitDir && !holdsNames + if (isCutShort(gitDir ? 'own' : 'names', child, walk)) continue + pending.push({ dir: child, entries: childEntries, gitDir }) } } - return gitDirs.sort() + return [...gitDirs].sort() } /** @@ -1079,14 +1081,11 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { interface TreeWalk { allowGitConfig: boolean deadline: number - /** - * Every directory a walk has been into, by where it really is. One set for - * all: a link can lead from a `worktrees` back to the git directory that - * keeps it, and each walk would find the other's tree anew, one spelling - * longer, for as long as a path can get. Shared between git directories, one - * that another keeps is not enumerated twice. - */ - visited: Set + /** The directory that is enumerated, and where it really is. */ + root: string + realRoot: string + /** See {@link isCutShort}. */ + visited: Map /** As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold * to the same ancestor, and a repeated path is a mount the profile never * pays for but would otherwise be counted as one. */ @@ -1096,22 +1095,31 @@ interface TreeWalk { } /** - * Whether a walk has been into `dir` before AS that. It has from now on. + * Whether a walk stops short of `dir`, whose entries it would read `as` names + * of submodules, as a git directory's `own` or as linked `worktrees`: a walk has + * read them so before, by where `dir` really is. That is what ends a link that + * leads back up, and what keeps many links to one directory, or many `.git` + * files into one repository, from costing as many walks of it. * - * THREAT: a command can link any directory into a `modules` or a `worktrees`, - * to have it visited there first. What a directory is denied by depends on what - * it is visited as: a linked worktree's has no `hooks` denied, and what a - * `modules` holds is all submodule names only where the walk starts from it. So - * a visit as one thing never stands in for a visit as another. + * INVARIANT: for a directory that is where its name says, with no link on the + * way from the root, only a walk that came to it so stands in. A command can + * link any directory into a `modules` or a `worktrees`, to have it visited from + * there first and as something else. What really lies under a git directory is + * walked from it all the same. */ -function isVisited( - as: 'root' | 'walked' | 'worktree', +function isCutShort( + as: 'names' | 'own' | 'worktrees', dir: string, walk: TreeWalk, ): boolean { - const key = `${as}:${realPathOrSelf(dir)}` - if (walk.visited.has(key)) return true - walk.visited.add(key) + const real = realPathOrSelf(dir) + const key = `${as}:${real}` + const isWhereItsNameSays = + real === path.join(walk.realRoot, path.relative(walk.root, dir)) + const cameSoBefore = walk.visited.get(key) + if (cameSoBefore === true) return true + if (cameSoBefore === false && !isWhereItsNameSays) return true + walk.visited.set(key, isWhereItsNameSays) return false } @@ -1182,12 +1190,15 @@ function walkEntry( deadline, ) } catch (err) { - if (!(err instanceof GitMetadataError)) throw err - // Where it leads cannot be named. A command can make one here, so it costs - // what any link here does and not every command after it. - walk.linkedEntryDirs.add(holder) + // Where it leads cannot be named, so what is there cannot be denied. + if (!(err instanceof GitMetadataError) || fs.existsSync(entryPath)) { + throw err + } + // Nothing is. A command can make such a link here, so it costs what any + // link here does and not every command after it. + walk.unreadableDirs.add(holder) logForDebugging( - `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, denying ${holder} whole`, + `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, to nothing; denying ${holder} whole`, { level: 'warn' }, ) return { kind: 'skip' } diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index acd2c727b..f68c090c3 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -22,6 +22,7 @@ import { wrapCommandWithSandboxLinux, } from '../../src/sandbox/linux-sandbox-utils.js' import { + GitMetadataError, SubmoduleWalkBudgetError, gitDirDenyPaths, gitDirTreeDenies, @@ -196,29 +197,110 @@ describe('The .git/modules walk beneath what looks like a git directory', () => ]) }) - it.if(!isWindows)( - 'denies whole the directory holding what cannot be named', - () => { - // A link whose target, and a directory whose name, is no text. A command - // can make either here, so it costs what any link here does, and not - // every command after it. + describe.if(!isWindows)('a directory that links lead to as well', () => { + /** A link to `target` on either side of every other name here, whichever + * way the walk goes through them. */ + function linkFromBothSides(target: string): void { + for (const name of ['a', 'z']) { + mkdirSync(join(modules, name), { recursive: true }) + symlinkSync(target, join(modules, name, 'link')) + } + } + + it('is a git directory by each name', () => { + const lib = makeGitDir(join(modules, 'lib')) + linkFromBothSides('../lib') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + join(modules, 'a', 'link'), + lib, + join(modules, 'z', 'link'), + ]) + }) + + it('is walked from where it is', () => { + const lib = makeGitDir(join(modules, 'vendor', 'deep', 'lib')) + linkFromBothSides('../vendor/deep') + + expect(submoduleGitDirs(modules).gitDirs).toContain(lib) + }) + + it('is so where the walk was asked for by another name itself', () => { + makeGitDir(join(modules, 'vendor', 'deep', 'lib')) + linkFromBothSides('../vendor/deep') + symlinkSync(dir, `${dir}-alias`) + try { + expect( + submoduleGitDirs(join(`${dir}-alias`, 'modules')).gitDirs, + ).toContain(join(`${dir}-alias`, 'modules', 'vendor', 'deep', 'lib')) + } finally { + rmSync(`${dir}-alias`) + } + }) + + it('is walked as each thing that it is reached as', () => { + // All of it lies elsewhere, so nothing is walked from where it is. + const lib = makeGitDir(join(dir, 'elsewhere', 'lib')) + makeGitDir(join(lib, 'modules', 'lfs', 'client')) + writeFileSync(join(lib, 'modules', 'HEAD'), '') + linkFromBothSides(join(lib, 'modules')) + symlinkSync(lib, join(modules, 'lib')) + + expect(submoduleGitDirs(modules).gitDirs).toContain( + join(modules, 'lib', 'modules', 'lfs', 'client'), + ) + }) + }) + + it('takes no nested submodule named hooks for the hooks of a git directory', () => { + // It would be denied whole, with no help from any command. + const lib = makeGitDir(join(modules, 'lib')) + const nested = ['hooks', 'objects'].map(name => + makeGitDir(join(lib, 'modules', name)), + ) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([lib, ...nested]) + }) + + describe.if(!isWindows)('what cannot be named', () => { + const NO_TEXT = Buffer.from([0x63, 0x61, 0x66, 0xe9]) + + it('has the directory holding a link to it denied whole, where nothing is there', () => { + // A command can make one here, so it costs what any link here does, and + // not every command after it. const linked = join(modules, 'linked') - const named = join(modules, 'named') mkdirSync(linked, { recursive: true }) - mkdirSync(named) - symlinkSync(Buffer.from([0x2e, 0x2e, 0x2f, 0xff]), join(linked, 'x')) - mkdirSync( - Buffer.concat([Buffer.from(`${named}/caf`), Buffer.from([0xe9])]), - ) + symlinkSync(NO_TEXT, join(linked, 'x')) expect(submoduleGitDirs(modules)).toEqual({ gitDirs: [], - unreadableDirs: [named], - linkedEntryDirs: [linked], + unreadableDirs: [linked], + linkedEntryDirs: [], chainHops: [], }) - }, - ) + }) + + // The macOS file systems take no such name. + it.if(isLinux)('has the directory holding it denied whole', () => { + const named = join(modules, 'named') + mkdirSync(named, { recursive: true }) + mkdirSync(Buffer.concat([Buffer.from(`${named}/`), NO_TEXT])) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([named]) + }) + + it.if(isLinux)('refuses where a link leads to it', () => { + // What is there cannot be denied, and the host's git follows the link. + mkdirSync(modules) + mkdirSync(Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT])) + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + + expect(() => submoduleGitDirs(modules)).toThrow(GitMetadataError) + }) + }) it('does not walk into what git itself keeps in a git directory', () => { // A real submodule git directory with everything in it that is large: diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts index da8969689..3174fae63 100644 --- a/test/sandbox/mandatory-deny-time-budget.test.ts +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -309,6 +309,22 @@ describe.if(isLinux)('Passes over a profile that does not fit', () => { denyPath.startsWith(join(dir, 'repo')), ), ).toEqual({ wholeGitDirs: 72, wholeModulesDirs: 0 }) + + // And so for the directories that hold them: the one that takes no mount + // gives nothing back, and takes nothing from what the next one gives, nine + // binds of its ten. The first is left as it is. + const further = [ + repository(join(dir, 'repo', 'first', '.git', 'modules'), 10), + ...repositories, + ] + expect( + collapseFurther( + { ...plan, repositories: further }, + { wholeGitDirs: 90, wholeModulesDirs: 0 }, + 9, + denyPath => denyPath.startsWith(join(dir, 'repo')), + ), + ).toEqual({ wholeGitDirs: 90, wholeModulesDirs: 2 }) }) it('resolves each path once to say what was degraded', () => { @@ -342,6 +358,25 @@ describe.if(isLinux)('Passes over a profile that does not fit', () => { } }) + it('degrades once a git directory that two repositories lead to', async () => { + // A nested submodule's is reached from the repository that keeps it and + // through the `.git` file of the checkout it is nested in. + const { checkout, subs } = makeSuperproject(join(dir, 'repo'), 2) + makeGitDir(join(subs[0]!, 'modules', 'nested')) + mkdirSync(join(checkout, 'sub')) + writeFileSync(join(checkout, 'sub', '.git'), `gitdir: ${subs[0]!}\n`) + process.chdir(checkout) + + const { warnings } = await wrapCountingPasses(checkout, 8_900) + + const modules = join(checkout, '.git', 'modules') + const told = warnings.join('\n') + expect(told).toContain( + `3 of the 3 submodule git directories under ${modules} are`, + ) + expect(told).toContain(`; ${modules} is denied whole`) + }) + it('still stops at the first level that fits where stepping does give words back', async () => { // A hundred submodule git directories the command MAY write, in a budget // that holds some of them precise and not all: each one degraded gives diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index 2b724c68f..c3e050541 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -18,7 +18,10 @@ import { join } from 'node:path' import { lastMountAt } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux, isWindows } from '../helpers/platform.js' -import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-utils.js' +import { + macGetMandatoryDenyEntries, + wrapCommandWithSandboxMacOS, +} from '../../src/sandbox/macos-sandbox-utils.js' import { cleanupBwrapMountPoints, linuxGetCwdMandatoryDenyPaths, @@ -322,6 +325,24 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) }) + it('carries it into the macOS profile where a write root is a pattern', () => { + // What a pattern reaches is not worked out. + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { + allowOnly: [checkout, join(dir, 'ma*')], + denyWithinAllow: [], + }, + }) + + for (const denyPath of kept) expect(profile).toContain(denyPath) + }) + it('carries what they keep into the macOS entries', () => { const { checkout, kept } = repositoryThatKeepsMore() process.chdir(checkout) @@ -394,35 +415,6 @@ describe.if(!isWindows)('Linked worktree git directories', () => { }, ) - it.if(isLinux)( - 'enumerates once a git directory reached both ways', - async () => { - // Found twice, what it keeps would be degraded twice, and the second - // time gives nothing back. - const project = join(dir, 'project') - const submodule = makeGitDir(join(project, '.git', 'modules', 'lib')) - makeGitDir(join(project, '.git')) - makeGitDir(join(submodule, 'modules', 'nested')) - mkdirSync(join(project, 'lib')) - writeFileSync( - join(project, 'lib', '.git'), - 'gitdir: ../.git/modules/lib\n', - ) - process.chdir(project) - const list = spyOn(fs, 'readdirSync') - - const listed = await wrap('true', [project]) - .then(() => list.mock.calls.map(([listedDir]) => listedDir)) - .finally(() => list.mockRestore()) - - expect( - listed.filter( - listedDir => listedDir === join(submodule, 'modules', 'nested'), - ), - ).toHaveLength(1) - }, - ) - it('keeps them with nothing left in the directory at all', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const { worktreeGitDir, pointer } = makeLinkedWorktree( @@ -986,25 +978,106 @@ describe.if(!isWindows)('Linked worktree git directories', () => { expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) }) - it('takes no visit as one thing for a visit as another', () => { - // A command can link any directory into a `worktrees` or a `modules`, to - // have it visited there first, as what is denied the least. - const visited = new Set() + it('ends where the modules of a linked worktree is the link back', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodules = ['a', 'b', 'c'].map(name => + makeGitDir(join(mainGitDir, 'modules', name)), + ) + for (const submodule of submodules) { + mkdirSync(join(submodule, 'worktrees', 'w'), { recursive: true }) + symlinkSync( + join(mainGitDir, 'modules'), + join(submodule, 'worktrees', 'w', 'modules'), + ) + } + + const denies = gitDirTreeDenies(mainGitDir, false, { + deadline: Date.now() + 5000, + }) + + expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) + }) + + it('walks once more at most what many links lead to', () => { + // Both numbers are a command's to choose, and their product is what runs + // a walk out of its time. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'm')) + for (const id of ['1', '2', '3']) { + mkdirSync(join(submodule, 'worktrees', id), { recursive: true }) + } + for (const name of ['a', 'b', 'c', 'x', 'y', 'z']) { + // To the git directory, and to its `worktrees` out of one made up. + symlinkSync('m', join(mainGitDir, 'modules', name)) + const madeUp = makeGitDir(join(mainGitDir, 'modules', `${name}2`)) + symlinkSync('../m/worktrees', join(madeUp, 'worktrees')) + } + + const commondirs = gitDirTreeDenyPaths( + gitDirTreeDenies(mainGitDir, false), + ).filter(denyPath => /worktrees\/\d\/commondir$/.test(denyPath)) + + expect(commondirs).toEqual( + expect.arrayContaining( + ['1', '2', '3'].map(id => + join(submodule, 'worktrees', id, 'commondir'), + ), + ), + ) + expect(commondirs.length).toBeLessThanOrEqual(6) + }) + + it('walks once what several git directories lead to, for a caller that has them all', () => { + // A submodule's is reached from the repository that keeps it and through + // the `.git` file of its checkout, and a command can write as many `.git` + // files as it likes. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const nested = makeGitDir(join(submodule, 'modules', 'nested')) + mkdirSync(join(submodule, 'worktrees', 'w'), { recursive: true }) + mkdirSync(join(mainGitDir, 'worktrees', 'w'), { recursive: true }) + symlinkSync(mainGitDir, join(dir, 'alias')) + const visited = new Map() + + expect( + gitDirTreeDenies(mainGitDir, false, { visited }).submodules.map( + each => each.gitDir, + ), + ).toEqual([submodule, nested]) + const list = spyOn(fs, 'readdirSync') + try { + for (const again of [submodule, mainGitDir, join(dir, 'alias')]) { + expect(gitDirTreeDenies(again, false, { visited })).toMatchObject({ + ownDenyPaths: gitDirDenyPaths(again, false), + submodules: [], + worktrees: [], + }) + } + // Not so much as listed: how much is there is a command's to choose. + expect(list.mock.calls).toEqual([]) + } finally { + list.mockRestore() + } + }) + + it('walks from where it is what an earlier git directory only linked to', () => { + // The one a command made up sorts first. + const madeUp = makeGitDir(join(dir, 'a', '.git')) const mainGitDir = makeGitDir(join(dir, 'main', '.git')) - const other = makeGitDir(join(dir, 'main', 'sub', '.git')) - const submodule = makeGitDir(join(other, 'modules', 'vendor', 'lib')) - const { worktreeGitDir } = makeLinkedWorktree(other, 'w', join(dir, 'w')) - mkdirSync(join(mainGitDir, 'worktrees')) - mkdirSync(join(mainGitDir, 'modules')) - symlinkSync(join(other, 'modules'), join(mainGitDir, 'worktrees', 'x')) - symlinkSync(join(other, 'worktrees'), join(mainGitDir, 'modules', 'y')) - - gitDirTreeDenies(mainGitDir, false, { visited }) - const denyPaths = gitDirTreeDenyPaths( - gitDirTreeDenies(other, false, { visited }), + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), ) + symlinkSync(join(mainGitDir, 'modules'), join(madeUp, 'modules')) + symlinkSync(join(mainGitDir, 'worktrees'), join(madeUp, 'worktrees')) + const visited = new Map() + gitDirTreeDenies(madeUp, false, { visited }) - expect(denyPaths).toEqual( + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false, { visited })), + ).toEqual( expect.arrayContaining([ ...gitDirDenyPaths(submodule, false), ...heldFiles(worktreeGitDir), @@ -1012,38 +1085,76 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) }) - it('walks a modules from the top though it was walked into from elsewhere', () => { - // Linked into another `modules`, with a `HEAD` left in it, it is a git - // directory there and its `lfs` git's own. Where the walk starts from it, - // `lfs` is the first segment of a submodule's name. - const visited = new Set() + it('walks what really lies under a git directory, whatever links lead there', () => { + // A command can link any directory into a `worktrees` or a `modules`, to + // have it visited from there first, as what is denied the least: a + // `modules` as a linked worktree, a `worktrees` as a name, a submodule as + // the `modules` of a worktree it made up. const mainGitDir = makeGitDir(join(dir, 'main', '.git')) - const other = makeGitDir(join(dir, 'main', 'sub', '.git')) - const submodule = makeGitDir(join(other, 'modules', 'lfs', 'client')) - writeFileSync(join(other, 'modules', 'HEAD'), '') - mkdirSync(join(mainGitDir, 'modules')) - symlinkSync(join(other, 'modules'), join(mainGitDir, 'modules', 'x')) - - gitDirTreeDenies(mainGitDir, false, { visited }) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'vendor', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), + ) + const kept = makeGitDir(join(worktreeGitDir, 'modules', 'kept')) + mkdirSync(join(mainGitDir, 'worktrees', 'b')) + symlinkSync('../modules', join(mainGitDir, 'worktrees', 'a')) + symlinkSync(submodule, join(mainGitDir, 'worktrees', 'b', 'modules')) + symlinkSync('../worktrees', join(mainGitDir, 'modules', 'a')) + // The submodules are walked before the linked worktrees, so this one is + // how the `modules` of `w` is walked first. + mkdirSync(join(submodule, 'worktrees', 'x'), { recursive: true }) + symlinkSync( + join(worktreeGitDir, 'modules'), + join(submodule, 'worktrees', 'x', 'modules'), + ) - expect( - gitDirTreeDenyPaths(gitDirTreeDenies(other, false, { visited })), - ).toEqual(expect.arrayContaining(gitDirDenyPaths(submodule, false))) + expect(gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false))).toEqual( + expect.arrayContaining([ + ...gitDirDenyPaths(submodule, false), + ...heldFiles(worktreeGitDir), + ...gitDirDenyPaths(kept, false), + ]), + ) }) - it('denies a worktrees whole that holds a name that is no text', () => { + it('denies a linked worktree under each name that leads to it', () => { + // On macOS it is what is denied beneath a link that holds the link. const mainGitDir = makeGitDir(join(dir, 'main', '.git')) - const worktrees = join(mainGitDir, 'worktrees') - mkdirSync(worktrees) - mkdirSync( - Buffer.concat([Buffer.from(`${worktrees}/caf`), Buffer.from([0xe9])]), + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), ) + const alias = join(mainGitDir, 'worktrees', 'alias') + symlinkSync('w', alias) - expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ - worktrees, - ]) + expect(gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false))).toEqual( + expect.arrayContaining([ + ...heldFiles(worktreeGitDir), + ...heldFiles(alias), + ]), + ) }) + // The macOS file systems take no such name. + it.if(isLinux)( + 'denies a worktrees whole that holds a name that is no text', + () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktrees = join(mainGitDir, 'worktrees') + mkdirSync(worktrees) + mkdirSync( + Buffer.concat([Buffer.from(`${worktrees}/caf`), Buffer.from([0xe9])]), + ) + + expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ + worktrees, + ]) + }, + ) + it('holds a worktrees that is a link, as it holds a modules that is one', () => { // Remove the link, put a directory where it was, and every `.git` file // that names a worktree's git directory through it opens a planted one. @@ -1062,21 +1173,6 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) }) - it('enumerates once a git directory that another one keeps', () => { - const mainGitDir = makeGitDir(join(dir, 'main', '.git')) - const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) - makeGitDir(join(submodule, 'modules', 'nested')) - makeLinkedWorktree(submodule, 'wt', join(dir, 'wt')) - const visited = new Set() - - gitDirTreeDenies(mainGitDir, false, { visited }) - const again = gitDirTreeDenies(submodule, false, { visited }) - - expect(gitDirTreeDenyPaths(again)).toEqual( - gitDirDenyPaths(submodule, false), - ) - }) - it('costs a submodule with no linked worktrees nothing', () => { const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) From ab26e003bfcfc49d6fc20bee0b5b927ba6e84941 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:41:50 +0000 Subject: [PATCH 138/141] hold each link by its own name, and go by what git needs to open a directory A third review of the fixes found no link that hides anything any more, and these. A walk was cut short only after what a link leads to had been listed, and a git directory was recorded under each name that led to it, for macOS to hold the link by. So many links to one large directory cost their product, and on Linux every name counted as mounts that landed on the same paths and had other repositories degraded. The entry link is now a hop like the links after it: macOS holds it by its own name, Linux by the directory holding it, as before. What a directory is read as follows from where it was met, so the cut comes before anything is listed, and a second name is neither listed nor recorded. git opens what is in a git directory by name, which takes leave to search the directory and none to list it. - A linked worktree's that cannot be listed has its commondir followed all the same. One that cannot be searched is denied whole, and refuses nothing. - THREAT: `chmod 311` or `000` on the git directory the working directory's .git file names took every deny off the repository that keeps it, whose git directory such a session is commonly let write. Where the directory is, `/worktrees/`, names that repository as well. - A directory that is denied whole because it cannot be looked into is named by where it really is: the name of a link holds the link, and is no deny of what it leads to. A link whose target is not valid UTF-8 refuses only where a directory is behind it. In the modules of a git directory `hooks` and `objects` are names whatever they are, a link too. In the README as a limit: a nested checkout made unlistable hides its .git file from the scan, and with it a repository outside the working directory. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ Co-authored-by: ronleizrowice --- README.md | 7 +- src/sandbox/mandatory-deny-paths.ts | 155 +++++++++++------- .../mandatory-deny-modules-walk.test.ts | 125 ++++++++++++-- test/sandbox/mandatory-deny-paths.test.ts | 52 ++++-- test/sandbox/mandatory-deny-worktrees.test.ts | 50 +++++- 5 files changed, 299 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index caeb7d062..7b69837a6 100644 --- a/README.md +++ b/README.md @@ -1188,11 +1188,11 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. - **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other. So is what they keep, where the command may write any of it: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. - **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. The `modules` of a git directory is one by the three files alone: a directory in it is a submodule's name, `hooks` and `objects` too. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. -- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. git, running as the same user, cannot read through it either. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. A linked worktree's git directory goes by what git needs, which opens `commondir` by name: one that cannot be searched is denied whole, and one that can be searched and not listed is read as git reads it. Where the directory a `.git` file names cannot be listed and sits at `/worktrees/`, `` is covered as the repository that keeps it, so a `chmod` of the one takes no deny off the other. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. - **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. - **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. - **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. -- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as (submodule names, a git directory's own entries, linked worktrees), on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. +- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as (an entry of a `modules`, submodule names, what git keeps for itself, a `worktrees`, one linked worktree), on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. - **A directory that looks like a git directory does not end the `.git/modules` walk.** A segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, and a `HEAD` left in it would otherwise take the denies off `vendor/lib`. It is denied as a git directory and what else it holds is walked as well, its `modules` included. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and is walked after all where that listing shows a `config` or a `hooks`. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A mount takes three of bubblewrap's 9000 words, and a submodule git directory asks for five mounts, seven where it is checked out. Rather than refuse every command in a repository with hundreds of them, the wrap counts what the profile comes to and, while that is past the cap, degrades from the end: the working directory's own repository is served first, then the nested ones in sorted order, and within a repository the linked worktrees go before the submodules. @@ -1203,6 +1203,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' With one write root and no network restrictions that starts at about 590 submodule git directories, or about 425 where each is checked out. An `SRT_DEBUG` warning says what was degraded. `too_many_arguments` is what is left for a profile that does not fit with everything degraded: measured at about 1485 CHECKED-OUT submodules, whose `.git` files no degrading saves, and at about 495 separate nested repositories. The violation monitor works the same denies out once for the session, with nothing degraded, so the path it reports can be one inside a git directory that is by then denied whole. - On Linux, the scan never looks inside a `node_modules`: a repository under one is not found and its hooks stay writable. +- On Linux, a directory the scan cannot list is denied whole, which covers what is under it and not what a `.git` file in there names. Where a nested checkout's repository lies outside the working directory and the command may write it, a `chmod` that takes the leave to list the checkout, or a directory above it, takes the denies off that repository for the commands after it. - On macOS only the working directory's own `.git` file is followed, and only its own repository is enumerated. A nested repository is matched by pattern, as `.git/` and `.git/modules//`, which covers a single-segment submodule name. Nothing is degraded there. A Seatbelt profile travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, with no code from this library. **The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap makes the mount point itself, once the manifest that names it is published, holding what git concludes with no file there (`.` for `commondir`, nothing for `config.worktree`), and binds it read-only onto itself. @@ -1214,7 +1215,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with something behind it. On macOS a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it. On macOS a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. - `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). - `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed is the working directory, or holds it or a write root. diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 1ce2c3375..77a376fd1 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -604,11 +604,24 @@ export function gitFileDenies( // hooks and config its commits run. git reads it out of the directory it // opened, so each candidate above has its own. for (const { gitDir, kind } of gitDirs) { - // Only a git directory has a commondir git reads; a directory this - // process could not list is already denied whole, and git, running as - // the same user, cannot read through it either. - if (kind !== 'git-dir') continue - gatherCommonDirDenies(gitDir, allowGitConfig, gathered, deadline) + if (kind === 'git-dir') { + gatherCommonDirDenies(gitDir, allowGitConfig, gathered, deadline) + } else if (kind === 'unreadable') { + // THREAT: one `chmod` by a command, which leaves the repository that + // keeps this worktree as writable as it was, with nothing read here to + // name it. WHERE the directory is names it as well. + const worktrees = path.dirname(physicalDenyPath(gitDir, deadline)) + if (path.basename(worktrees) !== 'worktrees') continue + const keeper = path.dirname(worktrees) + gatherTargetDenies( + keeper, + gitDirKind(keeper), + allowGitConfig, + gitFile, + gathered, + deadline, + ) + } } } catch (err) { // A dangling pointer names nothing git would read. A pointer this process @@ -904,18 +917,23 @@ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { } if (isNoText(entry, worktreesDir, walk)) continue const child = path.join(worktreesDir, entry.name) - const found = walkEntry(entry, child, walk, deadline) + const found = walkEntry(entry, child, walk) if (found.kind === 'skip') continue // Where a link leads to nothing is denied as a git directory that is not // there yet, as the `.git/modules` walk denies one. const worktree = found.kind === 'dangling' ? found.landing : child - // Denied whole where it cannot be listed, and git, the same user's, reads - // no `commondir` in there either: nothing to refuse the command over. - if ( - found.kind === 'directory' && - listDirectory(worktree, walk) === undefined - ) { - continue + if (found.kind === 'directory') { + if (isCutShort('worktree', worktree, walk)) continue + // git opens what is in there by name, which takes leave to search it and + // none to list it. Without that leave git, the same user's, reads no + // `commondir` either: denied whole, and nothing to refuse the command + // over. + try { + fs.accessSync(worktree, fs.constants.X_OK) + } catch (err) { + denyUnreadable(worktree, err, walk) + continue + } } const own = gitDirDenies( worktree, @@ -1031,6 +1049,7 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { GIT_OWN_DIRECTORIES.has(entry.name) && (entry.isDirectory() || entry.isSymbolicLink()) ) { + if (isCutShort('own', child, walk)) continue childEntries = listDirectory(child, walk) if ( childEntries?.some(e => GIT_PROTECTED_ENTRIES.has(e.name)) !== true @@ -1042,7 +1061,7 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { // git accepts a symlinked entry under .git/modules, and // Dirent.isDirectory is false for one, so the link is followed — and // the real path recorded, since a link back up would otherwise loop. - const found = walkEntry(entry, child, walk, walk.deadline) + const found = walkEntry(entry, child, walk) if (found.kind === 'skip') continue if (found.kind === 'dangling') { // Where a link leads to nothing is denied as a git directory that is @@ -1051,24 +1070,30 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { gitDirs.add(found.landing) continue } - childEntries ??= listDirectory(child, walk) - if (childEntries === undefined) continue // In the `modules` of a git directory every entry is a submodule's name, - // as in `modulesDir`, whatever a command left beside them: `hooks` is one - // too. So only a file makes a git directory of it. + // as in `modulesDir`, whatever a command left beside them. const holdsNames = item.gitDir === true && entry.name === 'modules' + // Before it is listed: how many links lead to it and how much it holds + // are both a command's to choose. + if (isCutShort(holdsNames ? 'names' : 'entry', child, walk)) continue + childEntries ??= listDirectory(child, walk) + if (childEntries === undefined) continue + // `hooks` and `objects` are names there too, so neither makes a git + // directory of it. const isGitDir = looksLikeGitDir( - holdsNames ? childEntries.filter(e => !e.isDirectory()) : childEntries, + holdsNames + ? childEntries.filter(e => e.name !== 'hooks' && e.name !== 'objects') + : childEntries, ) - // By each name that leads to it: on macOS it is what is denied beneath a - // link that holds the link. if (isGitDir) gitDirs.add(child) // Anything else is a segment of a submodule name (`vendor` of // `vendor/lib`) - and a directory that looks like a git directory can // be one as well, so what else it holds is looked at either way. - const gitDir = isGitDir && !holdsNames - if (isCutShort(gitDir ? 'own' : 'names', child, walk)) continue - pending.push({ dir: child, entries: childEntries, gitDir }) + pending.push({ + dir: child, + entries: childEntries, + gitDir: isGitDir && !holdsNames, + }) } } return [...gitDirs].sort() @@ -1086,20 +1111,29 @@ interface TreeWalk { realRoot: string /** See {@link isCutShort}. */ visited: Map - /** As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold - * to the same ancestor, and a repeated path is a mount the profile never - * pays for but would otherwise be counted as one. */ + /** + * As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold to + * the same ancestor, and a repeated path is a mount the profile never pays + * for but would otherwise be counted as one. + * + * INVARIANT: each of `unreadableDirs` is where the directory really is. The + * name of a link is what holds the link, and no deny of what it leads to + * ({@link GitChainHop.link}). + */ unreadableDirs: Set linkedEntryDirs: Set chainHops: GitChainHop[] } /** - * Whether a walk stops short of `dir`, whose entries it would read `as` names - * of submodules, as a git directory's `own` or as linked `worktrees`: a walk has - * read them so before, by where `dir` really is. That is what ends a link that - * leads back up, and what keeps many links to one directory, or many `.git` - * files into one repository, from costing as many walks of it. + * Whether a walk stops short of `dir`, which it would read `as` an `entry` of a + * walked directory (a git directory or a segment of a name, by what it holds), + * as git's `own`, as the `names` of submodules, as a `worktrees` or as one + * linked `worktree`: a walk has read it so before, by where `dir` really is. + * That is what ends a link that leads back up, and what keeps many links to one + * directory, or many `.git` files into one repository, from costing as many + * walks of it. Such a link is held by its own name all the same ({@link + * walkEntry}). * * INVARIANT: for a directory that is where its name says, with no link on the * way from the root, only a walk that came to it so stands in. A command can @@ -1108,7 +1142,7 @@ interface TreeWalk { * walked from it all the same. */ function isCutShort( - as: 'names' | 'own' | 'worktrees', + as: 'entry' | 'own' | 'names' | 'worktrees' | 'worktree', dir: string, walk: TreeWalk, ): boolean { @@ -1140,7 +1174,7 @@ type WalkEntry = */ function isNoText(entry: fs.Dirent, dir: string, walk: TreeWalk): boolean { if (!entry.name.includes('\uFFFD')) return false - walk.unreadableDirs.add(dir) + walk.unreadableDirs.add(realPathOrSelf(dir)) logForDebugging( `[Sandbox] An entry of ${dir} has a name that is not valid UTF-8, denying ${dir} whole`, { level: 'warn' }, @@ -1154,33 +1188,35 @@ function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { return fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { // Absent is the common case: no submodules, or none nested in this one. - if (!isAbsenceError(err)) { - const denied = deepestReachableAncestor(dir) ?? dir - walk.unreadableDirs.add(denied) - logForDebugging( - `[Sandbox] Could not list ${dir}, denying ${denied} whole: ${err}`, - { level: 'warn' }, - ) - } + if (!isAbsenceError(err)) denyUnreadable(dir, err, walk) return undefined } } +function denyUnreadable(dir: string, err: unknown, walk: TreeWalk): void { + const denied = realPathOrSelf(deepestReachableAncestor(dir) ?? dir) + walk.unreadableDirs.add(denied) + logForDebugging( + `[Sandbox] Could not look into ${dir}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) +} + /** * What `entry` is worth doing with, following a symlink to a directory. A link * that leads anywhere puts the directory HOLDING it on the whole-directory - * denies ({@link SubmoduleScan.linkedEntryDirs}), and further links on the way - * are held hop by hop ({@link GitChainHop}). A command can plant one and leave - * every submodule beside it read-only for the commands after it. + * denies ({@link SubmoduleScan.linkedEntryDirs}), and it and the further links + * on the way are held hop by hop ({@link GitChainHop}). A command can plant one + * and leave every submodule beside it read-only for the commands after it. */ function walkEntry( entry: fs.Dirent, entryPath: string, walk: TreeWalk, - deadline: number, ): WalkEntry { if (entry.isDirectory()) return { kind: 'directory' } if (!entry.isSymbolicLink()) return { kind: 'skip' } + const { deadline } = walk const holder = path.dirname(entryPath) let chain: ResolvedChain try { @@ -1190,24 +1226,31 @@ function walkEntry( deadline, ) } catch (err) { - // Where it leads cannot be named, so what is there cannot be denied. - if (!(err instanceof GitMetadataError) || fs.existsSync(entryPath)) { + if (!(err instanceof GitMetadataError)) throw err + // Where it leads cannot be named, so a directory there cannot be denied, + // nor what cannot be looked at. + let behind: fs.Stats | undefined + try { + behind = fs.statSync(entryPath, { throwIfNoEntry: false }) + } catch { throw err } - // Nothing is. A command can make such a link here, so it costs what any - // link here does and not every command after it. - walk.unreadableDirs.add(holder) + if (behind?.isDirectory() === true) throw err + if (behind !== undefined) return { kind: 'skip' } + // Nothing is there. A command can make such a link here, so it costs what + // any link here does and not every command after it. + walk.unreadableDirs.add(realPathOrSelf(holder)) logForDebugging( `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, to nothing; denying ${holder} whole`, { level: 'warn' }, ) return { kind: 'skip' } } - // The first hop is the entry, whose holder joins `linkedEntryDirs` below; - // the rest lie between it and what it lands on, and that deny reaches - // neither them nor the directories they sit in. + // The first hop is the entry, whose holder joins `linkedEntryDirs` below. The + // rest lie between it and what it lands on, and that deny reaches neither + // them nor the directories they sit in. const holdChain = (chainState: GitChainHop['chain']): void => { - for (const hop of chain.hops.slice(1)) { + for (const hop of chain.hops) { walk.chainHops.push({ kind: 'entry', chain: chainState, @@ -1314,6 +1357,8 @@ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) + // git opens what is in there by name, which takes leave to search it. + fs.accessSync(dir, fs.constants.X_OK) } catch (err) { if (isUnusablePathError(err)) return 'unusable' return isAbsenceError(err) ? 'absent' : 'unreadable' diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index f68c090c3..3134a0019 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' import { spawnSync } from 'node:child_process' import { chmodSync, @@ -207,17 +208,72 @@ describe('The .git/modules walk beneath what looks like a git directory', () => } } - it('is a git directory by each name', () => { + it('is listed once more at most, and each link held by its own name', () => { + // How many links and how much they lead to are a command's to choose. const lib = makeGitDir(join(modules, 'lib')) linkFromBothSides('../lib') + const list = spyOn(fs, 'readdirSync') + try { + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toContain(lib) + expect(scan.gitDirs.length).toBeLessThanOrEqual(2) + expect(scan.chainHops.map(hop => hop.link).sort()).toEqual([ + join(modules, 'a', 'link'), + join(modules, 'z', 'link'), + ]) + expect( + list.mock.calls.filter( + call => realpathSync(String(call[0])) === realpathSync(lib), + ).length, + ).toBeLessThanOrEqual(2) + } finally { + list.mockRestore() + } + }) + + it('is listed once more at most where it is taken for what git keeps', () => { + const store = join(modules, 'store') + mkdirSync(store, { recursive: true }) + for (const name of ['a', 'b', 'y', 'z']) { + const gitDir = join(modules, name) + mkdirSync(gitDir) + writeFileSync(join(gitDir, 'HEAD'), '') + symlinkSync('../store', join(gitDir, 'objects')) + } + const list = spyOn(fs, 'readdirSync') + try { + submoduleGitDirs(modules) - expect(submoduleGitDirs(modules).gitDirs).toEqual([ - join(modules, 'a', 'link'), - lib, - join(modules, 'z', 'link'), - ]) + expect( + list.mock.calls.filter( + call => realpathSync(String(call[0])) === realpathSync(store), + ).length, + ).toBeLessThanOrEqual(2) + } finally { + list.mockRestore() + } }) + it.if(process.getuid?.() !== 0)( + 'is denied whole where it is, where it cannot be listed', + () => { + // The name of the link holds the link, and on Linux is no deny at all. + const locked = join(dir, 'locked') + mkdirSync(locked) + mkdirSync(modules) + symlinkSync(locked, join(modules, 'link')) + chmodSync(locked, 0o311) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([ + realpathSync(locked), + ]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + it('is walked from where it is', () => { const lib = makeGitDir(join(modules, 'vendor', 'deep', 'lib')) linkFromBothSides('../vendor/deep') @@ -262,6 +318,20 @@ describe('The .git/modules walk beneath what looks like a git directory', () => expect(submoduleGitDirs(modules).gitDirs).toEqual([lib, ...nested]) }) + it.if(!isWindows)('nor one that is kept behind a link', () => { + const lib = makeGitDir(join(modules, 'lib')) + mkdirSync(join(lib, 'modules')) + symlinkSync( + makeGitDir(join(dir, 'store', 'hooks.git')), + join(lib, 'modules', 'hooks'), + ) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + join(lib, 'modules', 'hooks'), + ]) + }) + describe.if(!isWindows)('what cannot be named', () => { const NO_TEXT = Buffer.from([0x63, 0x61, 0x66, 0xe9]) @@ -280,15 +350,50 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }) }) + it('has that directory denied where it really is', () => { + // The name of a link holds the link, and on Linux is no deny at all. + const store = join(dir, 'store') + mkdirSync(store) + mkdirSync(modules) + symlinkSync(NO_TEXT, join(store, 'x')) + symlinkSync(store, join(modules, 'link')) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([store]) + }) + // The macOS file systems take no such name. it.if(isLinux)('has the directory holding it denied whole', () => { - const named = join(modules, 'named') - mkdirSync(named, { recursive: true }) - mkdirSync(Buffer.concat([Buffer.from(`${named}/`), NO_TEXT])) + // Where it really is as well. + const named = [join(modules, 'named'), join(dir, 'store')] + for (const holder of named) { + mkdirSync(holder, { recursive: true }) + mkdirSync(Buffer.concat([Buffer.from(`${holder}/`), NO_TEXT])) + } + symlinkSync(join(dir, 'store'), join(modules, 'link')) - expect(submoduleGitDirs(modules).unreadableDirs).toEqual([named]) + expect(submoduleGitDirs(modules).unreadableDirs).toEqual(named) }) + it.if(isLinux)( + 'passes over a link that leads to a file of such a name', + () => { + // As over any link to a file: git opens no git directory there. + mkdirSync(modules) + writeFileSync(Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT]), '') + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + it.if(isLinux)('refuses where a link leads to it', () => { // What is there cannot be denied, and the host's git follows the link. mkdirSync(modules) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 8a8d6c4ef..b4fcf2481 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -3705,6 +3705,14 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { expect(scan.linkedEntryDirs).toEqual([modules]) expect(scan.chainHops).toEqual([ + // The entry itself, for the backend that holds a link by its name. + { + kind: 'entry', + chain: 'resolved', + source: join(modules, 'lib'), + link: join(modules, 'lib'), + holder: undefined, + }, { kind: 'entry', chain: 'resolved', @@ -4421,23 +4429,43 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { it.if(process.getuid?.() !== 0)( 'denies a git directory it cannot read whole, rather than refusing', () => { - // git, running as the same user, cannot read through it either, so the - // repository is already broken for everyone. Refusing every command in - // the checkout would be a brick one chmod plants; the directory is - // denied whole instead, and nothing under it stays writable. + // Refusing every command in the checkout would be a brick one chmod + // plants; the directory is denied whole instead, and nothing under it + // stays writable. The repository that keeps it is as writable as it was, + // and its `commondir` cannot be read to name it (0o000, 0o444), or was not + // since the directory could not be listed (0o311, through which git still + // opens it): where the directory is names it. const main = makeGitDir(join(dir, 'main.git')) const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') const pointer = makePointer('wt-checkout', worktreeGitDir) - chmodSync(worktreeGitDir, 0o000) try { - expect(gitFileDenies(pointer, false).denyPaths).toEqual([ - pointer, - worktreeGitDir, - ]) + for (const mode of [0o000, 0o311, 0o444]) { + chmodSync(worktreeGitDir, mode) + expect(gitFileDenies(pointer, false)).toMatchObject({ + denyPaths: [ + pointer, + worktreeGitDir, + ...gitDirDenyPaths(main, false), + ], + gitDirs: [main], + }) + } } finally { chmodSync(worktreeGitDir, 0o755) } + // Nowhere else does git keep what belongs to the directory two up. + const elsewhere = makeGitDir(join(main, 'kept', 'wt')) + const other = makePointer('other-checkout', elsewhere) + chmodSync(elsewhere, 0o000) + try { + expect(gitFileDenies(other, false).denyPaths).toEqual([ + other, + elsewhere, + ]) + } finally { + chmodSync(elsewhere, 0o755) + } }, ) @@ -4589,11 +4617,10 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { symlinkSync(join(dir, 'modules'), join(dir, 'modules', 'a', 'back')) const gitDir = makeGitDir(join(dir, 'modules', 'a', 'lib')) - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + expect(submoduleGitDirs(join(dir, 'modules'))).toMatchObject({ gitDirs: [gitDir], unreadableDirs: [], linkedEntryDirs: [join(dir, 'modules', 'a')], - chainHops: [], }) }) @@ -4648,11 +4675,10 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { symlinkSync(join(locked, 'two'), join(dir, 'modules', 'b')) chmodSync(locked, 0o000) try { - expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + expect(submoduleGitDirs(join(dir, 'modules'))).toMatchObject({ gitDirs: [sibling], unreadableDirs: [locked], linkedEntryDirs: [join(dir, 'modules')], - chainHops: [], }) } finally { chmodSync(locked, 0o755) diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index c3e050541..2ad448851 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -1119,25 +1119,57 @@ describe.if(!isWindows)('Linked worktree git directories', () => { ) }) - it('denies a linked worktree under each name that leads to it', () => { - // On macOS it is what is denied beneath a link that holds the link. + it('holds by its own name each link that leads to a linked worktree', () => { + // Which is what holds it on macOS. What it leads to is read once. const mainGitDir = makeGitDir(join(dir, 'main', '.git')) const { worktreeGitDir } = makeLinkedWorktree( mainGitDir, 'w', join(dir, 'w'), ) - const alias = join(mainGitDir, 'worktrees', 'alias') - symlinkSync('w', alias) + const links = ['a', 'b', 'x', 'y'].map(name => + join(mainGitDir, 'worktrees', name), + ) + for (const link of links) symlinkSync('w', link) - expect(gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false))).toEqual( - expect.arrayContaining([ - ...heldFiles(worktreeGitDir), - ...heldFiles(alias), - ]), + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + expect(denyPaths).toEqual( + expect.arrayContaining([...heldFiles(worktreeGitDir), ...links]), ) + expect( + denyPaths.filter(denyPath => denyPath.endsWith('/commondir')).length, + ).toBeLessThanOrEqual(3) }) + it.if(NOT_ROOT)( + 'goes by whether a linked worktree can be searched, as git does', + () => { + // git opens `commondir` by name. Without leave to list the directory it + // still does, so what that names stays denied. Without leave to search + // it git opens nothing: denied whole, and no command is refused. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const other = makeGitDir(join(dir, 'other', '.git')) + const worktree = join(mainGitDir, 'worktrees', 'w') + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, 'commondir'), `${other}\n`) + try { + chmodSync(worktree, 0o311) + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)), + ).toEqual(expect.arrayContaining(gitDirDenyPaths(other, false))) + for (const mode of [0o444, 0o000]) { + chmodSync(worktree, mode) + expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ + worktree, + ]) + } + } finally { + chmodSync(worktree, 0o755) + } + }, + ) + // The macOS file systems take no such name. it.if(isLinux)( 'denies a worktrees whole that holds a name that is no text', From f3cdda2fd32cd4720bfe6bd17ef2d782c2e3f07e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:56:39 +0000 Subject: [PATCH 139/141] take back what the last round broke, and name a whole deny by where it is A fourth review found three things the last commit brought in. - gitDirKind asked for leave to search the directory a .git file names. One that can be listed and not searched then counted as unreadable, its one deny was the directory whole, and for a write root that is dropped: a `chmod 600` took every deny off where the command used to be refused. Taken back. - The cut before the one listing of a directory with a name git keeps for itself came before the link was followed, so a second `refs` link to one directory was not held. Taken back. Many such links cost a listing each, as they did. - The directory a walk starts from and the `modules` of a git directory were read as one thing. The second may be a git directory itself, and a walk a command had start there first kept it from being recorded. Two things now. And these, which were there before. - THREAT: the work-tree guard took a whole deny that lands on a write root for the holder of a link wherever it could list the directory. One that can be listed and not searched stands in for a tree that cannot be read as well: refused, as one that cannot be listed is. - THREAT: the directory a .git file or a commondir names, where it cannot be read, was denied by the name the value ends in. Where that is a link it is a hop, which Linux drops and macOS matches against the link alone. Named by where it really is. - A link under `worktrees` that leads nowhere for the kernel can fold on paper onto a directory that is there: read once, like one that resolves. - A link whose target is not valid UTF-8 refuses nothing where it is a loop or leads below a file, under Bun as under Node. - One quadratic filter outside every time budget. In the README as limits: on macOS a whole deny that lands on a write root is dropped whether or not the directory can be looked into, and a linked entry of a git directory that is itself reached through a link is not named as a removal names it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ Co-authored-by: ronleizrowice --- README.md | 9 +-- src/sandbox/linux-sandbox-utils.ts | 15 +++-- src/sandbox/mandatory-deny-paths.ts | 40 +++++------ test/sandbox/linux-work-tree-guard.test.ts | 29 ++++++++ .../mandatory-deny-modules-walk.test.ts | 67 +++++++++++++------ test/sandbox/mandatory-deny-paths.test.ts | 20 ++++-- test/sandbox/mandatory-deny-worktrees.test.ts | 47 ++++++++++++- 7 files changed, 171 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 7b69837a6..33be00a90 100644 --- a/README.md +++ b/README.md @@ -1192,7 +1192,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. - **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. - **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. -- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as (an entry of a `modules`, submodule names, what git keeps for itself, a `worktrees`, one linked worktree), on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. +- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as, on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. What a link costs by itself is paid for each: the length of its chain, the depth of the directory holding it, and one listing of what it leads to where it has a name git keeps for itself (`objects`, `refs`). A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. - **A directory that looks like a git directory does not end the `.git/modules` walk.** A segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, and a `HEAD` left in it would otherwise take the denies off `vendor/lib`. It is denied as a git directory and what else it holds is walked as well, its `modules` included. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and is walked after all where that listing shows a `config` or a `hooks`. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. - **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A mount takes three of bubblewrap's 9000 words, and a submodule git directory asks for five mounts, seven where it is checked out. Rather than refuse every command in a repository with hundreds of them, the wrap counts what the profile comes to and, while that is past the cap, degrades from the end: the working directory's own repository is served first, then the nested ones in sorted order, and within a repository the linked worktrees go before the submodules. @@ -1204,6 +1204,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - On Linux, the scan never looks inside a `node_modules`: a repository under one is not found and its hooks stay writable. - On Linux, a directory the scan cannot list is denied whole, which covers what is under it and not what a `.git` file in there names. Where a nested checkout's repository lies outside the working directory and the command may write it, a `chmod` that takes the leave to list the checkout, or a directory above it, takes the denies off that repository for the commands after it. +- On macOS a whole deny that lands on a write root is dropped whether or not the directory can be looked into, where Linux refuses: after a `chmod` that takes the leave to list a git directory the command may write, that directory has no deny. An entry that is a link, in a git directory that is itself reached through a link, is named by the path it was reached by, which is not the name a removal of it uses. - On macOS only the working directory's own `.git` file is followed, and only its own repository is enumerated. A nested repository is matched by pattern, as `.git/` and `.git/modules//`, which covers a single-segment submodule name. Nothing is degraded there. A Seatbelt profile travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, with no code from this library. **The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap makes the mount point itself, once the manifest that names it is published, holding what git concludes with no file there (`.` for `commondir`, nothing for `config.worktree`), and binds it read-only onto itself. @@ -1215,15 +1216,15 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it. On macOS a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it, or something that cannot be looked at. On macOS a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. - `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). -- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed is the working directory, or holds it or a write root. +- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed, or cannot be searched, is the working directory or a write root, or holds one (a git directory the command may write, after a `chmod` of it). - `too_many_arguments`: enough CHECKED-OUT submodules, or nested repositories, that the profile does not fit with everything degraded. Any other directory a command makes unreadable refuses nothing: it is denied whole. Neither does a wide `.git/modules`: what that costs the commands after it is degraded denies. -**No whole-directory deny is emitted for the work tree.** Each of them stands in for something read off paths a command can write (`gitdir: .` in a `.git` file, a `.git/modules` entry linked at `../..`, an entry of a git directory linked at the checkout), and one that lands on the working directory, a write root or a directory holding either would leave every later command unable to write the project. Judged by where it lands, such a deny is dropped with a warning where the directory can be listed: what it was holding keeps the deny paths of its own two ends. Where it cannot be listed the command is refused (`deny_scan_failed`). A write root that is a file holds no tree, and a deny that lands on it stands. So does one of the denied names where it is spelled (`.vscode`, the `hooks` of a git directory) with a write root configured at or beneath it: the deny wins. +**No whole-directory deny is emitted for the work tree.** Each of them stands in for something read off paths a command can write (`gitdir: .` in a `.git` file, a `.git/modules` entry linked at `../..`, an entry of a git directory linked at the checkout), and one that lands on the working directory, a write root or a directory holding either would leave every later command unable to write the project. Judged by where it lands, such a deny is dropped with a warning where the directory can be listed and searched: what it was holding keeps the deny paths of its own two ends. Where it cannot the command is refused (`deny_scan_failed`). A write root that is a file holds no tree, and a deny that lands on it stands. So does one of the denied names where it is spelled (`.vscode`, the `hooks` of a git directory) with a write root configured at or beneath it: the deny wins. **Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index fd66f59f0..60f756ebb 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -891,7 +891,7 @@ function wholeDirDenyLanding(dir: string): string { } /** - * `denyPaths` without the ones naming an intermediate hop of a chain. A bind + * `denyPaths` without the ones naming a hop of a chain. A bind * there lands on whatever the link leads to — for a symlinked directory * component a whole directory nothing asked to deny, and for a `.git/modules` * entry the git directory it reaches — while holding the link itself, which @@ -987,11 +987,12 @@ function guardedDenyPlan( const denied = new Set(plan.denyPaths) const added = held.filter(dir => !denied.has(dir)) if (added.length > 0) { + const isAdded = new Set(added) // A holder is a directory nothing asked to deny: say which link put it // there, so a subtree that turns read-only can be traced to its cause. const why = new Set( plan.chainHops - .filter(hop => hop.holder !== undefined && added.includes(hop.holder)) + .filter(hop => hop.holder !== undefined && isAdded.has(hop.holder)) .map( hop => `${hop.holder} holds ${hop.link}, which ${hop.source} is reached through`, @@ -1019,9 +1020,10 @@ function guardedDenyPlan( * working directory or a write root leaves nothing in the project writable from * the next wrap on, with nothing inside the sandbox able to undo it. * - * - A directory this process CAN list was denied whole to hold something inside - * it, both ends of which keep their own deny paths: dropped, with a warning. - * - One it cannot list stands in for a tree nothing else covers: the command is + * - A directory this process CAN list and search was denied whole to hold + * something inside it, both ends of which keep their own deny paths: dropped, + * with a warning. + * - One it cannot stands in for a tree nothing else covers: the command is * REFUSED, naming it. * * Each is judged by where it LANDS, not by how it is spelled: `hooks -> ../..` @@ -1051,6 +1053,7 @@ function withoutWorkTreeDenies( } try { fs.readdirSync(landing) + fs.accessSync(landing, fs.constants.X_OK) } catch (err) { // A file holds no tree. It is a write root itself, /dev/null among the // default ones, that an entry was linked to: the deny stands. @@ -1059,7 +1062,7 @@ function withoutWorkTreeDenies( continue } const message = - `[Sandbox Linux] ${denyPath} could not be listed (${errorText(err)}), ` + + `[Sandbox Linux] ${denyPath} could not be looked into (${errorText(err)}), ` + `and it holds the working directory or a path this command may ` + `write: denying it whole is the only thing that stands in for a tree ` + `that cannot be read, and that would leave every command after this ` + diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index 77a376fd1..bfa3417d2 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -156,9 +156,9 @@ interface ResolvedChain { } /** - * A symlink a git directory is reached THROUGH: a further hop of a symlinked - * entry, a symlinked directory on the way, or a component of the path a - * `gitdir:` or a `commondir` names. The denies at the two ends of the chain + * A symlink a git directory is reached THROUGH: an entry of a `modules` or a + * `worktrees`, a further hop of a symlinked entry, a symlinked directory on the + * way, or a component of the path a `gitdir:` or a `commondir` names. The denies at the two ends of the chain * cover neither it nor the directory it sits in, and a command that retargets * it moves where the chain leads, for the host's git to follow. */ @@ -212,7 +212,7 @@ export interface SubmoduleScan { * Sorted, no duplicates. */ linkedEntryDirs: string[] - /** The hops between such an entry and what it lands on. */ + /** Such an entry, and the hops between it and what it lands on. */ chainHops: GitChainHop[] } @@ -922,8 +922,8 @@ function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { // Where a link leads to nothing is denied as a git directory that is not // there yet, as the `.git/modules` walk denies one. const worktree = found.kind === 'dangling' ? found.landing : child + if (isCutShort('worktree', worktree, walk)) continue if (found.kind === 'directory') { - if (isCutShort('worktree', worktree, walk)) continue // git opens what is in there by name, which takes leave to search it and // none to list it. Without that leave git, the same user's, reads no // `commondir` either: denied whole, and nothing to refuse the command @@ -1010,7 +1010,7 @@ export function submoduleGitDirs( * see, so the deadline is what bounds the walk. */ function walkModules(modulesDir: string, walk: TreeWalk): string[] { - if (isCutShort('names', modulesDir, walk)) return [] + if (isCutShort('root', modulesDir, walk)) return [] const gitDirs = new Set() // Where the walk still has to look. A directory that was listed carries its // entries, and `gitDir` says it was recorded as a git directory, so that some @@ -1049,7 +1049,6 @@ function walkModules(modulesDir: string, walk: TreeWalk): string[] { GIT_OWN_DIRECTORIES.has(entry.name) && (entry.isDirectory() || entry.isSymbolicLink()) ) { - if (isCutShort('own', child, walk)) continue childEntries = listDirectory(child, walk) if ( childEntries?.some(e => GIT_PROTECTED_ENTRIES.has(e.name)) !== true @@ -1128,7 +1127,8 @@ interface TreeWalk { /** * Whether a walk stops short of `dir`, which it would read `as` an `entry` of a * walked directory (a git directory or a segment of a name, by what it holds), - * as git's `own`, as the `names` of submodules, as a `worktrees` or as one + * as the `root` of a walk, as the `names` in the `modules` of a git directory + * (which may be one itself, and a root is not), as a `worktrees` or as one * linked `worktree`: a walk has read it so before, by where `dir` really is. * That is what ends a link that leads back up, and what keeps many links to one * directory, or many `.git` files into one repository, from costing as many @@ -1142,7 +1142,7 @@ interface TreeWalk { * walked from it all the same. */ function isCutShort( - as: 'entry' | 'own' | 'names' | 'worktrees' | 'worktree', + as: 'entry' | 'root' | 'names' | 'worktrees' | 'worktree', dir: string, walk: TreeWalk, ): boolean { @@ -1229,14 +1229,13 @@ function walkEntry( if (!(err instanceof GitMetadataError)) throw err // Where it leads cannot be named, so a directory there cannot be denied, // nor what cannot be looked at. - let behind: fs.Stats | undefined try { - behind = fs.statSync(entryPath, { throwIfNoEntry: false }) - } catch { - throw err + if (fs.statSync(entryPath).isDirectory()) throw err + return { kind: 'skip' } + } catch (statErr) { + const code = (statErr as NodeJS.ErrnoException).code + if (!isAbsenceError(statErr) && code !== 'ELOOP') throw err } - if (behind?.isDirectory() === true) throw err - if (behind !== undefined) return { kind: 'skip' } // Nothing is there. A command can make such a link here, so it costs what // any link here does and not every command after it. walk.unreadableDirs.add(realPathOrSelf(holder)) @@ -1324,7 +1323,9 @@ function gitDirTargetDenies( case 'absent': return gitDirDenies(target, allowGitConfig, deadline) case 'unreadable': { - const denied = deepestReachableAncestor(target) ?? target + // Where it really is: a value can end in a link, whose name is what holds + // the link and no deny of what it leads to ({@link GitChainHop.link}). + const denied = realPathOrSelf(deepestReachableAncestor(target) ?? target) logForDebugging( `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, { level: 'warn' }, @@ -1357,8 +1358,6 @@ function gitDirKind(dir: string): GitDirKind { let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) - // git opens what is in there by name, which takes leave to search it. - fs.accessSync(dir, fs.constants.X_OK) } catch (err) { if (isUnusablePathError(err)) return 'unusable' return isAbsenceError(err) ? 'absent' : 'unreadable' @@ -1646,8 +1645,9 @@ export function physicalDenyPath( * The deepest ancestor of `target`, itself included, that this process can stat * as a directory. Denying it fails closed where what lies below cannot be * inspected. Symlinks are followed, so a link to an unlistable directory is - * answered with the link's own path, which the Linux deny loop binds at its - * target, not with the directory holding it. + * answered with the link's own path, not with the directory holding it. That + * path may be a {@link GitChainHop.link}, which is no deny of what it leads to: + * a caller denies where it really is. */ function deepestReachableAncestor(target: string): string | undefined { for (let dir = target; ; dir = path.dirname(dir)) { diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts index c9ff9be1b..e543ad401 100644 --- a/test/sandbox/linux-work-tree-guard.test.ts +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' import { spawnSync } from 'node:child_process' import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -180,6 +181,34 @@ describe.if(isLinux)('a git entry that is a link to the work tree', () => { expect(binds).not.toContain(checkout) }) + it.if(process.getuid?.() !== 0).each([0o311, 0o600, 0o000])( + 'refuses where a write root that cannot be looked into is a git directory (%o)', + async mode => { + // THREAT: a bare repository that a session in its linked worktree may + // write, and one `chmod` by a command. Nothing in it can be named any + // more, so it would be denied whole, which is dropped for a write root + // that holds something: the next command puts the mode back and writes + // the hooks. + const main = join(dir, 'main.git') + const worktreeGitDir = join(main, 'worktrees', 'wt') + mkdirSync(join(main, 'hooks'), { recursive: true }) + mkdirSync(worktreeGitDir, { recursive: true }) + writeFileSync(join(main, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const checkout = join(dir, 'wt') + mkdirSync(checkout) + writeFileSync(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) + chmodSync(main, mode) + try { + expect( + await wrapIn(checkout, 'true', [checkout, main]).catch(String), + ).toContain(`${main} could not be looked into`) + } finally { + chmodSync(main, 0o755) + } + }, + ) + it('still binds an ordinary directory an entry is linked at', async () => { const checkout = makeCheckout('repo') mkdirSync(join(checkout, 'shared-hooks')) diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 3134a0019..96dcc876f 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -232,27 +232,13 @@ describe('The .git/modules walk beneath what looks like a git directory', () => } }) - it('is listed once more at most where it is taken for what git keeps', () => { - const store = join(modules, 'store') - mkdirSync(store, { recursive: true }) - for (const name of ['a', 'b', 'y', 'z']) { - const gitDir = join(modules, name) - mkdirSync(gitDir) - writeFileSync(join(gitDir, 'HEAD'), '') - symlinkSync('../store', join(gitDir, 'objects')) - } - const list = spyOn(fs, 'readdirSync') - try { - submoduleGitDirs(modules) + it('has each link to it held that has a name git keeps for itself', () => { + // `refs` is listed before it is followed, and followed for what it holds. + const store = makeGitDir(join(dir, 'store')) + const gitDirs = ['a', 'b'].map(name => makeGitDir(join(modules, name))) + for (const gitDir of gitDirs) symlinkSync(store, join(gitDir, 'refs')) - expect( - list.mock.calls.filter( - call => realpathSync(String(call[0])) === realpathSync(store), - ).length, - ).toBeLessThanOrEqual(2) - } finally { - list.mockRestore() - } + expect(submoduleGitDirs(modules).linkedEntryDirs).toEqual(gitDirs) }) it.if(process.getuid?.() !== 0)( @@ -394,6 +380,47 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }, ) + it('takes what lies below a file for nothing', () => { + mkdirSync(modules) + writeFileSync(join(dir, 'file'), '') + symlinkSync( + Buffer.concat([Buffer.from('../file/'), NO_TEXT]), + join(modules, 'x'), + ) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([modules]) + }) + + it.if(isLinux)('takes a loop for nothing', () => { + mkdirSync(modules) + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + symlinkSync('modules/x', Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT])) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([modules]) + }) + + it.if(process.getuid?.() !== 0)( + 'refuses where what is behind the link cannot be looked at', + () => { + const locked = join(dir, 'locked') + mkdirSync(locked) + mkdirSync(modules) + symlinkSync( + Buffer.concat([Buffer.from('../locked/'), NO_TEXT]), + join(modules, 'x'), + ) + chmodSync(locked, 0o000) + try { + expect(() => submoduleGitDirs(modules)).toThrow(GitMetadataError) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + it.if(isLinux)('refuses where a link leads to it', () => { // What is there cannot be denied, and the host's git follows the link. mkdirSync(modules) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index b4fcf2481..6c4512b61 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -4432,15 +4432,15 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { // Refusing every command in the checkout would be a brick one chmod // plants; the directory is denied whole instead, and nothing under it // stays writable. The repository that keeps it is as writable as it was, - // and its `commondir` cannot be read to name it (0o000, 0o444), or was not - // since the directory could not be listed (0o311, through which git still - // opens it): where the directory is names it. + // and its `commondir` cannot be read to name it (0o000), or was not since + // the directory could not be listed (0o311, through which git still opens + // it): where the directory is names it. const main = makeGitDir(join(dir, 'main.git')) const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') const pointer = makePointer('wt-checkout', worktreeGitDir) try { - for (const mode of [0o000, 0o311, 0o444]) { + for (const mode of [0o000, 0o311]) { chmodSync(worktreeGitDir, mode) expect(gitFileDenies(pointer, false)).toMatchObject({ denyPaths: [ @@ -4454,6 +4454,18 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { } finally { chmodSync(worktreeGitDir, 0o755) } + // By where it really is, where the value ends in a link: the name of a + // link holds the link, and on Linux is no deny at all. + const behind = makeGitDir(join(dir, 'repos', 'behind')) + mkdirSync(join(dir, 'links')) + symlinkSync(behind, join(dir, 'links', 'gd')) + const linked = makePointer('linked-checkout', join(dir, 'links', 'gd')) + chmodSync(behind, 0o311) + try { + expect(gitFileDenies(linked, false).denyPaths).toContain(behind) + } finally { + chmodSync(behind, 0o755) + } // Nowhere else does git keep what belongs to the directory two up. const elsewhere = makeGitDir(join(main, 'kept', 'wt')) const other = makePointer('other-checkout', elsewhere) diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts index 2ad448851..404504ce3 100644 --- a/test/sandbox/mandatory-deny-worktrees.test.ts +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -1053,13 +1053,56 @@ describe.if(!isWindows)('Linked worktree git directories', () => { worktrees: [], }) } - // Not so much as listed: how much is there is a command's to choose. - expect(list.mock.calls).toEqual([]) + // Not so much as listed: how much is there is a command's to choose. But + // for the `modules` a walk starts from, which the first walk read as one + // that may be a git directory itself. + expect(list.mock.calls.map(call => String(call[0]))).toEqual([ + join(submodule, 'modules'), + ]) } finally { list.mockRestore() } }) + it('records the modules of a git directory that a walk started from earlier', () => { + // `sub/modules` is a submodule's name too. A walk that starts from it + // takes it for no git directory, and a command can have one start there: a + // `.git` file that names `sub`, under a name that sorts first. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const sub = makeGitDir(join(mainGitDir, 'modules', 'sub')) + const named = makeGitDir(join(sub, 'modules')) + const visited = new Map() + gitDirTreeDenies(sub, false, { visited }) + + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false, { visited })), + ).toEqual(expect.arrayContaining(gitDirDenyPaths(named, false))) + }) + + it('reads once what many links that lead nowhere fold to', () => { + // `nope/../w` is nothing to the kernel, and on paper the directory beside + // the link. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktree = join(mainGitDir, 'worktrees', 'w') + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, 'commondir'), '../..\n') + for (const name of ['a', 'b', 'x', 'y']) { + symlinkSync('nope/../w', join(mainGitDir, 'worktrees', name)) + } + const open = spyOn(fs, 'openSync') + try { + gitDirTreeDenies(mainGitDir, false) + + expect( + open.mock.calls.filter(call => + String(call[0]).endsWith('/w/commondir'), + ).length, + ).toBeLessThanOrEqual(2) + } finally { + open.mockRestore() + } + }) + it('walks from where it is what an earlier git directory only linked to', () => { // The one a command made up sorts first. const madeUp = makeGitDir(join(dir, 'a', '.git')) From cac279c8b173b9a669e8b3ad40770d618c83e3f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:56:26 +0000 Subject: [PATCH 140/141] deny what shuts a git directory off behind the link, not around it A review of the last commit alone found no regression, and one variant left open in each of two of its fixes. - THREAT: where a directory ABOVE what a link leads to is shut, the deepest directory that could be reached was looked for along the path as spelled, which climbs to the directory holding the link. Nothing behind the link was denied, and a command that had done the `chmod` undid it in the next wrap and wrote the hooks. Looked for along the path the kernel walks now, for what a .git file or a commondir names and for what a walk cannot list. - A link whose target is not valid UTF-8 and is a name no file can have refused every later command. It is nothing, like a loop. The README said of every linked worktree's git directory what holds for one reached from the repository that keeps it, and did not list the refusal for one a .git file names that can be listed and not searched. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ Co-authored-by: ronleizrowice --- README.md | 8 ++--- src/sandbox/mandatory-deny-paths.ts | 34 +++++++++++-------- .../mandatory-deny-modules-walk.test.ts | 22 ++++++++++-- test/sandbox/mandatory-deny-paths.test.ts | 7 ++-- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 33be00a90..ca34c10be 100644 --- a/README.md +++ b/README.md @@ -1188,7 +1188,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' - **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. - **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other. So is what they keep, where the command may write any of it: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. - **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. The `modules` of a git directory is one by the three files alone: a directory in it is a submodule's name, `hooks` and `objects` too. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. -- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. A linked worktree's git directory goes by what git needs, which opens `commondir` by name: one that cannot be searched is denied whole, and one that can be searched and not listed is read as git reads it. Where the directory a `.git` file names cannot be listed and sits at `/worktrees/`, `` is covered as the repository that keeps it, so a `chmod` of the one takes no deny off the other. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. A linked worktree's git directory, reached from the repository that keeps it, goes by what git needs, which opens `commondir` by name: one that cannot be searched is denied whole, and one that can be searched and not listed is read as git reads it. What is denied whole is the deepest directory that can be reached towards what could not be read, behind any link on the way and not around it. Where the directory a `.git` file names cannot be listed and sits at `/worktrees/`, `` is covered as the repository that keeps it, so a `chmod` of the one takes no deny off the other. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. - **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. - **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. - **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. @@ -1216,13 +1216,13 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: -- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it, or something that cannot be looked at. On macOS a `GitMetadataError`, exported from the package root. +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git, a `commondir` in a directory a `.git` file names that can be listed and not searched), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it, or something that cannot be looked at. On macOS a `GitMetadataError`, exported from the package root. - `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. - `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). -- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed, or cannot be searched, is the working directory or a write root, or holds one (a git directory the command may write, after a `chmod` of it). +- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed, or cannot be searched, is the working directory or a write root, or holds one (a git directory the command may write, after a `chmod` that takes the leave to list it). - `too_many_arguments`: enough CHECKED-OUT submodules, or nested repositories, that the profile does not fit with everything degraded. -Any other directory a command makes unreadable refuses nothing: it is denied whole. Neither does a wide `.git/modules`: what that costs the commands after it is degraded denies. +Any other directory a command makes unreadable, but for the one case under `deny_git_metadata_unreadable`, refuses nothing: it is denied whole. Neither does a wide `.git/modules`: what that costs the commands after it is degraded denies. **No whole-directory deny is emitted for the work tree.** Each of them stands in for something read off paths a command can write (`gitdir: .` in a `.git` file, a `.git/modules` entry linked at `../..`, an entry of a git directory linked at the checkout), and one that lands on the working directory, a write root or a directory holding either would leave every later command unable to write the project. Judged by where it lands, such a deny is dropped with a warning where the directory can be listed and searched: what it was holding keeps the deny paths of its own two ends. Where it cannot the command is refused (`deny_scan_failed`). A write root that is a file holds no tree, and a deny that lands on it stands. So does one of the denied names where it is spelled (`.vscode`, the `hooks` of a git directory) with a write root configured at or beneath it: the deny wins. diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts index bfa3417d2..2c7a9c5f1 100644 --- a/src/sandbox/mandatory-deny-paths.ts +++ b/src/sandbox/mandatory-deny-paths.ts @@ -1194,7 +1194,8 @@ function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { } function denyUnreadable(dir: string, err: unknown, walk: TreeWalk): void { - const denied = realPathOrSelf(deepestReachableAncestor(dir) ?? dir) + const denied = + deepestReachableAncestor(physicalDenyPath(dir, walk.deadline)) ?? dir walk.unreadableDirs.add(denied) logForDebugging( `[Sandbox] Could not look into ${dir}, denying ${denied} whole: ${err}`, @@ -1229,20 +1230,21 @@ function walkEntry( if (!(err instanceof GitMetadataError)) throw err // Where it leads cannot be named, so a directory there cannot be denied, // nor what cannot be looked at. + let behind: fs.Stats try { - if (fs.statSync(entryPath).isDirectory()) throw err - return { kind: 'skip' } + behind = fs.statSync(entryPath) } catch (statErr) { - const code = (statErr as NodeJS.ErrnoException).code - if (!isAbsenceError(statErr) && code !== 'ELOOP') throw err + if (!isAbsenceErrno(statErr)) throw err + // Nothing is there. A command can make such a link here, so it costs what + // any link here does and not every command after it. + walk.unreadableDirs.add(realPathOrSelf(holder)) + logForDebugging( + `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, to nothing; denying ${holder} whole`, + { level: 'warn' }, + ) + return { kind: 'skip' } } - // Nothing is there. A command can make such a link here, so it costs what - // any link here does and not every command after it. - walk.unreadableDirs.add(realPathOrSelf(holder)) - logForDebugging( - `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, to nothing; denying ${holder} whole`, - { level: 'warn' }, - ) + if (behind.isDirectory()) throw err return { kind: 'skip' } } // The first hop is the entry, whose holder joins `linkedEntryDirs` below. The @@ -1323,9 +1325,11 @@ function gitDirTargetDenies( case 'absent': return gitDirDenies(target, allowGitConfig, deadline) case 'unreadable': { - // Where it really is: a value can end in a link, whose name is what holds - // the link and no deny of what it leads to ({@link GitChainHop.link}). - const denied = realPathOrSelf(deepestReachableAncestor(target) ?? target) + // Towards where it really is. A value can go through a link, whose name + // holds the link and is no deny of what it leads to ({@link + // GitChainHop.link}), and what cannot be looked into may lie behind it. + const denied = + deepestReachableAncestor(physicalDenyPath(target, deadline)) ?? target logForDebugging( `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, { level: 'warn' }, diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 96dcc876f..86d897665 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -260,6 +260,21 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }, ) + it.if(process.getuid?.() !== 0)( + 'has what shuts it off denied whole, behind the link and not around it', + () => { + const locked = join(dir, 'locked') + mkdirSync(join(locked, 'deep'), { recursive: true }) + symlinkSync(join(locked, 'deep'), modules) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + it('is walked from where it is', () => { const lib = makeGitDir(join(modules, 'vendor', 'deep', 'lib')) linkFromBothSides('../vendor/deep') @@ -380,11 +395,14 @@ describe('The .git/modules walk beneath what looks like a git directory', () => }, ) - it('takes what lies below a file for nothing', () => { + it.each([ + { what: 'what lies below a file', before: '../file/', after: '' }, + { what: 'a name no file can have', before: '', after: 'x'.repeat(300) }, + ])('takes $what for nothing', ({ before, after }) => { mkdirSync(modules) writeFileSync(join(dir, 'file'), '') symlinkSync( - Buffer.concat([Buffer.from('../file/'), NO_TEXT]), + Buffer.concat([Buffer.from(before), NO_TEXT, Buffer.from(after)]), join(modules, 'x'), ) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 6c4512b61..66654dec2 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -4204,8 +4204,9 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { 'holds the link it read where what lies past it cannot be', () => { // The chain runs into a directory this process cannot search, so the - // git directory is failed closed on the deepest ancestor it can reach - // and the link it DID read is held as any other is. + // git directory is failed closed on the deepest directory it can reach + // TOWARDS it, behind the link and not around it, and the link it DID read + // is held as any other is. const locked = join(dir, 'checkout', 'locked') mkdirSync(join(locked, 'inner'), { recursive: true }) const pointer = writePointer('checkout', 'gitdir: mid/gitdir\n') @@ -4215,7 +4216,7 @@ describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { try { const denies = gitFileDenies(pointer, false) - expect(denies.denyPaths).toEqual([pointer, hop, join(dir, 'checkout')]) + expect(denies.denyPaths).toEqual([pointer, hop, locked]) expect(denies.chainHops).toEqual([ { kind: 'pointer', From 5de9ab59dc4b475debb1802cf72b5b974a9da332 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:13:21 +0000 Subject: [PATCH 141/141] test: do not resolve a directory that cannot be read macOS refuses realpath on a directory without leave to read it, and the test directory is a real path already. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WzCURdLN6GNmf5ogwTo1vZ Co-authored-by: ronleizrowice --- test/sandbox/mandatory-deny-modules-walk.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts index 86d897665..bfb43bc96 100644 --- a/test/sandbox/mandatory-deny-modules-walk.test.ts +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -251,9 +251,7 @@ describe('The .git/modules walk beneath what looks like a git directory', () => symlinkSync(locked, join(modules, 'link')) chmodSync(locked, 0o311) try { - expect(submoduleGitDirs(modules).unreadableDirs).toEqual([ - realpathSync(locked), - ]) + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) } finally { chmodSync(locked, 0o755) }