From 55dc71fe2b9c7ea9b7823bf0c46e36bf0636e49b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 14 Oct 2026 09:07:33 +0000 Subject: [PATCH 1/5] linux: a file above a write deny is kept there Nothing can be created below a file for as long as it is there. So where a file lies above a write-denied path, in a directory the command may write, the file is bound read-only onto itself. It is not tracked for removal: it is the caller's own. A .git that is a file, as in a worktree or a submodule, is denied itself, in the working directory and, as far as the scan goes, below it. --- README.md | 6 +- src/sandbox/linux-sandbox-utils.ts | 70 +++--- test/sandbox/deny-path-made-meanwhile.test.ts | 5 +- test/sandbox/linux-file-above-deny.test.ts | 210 ++++++++++++++++++ 4 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 test/sandbox/linux-file-above-deny.test.ts diff --git a/README.md b/README.md index 12cd9a695..761a6c2e4 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,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, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. @@ -941,7 +941,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 a worktree or a submodule `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. 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: @@ -961,7 +961,7 @@ With `allowWrite: ["/"]` the pins reach every ancestor, including any other allo A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance: a dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. 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 dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). A `.git` that is a file counts as a dangerous file. Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index d8e147142..4b0054993 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -254,13 +254,11 @@ function resolveSymlinkedDenyPath( } /** - * Check if any existing component in the path is a file (not a directory). - * If so, the target path can never be created because you can't mkdir under a file. - * - * This handles the git worktree case: .git is a file, so .git/hooks can never - * exist and there's nothing to deny. + * The first existing component of the path that is not a directory, if there + * is one. Nothing can be created below it for as long as it is there: a git + * worktree's `.git` is a file, so there is no `.git/hooks`. */ -function hasFileAncestor(targetPath: string): boolean { +function fileOnTheWay(targetPath: string): string | undefined { const parts = targetPath.split(path.sep) let currentPath = '' @@ -268,11 +266,7 @@ function hasFileAncestor(targetPath: string): boolean { if (!part) continue // Skip empty parts (leading /) const nextPath = currentPath + path.sep + part try { - const stat = fs.statSync(nextPath) - if (stat.isFile()) { - // This component exists as a file — nothing below it can be created - return true - } + if (!fs.statSync(nextPath).isDirectory()) return nextPath } catch { // Path doesn't exist — stop checking break @@ -280,7 +274,7 @@ function hasFileAncestor(targetPath: string): boolean { currentPath = nextPath } - return false + return undefined } /** @@ -401,7 +395,8 @@ function makeableMountPoint( /** * The part of the mandatory deny set that follows from the cwd alone: the * dangerous files and directories resolved against it, plus `.git/hooks` and - * (unless the caller allows git config) `.git/config`. + * (unless the caller allows git config) `.git/config`, or a `.git` that is a + * file. * {@link linuxGetMandatoryDenyPaths} adds the nested matches its ripgrep scan * finds on top of these. Split out so a consumer that must not scan — the * violation monitor, which needs the same denies to judge a write bwrap @@ -419,19 +414,23 @@ export function linuxGetCwdMandatoryDenyPaths( ...getDangerousDirectories().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. + // Git hooks and config are denied where .git is a directory. + // In a git worktree or a submodule .git is a file ("gitdir: /path/..."), + // and there are no .git/hooks for as long as it is one. So the file is + // denied, which keeps it one. // When .git doesn't exist at all, mounting at .git would block its // creation and break git init. const dotGitPath = path.resolve(cwd, '.git') - let dotGitIsDirectory = false + let dotGitIsDirectory: boolean | undefined try { dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory() } catch { // .git doesn't exist } + if (dotGitIsDirectory === false) { + denyPaths.push(dotGitPath) + } if (dotGitIsDirectory) { // Git hooks always blocked for security denyPaths.push(path.resolve(cwd, '.git/hooks')) @@ -538,6 +537,9 @@ async function linuxGetMandatoryDenyPaths( // Git hooks always blocked in nested repos. A repository is known by its // HEAD, so that its hooks are denied before there are any. iglobArgs.push('--iglob', '**/.git/hooks/**', '--iglob', '**/.git/HEAD') + // The .git of a nested worktree or submodule, which is a file. (ripgrep + // lists files: a directory of that name does not match.) + iglobArgs.push('--iglob', '.git') // Git config conditionally blocked in nested repos if (!allowGitConfig) { @@ -2778,7 +2780,8 @@ async function generateFilesystemArgs( * The placeholder an absent deny path adds to the plan: where, which is * its first missing component, and the kind of mount point bubblewrap * makes for it on the host. `kind` is undefined where none can be made, - * and the whole answer where the path adds nothing. + * 'held' where what is there is bound onto itself, and the whole answer + * undefined where the path adds nothing. * * INVARIANT: a placeholder is left out only where the sandboxed command * cannot create the path either; each branch says why it cannot. On any @@ -2787,7 +2790,9 @@ async function generateFilesystemArgs( */ const mountPointFor = ( absentPath: string, - ): { dest: string; kind: MountPointKind | undefined } | undefined => { + ): + | { dest: string; kind: MountPointKind | 'held' | undefined } + | undefined => { const none = (why: string): undefined => { logForDebugging( `[Sandbox Linux] No placeholder for non-existent deny path ${absentPath}: ${why}`, @@ -2819,12 +2824,19 @@ async function generateFilesystemArgs( ) { return none('it is read-only there') } - // Nothing can be created under a file: a git worktree's .git is one. + // Nothing can be created under a file (a git worktree's .git is one), + // for as long as the file is there. So it is kept there: the command + // may write the directory that holds it. It is not tracked for removal: + // it is the caller's own, or another sandbox's mount point. // Asked of what lies above the path, not of the path: another sandbox's // bubblewrap may have made a mount point at it since it was found // absent, and a file there is no reason to leave it unbound. - if (hasFileAncestor(path.dirname(absentPath))) { - return none('a file lies above it') + const file = fileOnTheWay(path.dirname(absentPath)) + if (file !== undefined) { + if (seenDenyWrite.has(file)) return none(`${file} is denied itself`) + seenDenyWrite.add(file) + decidedMountPoints.add(file) + return { dest: file, kind: 'held' } } // A component with a deny path beneath it is covered by an empty @@ -2939,9 +2951,11 @@ async function generateFilesystemArgs( const { dest, kind } = placeholder denyWriteArgs.push( '--ro-bind', - kind === 'directory' - ? (emptySource ??= ensureEmptyMountSourceDir()) - : '/dev/null', + kind === 'held' + ? dest + : kind === 'directory' + ? (emptySource ??= ensureEmptyMountSourceDir()) + : '/dev/null', dest, ) // The first of the denies that share the destination. The record is @@ -2953,7 +2967,11 @@ async function generateFilesystemArgs( kind === undefined ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` : `[Sandbox Linux] Mounted ${ - kind === 'directory' ? 'empty dir' : '/dev/null' + kind === 'held' + ? 'itself' + : kind === 'directory' + ? 'empty dir' + : '/dev/null' } at ${dest} to block creation of ${normalizedPath}`, ) } diff --git a/test/sandbox/deny-path-made-meanwhile.test.ts b/test/sandbox/deny-path-made-meanwhile.test.ts index 30eb6693a..4936c1358 100644 --- a/test/sandbox/deny-path-made-meanwhile.test.ts +++ b/test/sandbox/deny-path-made-meanwhile.test.ts @@ -76,12 +76,15 @@ describe.if(isLinux)('A deny path made while the wrap is looking', () => { expect(countMounts(wrapped, '--ro-bind', '/dev/null', denied)).toBe(1) }) - it('still leaves alone a path beneath a file, which can never be created', async () => { + it('still lays nothing at a path beneath a file, and keeps the file', async () => { writeFileSync(join(area, '.git'), 'gitdir: elsewhere\n') const denied = join(area, '.git', 'hooks') const wrapped = await wrap(denied) expect(wrapped).not.toContain(denied) + expect( + countMounts(wrapped, '--ro-bind', join(area, '.git'), join(area, '.git')), + ).toBe(1) }) }) diff --git a/test/sandbox/linux-file-above-deny.test.ts b/test/sandbox/linux-file-above-deny.test.ts new file mode 100644 index 000000000..2912d04ec --- /dev/null +++ b/test/sandbox/linux-file-above-deny.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' +import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' + +/** + * Nothing can be created below a file, for as long as the file is there. So a + * file above a write deny is kept there, in a directory the command may write. + */ +describe.if(isLinux)('A file above a write deny', () => { + const POINTER = 'gitdir: elsewhere\n' + let base: string + let area: string + const savedCwd = process.cwd() + + beforeEach(() => { + cleanupBwrapMountPoints({ force: true }) + base = realpathSync(mkdtempSync(join(tmpdir(), 'file-above-deny-'))) + area = join(base, 'area') + mkdirSync(area) + process.chdir(area) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(base, { recursive: true, force: true }) + }) + + const wrap = ( + command = 'true', + denyWithinAllow: string[] = [], + allowOnly = [area], + ): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow }, + }) + const held = (wrapped: string, file: string): number => + countMounts(wrapped, '--ro-bind', file, file) + + describe('in the plan', () => { + it('a .git that is a file is bound onto itself, once, and nothing below it', async () => { + writeFileSync(join(area, '.git'), POINTER) + const wrapped = await wrap() + expect(held(wrapped, join(area, '.git'))).toBe(1) + expect(wrapped).not.toContain(join(area, '.git', 'hooks')) + expect(wrapped).not.toContain(join(area, '.git', 'config')) + }) + + it('so is one below the working directory', async () => { + mkdirSync(join(area, 'sub')) + writeFileSync(join(area, 'sub', '.git'), POINTER) + expect(held(await wrap(), join(area, 'sub', '.git'))).toBe(1) + }) + + it('a .git that is a directory is denied as before', async () => { + mkdirSync(join(area, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(area, '.git', 'config'), '') + const wrapped = await wrap() + expect(held(wrapped, join(area, '.git', 'hooks'))).toBe(1) + expect(held(wrapped, join(area, '.git', 'config'))).toBe(1) + }) + + it('no .git at all: nothing is laid there', async () => { + const wrapped = await wrap() + expect(lastMountAt(wrapped, join(area, '.git'))).toBeUndefined() + expect(wrapped).not.toContain(`${join(area, '.git')}/`) + }) + + // Read without the scan too, which would find the file as well. + it.each([ + ['a file', ['.git']], + ['a directory', ['.git/hooks', '.git/config']], + ['not there', []], + ])('what follows from the cwd alone, .git being %s', (what, denied) => { + if (what === 'a file') writeFileSync(join(area, '.git'), POINTER) + if (what === 'a directory') mkdirSync(join(area, '.git')) + expect( + linuxGetCwdMandatoryDenyPaths().filter(p => + /\/\.git(\/|$)/.test(p.slice(area.length)), + ), + ).toEqual(denied.map(name => join(area, name))) + }) + + it('two denies below one file: one bind', async () => { + writeFileSync(join(area, 'f'), 'mine') + const wrapped = await wrap('true', [ + join(area, 'f', 'x'), + join(area, 'f', 'y', 'z'), + ]) + expect(held(wrapped, join(area, 'f'))).toBe(1) + }) + + it('a file that is denied itself too: one bind', async () => { + writeFileSync(join(area, 'f'), 'mine') + for (const denies of [ + [join(area, 'f'), join(area, 'f', 'x')], + [join(area, 'f', 'x'), join(area, 'f')], + ]) { + expect(held(await wrap('true', denies), join(area, 'f'))).toBe(1) + } + }) + + it('where the command may not write, nothing is laid', async () => { + writeFileSync(join(base, 'f'), 'mine') + expect(await wrap('true', [join(base, 'f', 'x')])).not.toContain( + join(base, 'f'), + ) + }) + }) + + describe.if(bwrapCanNamespace())('in the sandbox', () => { + const run = async ( + command: string, + denies: string[] = [], + ): Promise => { + const r = spawnSync(await wrap(command, denies), { + shell: true, + encoding: 'utf8', + timeout: 20_000, + }) + cleanupBwrapMountPoints() + expect(r.stderr).not.toContain('bwrap:') + return r.stdout.trim() + } + // Each way of putting something else at the name, and what it is after. + const REPLACE = (name: string): string => + `mv ${name} aside 2>/dev/null; echo mv=$?; rm -rf ${name} 2>/dev/null; echo rm=$?; ` + + `mkdir -p ${name}/hooks 2>/dev/null; echo mkdir=$?; echo x > ${name} 2>/dev/null; echo write=$?` + const REFUSED = 'mv=1\nrm=1\nmkdir=1\nwrite=1' + + it.each([ + ['.git', '.'], + ['sub/.git', 'sub'], + ])('%s that is a file stays one', async (name, dir) => { + mkdirSync(join(area, dir), { recursive: true }) + writeFileSync(join(area, name), POINTER) + expect(await run(REPLACE(name))).toBe(REFUSED) + expect(readFileSync(join(area, name), 'utf8')).toBe(POINTER) + }) + + it('the directory that holds it stays too', async () => { + mkdirSync(join(area, 'sub')) + writeFileSync(join(area, 'sub', '.git'), POINTER) + expect(await run('mv sub aside 2>/dev/null; echo mv=$?')).toBe('mv=1') + expect(readFileSync(join(area, 'sub', '.git'), 'utf8')).toBe(POINTER) + }) + + it('a command beside it runs, and writes what it may', async () => { + writeFileSync(join(area, '.git'), POINTER) + expect(await run('cat .git; echo mine > new; cat new')).toBe( + `${POINTER}mine`, + ) + }) + + it('a configured deny below a file: the file stays', async () => { + writeFileSync(join(area, 'f'), 'mine') + expect(await run(REPLACE('f'), [join(area, 'f', 'x')])).toBe(REFUSED) + expect(readFileSync(join(area, 'f'), 'utf8')).toBe('mine') + }) + + it('a protected name that leads through a file: the file stays', async () => { + writeFileSync(join(area, 'f'), 'mine') + symlinkSync('f/x', join(area, '.bashrc')) + expect(await run(REPLACE('f'))).toBe(REFUSED) + expect(readFileSync(join(area, 'f'), 'utf8')).toBe('mine') + }) + + // An empty one looks like a mount point bubblewrap has made. + it.each([ + ['.git', POINTER, []], + ['f', 'mine', ['f/x']], + ['f', '', ['f/x']], + ])( + "%s holding %p is the caller's own afterwards: there, as it was, and writable", + async (name, content, denies) => { + writeFileSync(join(area, name), content, { mode: 0o644 }) + await run( + 'true', + denies.map(d => join(area, d)), + ) + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(join(area, name), 'utf8')).toBe(content) + expect(lstatSync(join(area, name)).mode & 0o777).toBe(0o644) + writeFileSync(join(area, name), 'changed') + }, + ) + }) +}) From c3e4c432df11e2b41cb93f7346553b98f0698dc0 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Wed, 14 Oct 2026 10:28:07 +0000 Subject: [PATCH 2/5] linux: a .git file below the working directory is left to git The commit before this one also denied the .git file of a worktree or a submodule below the working directory. git rewrites and removes that file itself in more commands than it allowed for: a checkout that recurses into a submodule whose commit changes failed and left the tree half switched, and git rm and rm -rf of a submodule stopped half way. It also cost two mounts a file. The working directory's own .git file stays denied. --- README.md | 4 ++-- src/sandbox/linux-sandbox-utils.ts | 6 ++---- test/sandbox/linux-file-above-deny.test.ts | 24 +++++++--------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 761a6c2e4..60bb85487 100644 --- a/README.md +++ b/README.md @@ -941,7 +941,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 a worktree or a submodule `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. +- Git hooks and config: `.git/hooks/`, `.git/config`. Where the working directory is a worktree or a submodule, its `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. 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: @@ -961,7 +961,7 @@ With `allowWrite: ["/"]` the pins reach every ancestor, including any other allo A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance: a dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). A `.git` that is a file counts as a dangerous file. Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. 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 dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 4b0054993..e988ddacb 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -417,7 +417,8 @@ export function linuxGetCwdMandatoryDenyPaths( // Git hooks and config are denied where .git is a directory. // In a git worktree or a submodule .git is a file ("gitdir: /path/..."), // and there are no .git/hooks for as long as it is one. So the file is - // denied, which keeps it one. + // denied, which keeps it one. Only here: one below the cwd is git's to + // rewrite and remove (a checkout that recurses into submodules does). // When .git doesn't exist at all, mounting at .git would block its // creation and break git init. const dotGitPath = path.resolve(cwd, '.git') @@ -537,9 +538,6 @@ async function linuxGetMandatoryDenyPaths( // Git hooks always blocked in nested repos. A repository is known by its // HEAD, so that its hooks are denied before there are any. iglobArgs.push('--iglob', '**/.git/hooks/**', '--iglob', '**/.git/HEAD') - // The .git of a nested worktree or submodule, which is a file. (ripgrep - // lists files: a directory of that name does not match.) - iglobArgs.push('--iglob', '.git') // Git config conditionally blocked in nested repos if (!allowGitConfig) { diff --git a/test/sandbox/linux-file-above-deny.test.ts b/test/sandbox/linux-file-above-deny.test.ts index 2912d04ec..0199ffc31 100644 --- a/test/sandbox/linux-file-above-deny.test.ts +++ b/test/sandbox/linux-file-above-deny.test.ts @@ -68,10 +68,11 @@ describe.if(isLinux)('A file above a write deny', () => { expect(wrapped).not.toContain(join(area, '.git', 'config')) }) - it('so is one below the working directory', async () => { + // git rewrites and removes it: a checkout that recurses into submodules. + it('one below the working directory is left to git', async () => { mkdirSync(join(area, 'sub')) writeFileSync(join(area, 'sub', '.git'), POINTER) - expect(held(await wrap(), join(area, 'sub', '.git'))).toBe(1) + expect(await wrap()).not.toContain(join(area, 'sub')) }) it('a .git that is a directory is denied as before', async () => { @@ -150,21 +151,10 @@ describe.if(isLinux)('A file above a write deny', () => { `mkdir -p ${name}/hooks 2>/dev/null; echo mkdir=$?; echo x > ${name} 2>/dev/null; echo write=$?` const REFUSED = 'mv=1\nrm=1\nmkdir=1\nwrite=1' - it.each([ - ['.git', '.'], - ['sub/.git', 'sub'], - ])('%s that is a file stays one', async (name, dir) => { - mkdirSync(join(area, dir), { recursive: true }) - writeFileSync(join(area, name), POINTER) - expect(await run(REPLACE(name))).toBe(REFUSED) - expect(readFileSync(join(area, name), 'utf8')).toBe(POINTER) - }) - - it('the directory that holds it stays too', async () => { - mkdirSync(join(area, 'sub')) - writeFileSync(join(area, 'sub', '.git'), POINTER) - expect(await run('mv sub aside 2>/dev/null; echo mv=$?')).toBe('mv=1') - expect(readFileSync(join(area, 'sub', '.git'), 'utf8')).toBe(POINTER) + it('a .git that is a file stays one', async () => { + writeFileSync(join(area, '.git'), POINTER) + expect(await run(REPLACE('.git'))).toBe(REFUSED) + expect(readFileSync(join(area, '.git'), 'utf8')).toBe(POINTER) }) it('a command beside it runs, and writes what it may', async () => { From 58b4e8e7bcb2e3d0287bdf37f7c9f8bfd0288070 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 15 Oct 2026 15:26:33 +0000 Subject: [PATCH 3/5] linux: the three states of .git are named A boolean that was true, false or undefined stood for a directory, a file and nothing there. No change in behaviour. --- src/sandbox/linux-sandbox-utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e988ddacb..e89e35aa8 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -422,17 +422,17 @@ export function linuxGetCwdMandatoryDenyPaths( // When .git doesn't exist at all, mounting at .git would block its // creation and break git init. const dotGitPath = path.resolve(cwd, '.git') - let dotGitIsDirectory: boolean | undefined + let dotGit: 'directory' | 'file' | 'absent' = 'absent' try { - dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory() + dotGit = fs.statSync(dotGitPath).isDirectory() ? 'directory' : 'file' } catch { // .git doesn't exist } - if (dotGitIsDirectory === false) { + if (dotGit === 'file') { denyPaths.push(dotGitPath) } - if (dotGitIsDirectory) { + if (dotGit === 'directory') { // Git hooks always blocked for security denyPaths.push(path.resolve(cwd, '.git/hooks')) From 197a9dd3e6bdde3172c1d21fbf1ee07b94efb23c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Thu, 15 Oct 2026 15:28:28 +0000 Subject: [PATCH 4/5] linux: what .git is, is an enum DotGitKind takes the place of three strings, and says at each member what is denied there. No change in behaviour. --- src/sandbox/linux-sandbox-utils.ts | 44 ++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e89e35aa8..56355b964 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -392,6 +392,30 @@ function makeableMountPoint( } } +/** What the `.git` of a directory is, and so what is denied there. */ +enum DotGitKind { + /** A repository: its hooks and config are denied. */ + Directory, + /** + * The pointer of a worktree or a submodule ("gitdir: /path/..."). There are + * no .git/hooks for as long as it is a file, so the file is denied, which + * keeps it one. + */ + File, + /** Nothing is denied: a mount at .git would keep `git init` from making it. */ + Absent, +} + +function dotGitKindAt(dotGitPath: string): DotGitKind { + try { + return fs.statSync(dotGitPath).isDirectory() + ? DotGitKind.Directory + : DotGitKind.File + } catch { + return DotGitKind.Absent + } +} + /** * The part of the mandatory deny set that follows from the cwd alone: the * dangerous files and directories resolved against it, plus `.git/hooks` and @@ -414,25 +438,15 @@ export function linuxGetCwdMandatoryDenyPaths( ...getDangerousDirectories().map(d => path.resolve(cwd, d)), ] - // Git hooks and config are denied where .git is a directory. - // In a git worktree or a submodule .git is a file ("gitdir: /path/..."), - // and there are no .git/hooks for as long as it is one. So the file is - // denied, which keeps it one. Only here: one below the cwd is git's to - // rewrite and remove (a checkout that recurses into submodules does). - // When .git doesn't exist at all, mounting at .git would block its - // creation and break git init. + // Only the cwd's own: a .git file below it is git's to rewrite and remove + // (a checkout that recurses into submodules does). const dotGitPath = path.resolve(cwd, '.git') - let dotGit: 'directory' | 'file' | 'absent' = 'absent' - try { - dotGit = fs.statSync(dotGitPath).isDirectory() ? 'directory' : 'file' - } catch { - // .git doesn't exist - } + const dotGit = dotGitKindAt(dotGitPath) - if (dotGit === 'file') { + if (dotGit === DotGitKind.File) { denyPaths.push(dotGitPath) } - if (dotGit === 'directory') { + if (dotGit === DotGitKind.Directory) { // Git hooks always blocked for security denyPaths.push(path.resolve(cwd, '.git/hooks')) From ce7b0dc05861e384196a951d89d9b5d5fe3ca0ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:23:24 +0000 Subject: [PATCH 5/5] linux: the kind of a mount point is an enum, and a file that alone may be written is not held The kinds of mount point for an absent deny path were strings, with undefined for "none can be made". They are one enum now, read by one exhaustive switch. The debug line names what is mounted. A file above an absent write deny was bound read-only onto itself even where the command may write that file and not its directory. There nothing can take the file's place, so it is left writable, as it was before this branch. --- src/sandbox/linux-sandbox-utils.ts | 114 ++++++++++++--------- test/sandbox/linux-file-above-deny.test.ts | 24 ++++- 2 files changed, 89 insertions(+), 49 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 56355b964..bc47779da 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -301,8 +301,19 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } -/** What bubblewrap makes on the host to mount a placeholder on. */ -type MountPointKind = 'file' | 'directory' +/** What the placeholder for an absent deny path is mounted on. */ +enum MountPointKind { + /** A file made on the host for it, which /dev/null goes on. */ + File = 'file', + /** A directory made on the host for it, which an empty one goes on. */ + Directory = 'directory', + /** The file above the path, which goes on itself. Nothing is made. */ + Held = 'held', + /** None can be made. Nothing is mounted; what lies above is still pinned. */ + Unmakeable = 'unmakeable', +} +/** The kinds that are made on the host. */ +type MadeMountPointKind = MountPointKind.File | MountPointKind.Directory /** open(2)'s O_PATH on x86-64 and arm64; node:fs does not name it. Where the * bit means something else the open is an ordinary one or fails, and a failure @@ -314,16 +325,17 @@ const V9FS_MAGIC = 0x01021997 /** * The kind of mount point that can be made at `dest`, an absent path whose * parent is there: `wanted`, a directory where only a file is refused, or - * none. bubblewrap makes it on the host as this user, before the command runs, - * and exits if it cannot. + * unmakeable. bubblewrap makes it on the host as this user, before the command + * runs, and exits if it cannot. * - * None only where the refusal is not this user's to lift, so that the command - * cannot create `dest` either; `wanted` on any doubt (see mountPointFor). + * Unmakeable only where the refusal is not this user's to lift, so that the + * command cannot create `dest` either; `wanted` on any doubt (see + * mountPointFor). */ function makeableMountPoint( dest: string, - wanted: MountPointKind, -): MountPointKind | undefined { + wanted: MadeMountPointKind, +): MadeMountPointKind | MountPointKind.Unmakeable { /** The errno `attempt` is refused with. Any other failure is a doubt. */ const refusalOf = (attempt: () => void): string | undefined => { try { @@ -360,7 +372,7 @@ function makeableMountPoint( return refusal === undefined || (refusal === 'EACCES' && uid === process.geteuid?.()) ? wanted - : undefined + : MountPointKind.Unmakeable } // On a Windows drive the Windows ACL decides, and nothing on this side @@ -370,21 +382,21 @@ function makeableMountPoint( if (refusal === undefined) { const entry = `${parent}/${path.basename(dest)}` if ( - wanted === 'file' && + wanted === MountPointKind.File && refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === undefined ) { - return 'file' + return MountPointKind.File } if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { - return 'directory' + return MountPointKind.Directory } } // The name may be refused for a reason that passes. The folder is beyond // this user when it may not even be given the mode it has. return refusalOf(() => fs.chmodSync(parent, mode & 0o7777)) === undefined ? wanted - : undefined + : MountPointKind.Unmakeable } catch { return wanted } finally { @@ -395,15 +407,15 @@ function makeableMountPoint( /** What the `.git` of a directory is, and so what is denied there. */ enum DotGitKind { /** A repository: its hooks and config are denied. */ - Directory, + Directory = 'directory', /** * The pointer of a worktree or a submodule ("gitdir: /path/..."). There are * no .git/hooks for as long as it is a file, so the file is denied, which * keeps it one. */ - File, + File = 'file', /** Nothing is denied: a mount at .git would keep `git init` from making it. */ - Absent, + Absent = 'absent', } function dotGitKindAt(dotGitPath: string): DotGitKind { @@ -2788,23 +2800,18 @@ async function generateFilesystemArgs( const decidedMountPoints = new Set() const decidedMountPointOver = (p: string): string | undefined => [p, ...properAncestors(p)].find(at => decidedMountPoints.has(at)) + type Placeholder = { dest: string; kind: MountPointKind } /** * The placeholder an absent deny path adds to the plan: where, which is - * its first missing component, and the kind of mount point bubblewrap - * makes for it on the host. `kind` is undefined where none can be made, - * 'held' where what is there is bound onto itself, and the whole answer - * undefined where the path adds nothing. + * its first missing component or the file above it, and on what kind of + * mount point. Undefined where the path adds nothing. * * INVARIANT: a placeholder is left out only where the sandboxed command * cannot create the path either; each branch says why it cannot. On any * doubt the placeholder stays: at worst bubblewrap cannot make its mount * point and nothing starts, which loses no deny. */ - const mountPointFor = ( - absentPath: string, - ): - | { dest: string; kind: MountPointKind | 'held' | undefined } - | undefined => { + const mountPointFor = (absentPath: string): Placeholder | undefined => { const none = (why: string): undefined => { logForDebugging( `[Sandbox Linux] No placeholder for non-existent deny path ${absentPath}: ${why}`, @@ -2837,18 +2844,26 @@ async function generateFilesystemArgs( return none('it is read-only there') } // Nothing can be created under a file (a git worktree's .git is one), - // for as long as the file is there. So it is kept there: the command - // may write the directory that holds it. It is not tracked for removal: - // it is the caller's own, or another sandbox's mount point. + // for as long as the file is there. So it is kept there, where the + // command may write the directory that holds it. Where it may write the + // file alone, that stays where it is and writable. It is not tracked + // for removal: it is the caller's own, or another sandbox's mount point. // Asked of what lies above the path, not of the path: another sandbox's // bubblewrap may have made a mount point at it since it was found // absent, and a file there is no reason to leave it unbound. const file = fileOnTheWay(path.dirname(absentPath)) if (file !== undefined) { if (seenDenyWrite.has(file)) return none(`${file} is denied itself`) + if ( + !uncoveredWritePaths.some(allowedPath => + isAtOrUnder(path.dirname(file), allowedPath), + ) + ) { + return none(`${file} is a file, in a directory that is read-only`) + } seenDenyWrite.add(file) decidedMountPoints.add(file) - return { dest: file, kind: 'held' } + return { dest: file, kind: MountPointKind.Held } } // A component with a deny path beneath it is covered by an empty @@ -2859,9 +2874,11 @@ async function generateFilesystemArgs( decidedMountPoints.add(dest) const kind = makeableMountPoint( dest, - denyPathAncestors.has(dest) ? 'directory' : 'file', + denyPathAncestors.has(dest) + ? MountPointKind.Directory + : MountPointKind.File, ) - if (kind === undefined) { + if (kind === MountPointKind.Unmakeable) { // The directories above it are pinned all the same: a command that // may write one of THEM could otherwise move the refusing one aside. unmakeableDenyDests.add(dest) @@ -2875,6 +2892,20 @@ async function generateFilesystemArgs( } return { dest, kind } } + /** What goes on it. The empty directory is made when first asked for. */ + const mountSourceFor = ({ dest, kind }: Placeholder): string => { + switch (kind) { + case MountPointKind.Held: + return dest + case MountPointKind.Directory: + return (emptySource ??= ensureEmptyMountSourceDir()) + case MountPointKind.File: + return '/dev/null' + case MountPointKind.Unmakeable: + // Never mounted: unmakeableDenyDests keeps it out of what is emitted. + return '/dev/null' + } + } for (const [index, pathPattern] of denyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length @@ -2961,30 +2992,17 @@ async function generateFilesystemArgs( const placeholder = mountPointFor(normalizedPath) if (placeholder !== undefined) { const { dest, kind } = placeholder - denyWriteArgs.push( - '--ro-bind', - kind === 'held' - ? dest - : kind === 'directory' - ? (emptySource ??= ensureEmptyMountSourceDir()) - : '/dev/null', - dest, - ) + const source = mountSourceFor(placeholder) + denyWriteArgs.push('--ro-bind', source, dest) // The first of the denies that share the destination. The record is // purely additive: it only gives the tmpfs and mask comparisons // below a second spelling to test, and `dest` itself is always // tested. denyWriteRawDests.set(dest, rawPath) logForDebugging( - kind === undefined + kind === MountPointKind.Unmakeable ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` - : `[Sandbox Linux] Mounted ${ - kind === 'held' - ? 'itself' - : kind === 'directory' - ? 'empty dir' - : '/dev/null' - } at ${dest} to block creation of ${normalizedPath}`, + : `[Sandbox Linux] Mounted ${source} at ${dest} to block creation of ${normalizedPath}`, ) } continue diff --git a/test/sandbox/linux-file-above-deny.test.ts b/test/sandbox/linux-file-above-deny.test.ts index 0199ffc31..2589674ed 100644 --- a/test/sandbox/linux-file-above-deny.test.ts +++ b/test/sandbox/linux-file-above-deny.test.ts @@ -129,14 +129,23 @@ describe.if(isLinux)('A file above a write deny', () => { join(base, 'f'), ) }) + + it('where it may write the file and not its directory, the file is not held', async () => { + const f = join(base, 'f') + writeFileSync(f, 'mine') + const wrapped = await wrap('true', [join(f, 'x')], [f]) + expect(held(wrapped, f)).toBe(0) + expect(lastMountAt(wrapped, f)).toBe(`--bind ${f} ${f}`) + }) }) describe.if(bwrapCanNamespace())('in the sandbox', () => { const run = async ( command: string, denies: string[] = [], + allowOnly?: string[], ): Promise => { - const r = spawnSync(await wrap(command, denies), { + const r = spawnSync(await wrap(command, denies, allowOnly), { shell: true, encoding: 'utf8', timeout: 20_000, @@ -170,6 +179,19 @@ describe.if(isLinux)('A file above a write deny', () => { expect(readFileSync(join(area, 'f'), 'utf8')).toBe('mine') }) + it('a file that alone may be written is written, and stays one', async () => { + const f = join(base, 'f') + writeFileSync(f, 'mine') + expect( + await run( + `echo more >> ${f}; echo append=$?; cd ${base}; ${REPLACE('f')}`, + [join(f, 'x')], + [f], + ), + ).toBe('append=0\nmv=1\nrm=1\nmkdir=1\nwrite=0') + expect(readFileSync(f, 'utf8')).toBe('x\n') + }) + it('a protected name that leads through a file: the file stays', async () => { writeFileSync(join(area, 'f'), 'mine') symlinkSync('f/x', join(area, '.bashrc'))