From 8f4c0662c3a709736c8ca2ede360d41b6e15354c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 2 Sep 2026 09:24:58 +0100 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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 07/21] 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 08/21] 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 09/21] 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 10/21] 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 11/21] 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 12/21] 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 13/21] 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 14/21] 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 15/21] 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 16/21] 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 17/21] 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 18/21] 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 19/21] 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 20/21] 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 21/21] 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 () => {