From 1c1bc7f5815ed4448d4f4580004773f7165cf873 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 17:30:43 +0000 Subject: [PATCH 1/2] linux: bind a write deny that holds an allowed write path denyWrite takes precedence over allowWrite however the two are nested. A deny is a read-only bind emitted after every allow bind: for a deny path inside an allowed write path, equal to one, or holding one. The deny loop's gate and its pre-pass share the predicate. An absent deny path that only write paths beneath a deny directory hold gets no mount point: nothing can create it there. --- src/sandbox/linux-sandbox-utils.ts | 66 ++- test/sandbox/default-write-paths.test.ts | 18 + test/sandbox/readonly-deny-dir-binds.test.ts | 580 ++++++++++++++++++- 3 files changed, 642 insertions(+), 22 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 3acfacbc5..cb2bcbbb5 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1967,15 +1967,10 @@ async function generateFilesystemArgs( // names the link, and re-allows nothing the link points at. const nameLocationOf = (p: string): string => nameLocation(p, canonicalForm) // Whether a canonical path lies inside the write allowlist, and so - // whether it is denied, stubbed and pinned at all: a path outside it is - // left read-only by the initial --ro-bind / /. The deny pre-pass, the deny - // loop's --ro-bind gate and the ancestor-pin walk MUST share it: the - // pre-pass is only sound if it records exactly the directories the loop - // re-binds read-only (a recorded directory that is never re-bound read-only - // would suppress stubs unsafely; a re-bound directory missing from the - // record only costs an abort). allowedWritePaths entries are canonical (the - // allow loop drops a symlink-spelled one) and recorded with trailing - // slashes stripped. + // whether it is stubbed and pinned at all: a path outside it is left + // read-only by the initial --ro-bind / /. allowedWritePaths entries are + // canonical (the allow loop drops a symlink-spelled one) and recorded with + // trailing slashes stripped. // // Containment is root-aware (isAtOrUnder) because '/' is a legal allowOnly // entry that the allow loop binds writable: an `allowedPath + '/'` prefix @@ -1987,6 +1982,18 @@ async function generateFilesystemArgs( allowedWritePaths.some(allowedPath => isAtOrUnder(candidatePath, allowedPath), ) + // Whether an existing deny path gets its own --ro-bind: it lies inside the + // write allowlist, or an allowed write path lies beneath it, whose --bind + // the deny's then covers. The deny pre-pass and the deny loop's --ro-bind + // gate MUST share it: the pre-pass is only sound if it records exactly the + // directories the loop re-binds read-only (a recorded directory that is + // never re-bound read-only would suppress stubs unsafely; a re-bound + // directory missing from the record only costs an abort). + const meetsAnyAllowedWritePath = (candidatePath: string): boolean => + isWithinAnyAllowedWritePath(candidatePath) || + allowedWritePaths.some(allowedPath => + isStrictlyUnder(allowedPath, candidatePath), + ) const isAllowedWriteRoot = (candidatePath: string): boolean => allowedWritePaths.includes(candidatePath) // Resolves symlinks, so computed on first use, which is after the @@ -2385,7 +2392,7 @@ async function generateFilesystemArgs( // re-binds read-only, BEFORE any stub decision, so the read-only // conclusion does not depend on where an enclosing directory appears in // the caller's denyWrite ordering. It applies the loop's own resolution, - // the same symlink re-check, and the same isWithinAnyAllowedWritePath + // the same symlink re-check, and the same meetsAnyAllowedWritePath // gate as the --ro-bind emission, and it records every raw spelling each // directory is reached through. A recorded directory is EVIDENCE for // skipping a stub only if it also passes the guard's vetoes below (no @@ -2426,7 +2433,7 @@ async function generateFilesystemArgs( if (!isDirectory) { continue } - if (isWithinAnyAllowedWritePath(resolvedPath)) { + if (meetsAnyAllowedWritePath(resolvedPath)) { // Keep every spelling this dest is reached through (the // re-application passes record the first-seen raw spelling beside // the dest, which this pass cannot assume): the guard below treats @@ -2501,6 +2508,19 @@ async function generateFilesystemArgs( // Materialized once: the pre-pass above fully populates the map and the // deny loop never mutates it. const readOnlyDenyDirs = [...readOnlyDenyDirSpellings.keys()] + // The allowed write paths with no recorded directory above them. + // INVARIANT: one strictly beneath a recorded directory is never writable, + // whatever the vetoes say of that directory's own bind. Emitted, that bind + // (or the one above that it is skipped for) covers the path; dropped as + // hidden by a read-deny tmpfs, that tmpfs has wiped the path's allow bind, + // and the emission filter puts a read-only bind on every write path a unit + // restored beneath the dropped dest. No writable mount follows either. + const uncoveredWritePaths = allowedWritePaths.filter( + allowedPath => + !readOnlyDenyDirs.some(denyDir => + isStrictlyUnder(allowedPath, denyDir), + ), + ) // Is `candidate` already unwritable in the sandbox: strictly under a // recorded read-only deny directory that survives every // coveringDirIsUnsafe veto? Strictly, because a deny equal to a recorded @@ -2661,9 +2681,9 @@ async function generateFilesystemArgs( // Only protect if the existing ancestor is within an allowed write path. // If not, the path is already read-only from --ro-bind / /. - // Same predicate as the pre-pass and the --ro-bind gate (equality on - // the absent normalizedPath itself is unreachable — allow entries - // exist, the deny path does not). + // (Equality on the absent normalizedPath itself is unreachable, and + // so is an allow entry beneath it — allow entries exist, the deny + // path does not.) const ancestorIsWithinAllowedPath = isWithinAnyAllowedWritePath(ancestorPath) || isWithinAnyAllowedWritePath(normalizedPath) @@ -2680,9 +2700,13 @@ async function generateFilesystemArgs( // coveringDirIsUnsafe vetoes (see the INVARIANT at its definition). // (Tested on the absent path itself: a recorded directory that // covers it is at-or-above its deepest existing ancestor, since - // recorded directories exist.) + // recorded directories exist.) So is a path that only covered write + // paths hold (see uncoveredWritePaths). const ancestorIsWithinReadOnlyDeny = - coveredBySafeReadOnlyDenyDir(normalizedPath) + coveredBySafeReadOnlyDenyDir(normalizedPath) || + !uncoveredWritePaths.some(allowedPath => + isAtOrUnder(normalizedPath, allowedPath), + ) if (ancestorIsWithinAllowedPath && !ancestorIsWithinReadOnlyDeny) { const firstNonExistent = findFirstNonExistentComponent(normalizedPath) @@ -2745,11 +2769,10 @@ async function generateFilesystemArgs( continue } - // Only add deny binding if this path is within an allowed write path - // Otherwise it's already read-only from the initial --ro-bind / / - const isWithinAllowedPath = isWithinAnyAllowedWritePath(normalizedPath) - - if (isWithinAllowedPath) { + // Only add deny binding if this path is within an allowed write path or + // holds one. Otherwise all of it is already read-only from the initial + // --ro-bind / / + if (meetsAnyAllowedWritePath(normalizedPath)) { // Already unwritable under a read-only denied directory (the // existing-path twin of the stub skip above). Veto (ii) keeps the // covering bind through the emission filter; a dest ANY deny entry @@ -3085,6 +3108,7 @@ async function generateFilesystemArgs( // restored writable beneath it: that path is inside this write deny, so // restore it read-only, where the unit left it. Emitting the dest's own // bind instead would expose the read-denied directory around it. + // uncoveredWritePaths counts on this restore. for (const unit of readDenyTmpfsUnits) { for (const writeMount of unit.restoredWrites) { const writePath = writeMount.dest diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index c6fd91384..98e09a340 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -218,6 +218,24 @@ describe.if(isLinux || isMacOS)( // A per-call allowRead of the directory keeps its write allow. expect(grantsWrite(reopened, join(fakeHome, NPM_LOGS))).toBe(true) }) + + it.if(isLinux)('is covered by a denyWrite entry above it', () => { + mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) + const wrapped = runWithHome( + fakeHome, + `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + console.log(JSON.stringify(await SandboxManager.wrapWithSandbox('true', undefined, { + filesystem: { denyRead: [], allowWrite: [], denyWrite: ['~/.npm'] }, + })))`, + ) as string + + const npm = join(fakeHome, '.npm') + expect(grantsWrite(wrapped, join(fakeHome, NPM_LOGS))).toBe(true) + expect(wrapped.indexOf(`--ro-bind ${npm} ${npm}`)).toBeGreaterThan( + wrapped.indexOf(`--bind ${npm}/_logs ${npm}/_logs`), + ) + expect(wrapped).not.toContain(`--ro-bind ${fakeHome}/.claude`) + }) }, ) diff --git a/test/sandbox/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index 227b013dd..87cb7eb08 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, symlinkSync, @@ -16,8 +17,10 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { isAtOrUnder } from '../../src/sandbox/sandbox-utils.js' import { isLinux } from '../helpers/platform.js' -import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' +import { countMounts, lastMountAt, mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' @@ -989,6 +992,581 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { expect(command).toContain(`--ro-bind ${FILE} ${FILE}`) }) + // denyWithinAllow wins over allowOnly however the two are nested. A deny's + // read-only bind lands after every allow bind and is recursive, so it holds + // the allowed write paths beneath it too. + describe('a write deny and an allowed write path, by where each lies', () => { + let SUB: string // PROJ/sub, the allowed write path most rows name + + beforeEach(() => { + SUB = join(PROJ, 'sub') + mkdirSync(join(AREA, 'other')) + mkdirSync(join(AREA, 'proj2')) + symlinkSync(AREA, join(BASE, 'to-area')) + symlinkSync(PROJ, join(BASE, 'to-proj')) + symlinkSync(SUB, join(BASE, 'to-sub')) + }) + + const under = (names: string[]) => names.map(name => join(BASE, name)) + + /** The mount that decides `p` in the sandbox: the last at or above it. */ + const decidingMount = (command: string, p: string) => + mountsOf(command) + .filter(mount => isAtOrUnder(p, mount.split(' ').at(-1) ?? '')) + .at(-1) + + // Where the deny lies, denyWithinAllow, allowOnly, and the directories + // that end up read-only and writable; every path is relative to BASE. + const [A, P, S, O] = ['area', 'area/proj', 'area/proj/sub', 'area/other'] + const PLACEMENTS: [string, string[], string[], string[], string[]][] = [ + ['inside it', [P], [A], [S], [O]], + ['equal to it', [P], [P], [S], []], + ['above it', [P], [S], [S], []], + ['two levels above it', [A], [S], [S], []], + ['between two of them', [P], [A, S], [S], [O]], + ['above one and beside another', [P], [O, S], [S], [O]], + ['above two of them', [A], [O, S], [O, S], []], + ['beside it', [O], [S], [], [S]], + ['beside it, its name a prefix', [P], [P + '2'], [], [P + '2']], + ['above it, through a symlink', ['to-proj'], [S], [S], []], + ['above it, through a symlinked parent', ['to-area/proj'], [S], [S], []], + ['above what it is a symlink to', [P], ['to-sub'], [S], []], + ] + + it.each(PLACEMENTS)( + 'plans the mounts for a deny %s', + async (_where, deny, allow, readOnly, writable) => { + const command = await wrap(under(deny), [], under(allow)) + + for (const dir of under(readOnly)) { + expect(decidingMount(command, dir)).toStartWith('--ro-bind ') + } + for (const dir of under(writable)) { + expect(decidingMount(command, dir)).toStartWith('--bind ') + } + }, + ) + + it.skipIf(!BWRAP_CAN_NAMESPACE).each(PLACEMENTS)( + 'refuses and takes the writes for a deny %s', + async (_where, deny, allow, readOnly, writable) => { + const files = under([...readOnly, ...writable]).map(d => join(d, 'new')) + + await runSandboxed( + under(deny), + [], + under(allow), + files.map(file => `echo x > ${file}`).join('; '), + ) + + expect(files.filter(file => existsSync(file))).toEqual( + under(writable).map(d => join(d, 'new')), + ) + }, + ) + + it('mounts nothing for a deny that meets no allowed write path', async () => { + const command = await wrap([join(AREA, 'other'), PROJ], [], [PROJ + '2']) + + expect(mountsOf(command).filter(mount => mount.includes(AREA))).toEqual([ + `--bind ${PROJ}2 ${PROJ}2`, + ]) + }) + + it('names the deny and the allowed write path beneath it', async () => { + const { warnings } = await withCapturedWarnings(() => + wrap([PROJ], [], [SUB]), + ) + + expect(warnings.join('\n')).toContain( + `Write deny ${PROJ} covers allowed write path ${SUB}`, + ) + }) + + it('binds "/" again over the allowed write paths when "/" is denied', async () => { + const command = await wrap(['/'], [], [SUB]) + + // Two: the base root mount, then the deny's. + expect(countMounts(command, '--ro-bind', '/', '/')).toBe(2) + expect(decidingMount(command, SUB)).toBe('--ro-bind / /') + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'starts with "/" denied, and nothing is writable', + async () => { + await runSandboxed(['/'], [], [SUB], `echo x > ${join(SUB, 'new')}`) + + expect(existsSync(join(SUB, 'new'))).toBe(false) + }, + ) + + // An absent deny path beneath the covered write path is uncreatable, and + // bubblewrap could not make a mount point for it on a read-only mount. + describe('with absent deny paths in the allowed write path', () => { + const orders = (): string[][] => { + const denies = [PROJ, join(SUB, '.secret'), join(SUB, 'a', 'b')] + return [denies, [...denies].reverse()] + } + + it('gives them no mount point, in either order', async () => { + for (const denies of orders()) { + expect( + mountsOf(await wrap(denies, [], [SUB])).filter(mount => + mount.includes(BASE), + ), + ).toEqual([`--bind ${SUB} ${SUB}`, `--ro-bind ${PROJ} ${PROJ}`]) + } + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'starts and keeps them uncreatable, in either order', + async () => { + for (const denies of orders()) { + await runSandboxed( + denies, + [], + [SUB], + `echo x > ${join(SUB, '.secret')}; mkdir -p ${join(SUB, 'a', 'b')}`, + ) + + expect(existsSync(join(SUB, '.secret'))).toBe(false) + expect(existsSync(join(SUB, 'a'))).toBe(false) + } + }, + ) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'starts in an allowed working directory beneath the deny', + async () => { + // The mandatory denies of the working directory are such paths. + process.chdir(SUB) + + const command = await wrap([PROJ], [], [SUB], 'echo BOOTED') + + expect(command).not.toContain(`/dev/null ${SUB}/`) + expect(run(command).stdout).toContain('BOOTED') + }, + ) + }) + + // A placeholder is bubblewrap's mount point for an absent deny path, made + // on the host. A path that only covered write paths hold gets none, read + // denies or not: nothing can create it, and nothing is left on the host. + describe('with the working directory in the allowed write path', () => { + let ABSENT: string // a configured deny path, absent like the dotfiles + + beforeEach(() => { + ABSENT = join(SUB, '.secret') + process.chdir(SUB) + }) + + const placeholdersIn = (command: string, dir: string) => + mountsOf(command) + .filter(m => /^--ro-bind (\/dev\/null|\S*claude-empty-\S+) /.test(m)) + .map(mount => mount.split(' ').at(-1) ?? '') + .filter(dest => isAtOrUnder(dest, dir)) + + const wrapHere = ( + denyWithinAllow: string[], + denyOnly: string[], + allowOnly: string[], + command = 'echo hello', + ) => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly, allowWithinDeny: [SUB] }, + writeConfig: { allowOnly, denyWithinAllow }, + }) + + const attempt = () => + `sh -c 'echo BOOTED; cat ${FILE}; for f in new .secret .bashrc; do echo x > ${SUB}/$f; done'` + + // The read deny, relative to BASE, and how the write path is spelled. + const READ_DENIES: [string, string[], string][] = [ + ['no read deny', [], S], + ['a read deny at the write deny', [A], S], + ['a read deny at the write deny, the write path "."', [A], '.'], + ['a read deny above the write deny', ['.'], S], + ['a read deny between the two', [P], S], + ] + + it.each(READ_DENIES)( + 'plans no placeholder with %s', + async (_name, readDenied, root) => { + const command = await wrapHere( + [AREA, ABSENT], + under(readDenied), + root === '.' ? ['.'] : under([root]), + ) + + expect(placeholdersIn(command, BASE)).toEqual([]) + expect(decidingMount(command, ABSENT)).toStartWith('--ro-bind ') + }, + ) + + it.skipIf(!BWRAP_CAN_NAMESPACE).each(READ_DENIES)( + 'runs read-only and leaves the host as it is with %s', + async (_name, readDenied, root) => { + const result = run( + await wrapHere( + [AREA, ABSENT], + under(readDenied), + root === '.' ? ['.'] : under([root]), + attempt(), + ), + ) + + expect(result.stderr).not.toContain('bwrap:') + expect(result.stdout).toBe('BOOTED\n{}\n') + expect(readdirSync(SUB)).toEqual(['settings.json']) + }, + ) + + it('plans them with a read deny and no write deny above', async () => { + const planned = placeholdersIn( + await wrapHere([ABSENT], [AREA], [SUB]), + BASE, + ) + + expect(planned).toContain(ABSENT) + expect(planned).toContain(join(SUB, '.bashrc')) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'writes beside them with a read deny and no write deny above, and the clean-up removes them', + async () => { + const result = run(await wrapHere([ABSENT], [AREA], [SUB], attempt())) + + expect(result.stdout).toBe('BOOTED\n{}\n') + expect(readFileSync(ABSENT, 'utf8')).toBe('') + cleanupBwrapMountPoints() + expect(readdirSync(SUB).sort()).toEqual(['new', 'settings.json']) + }, + ) + }) + + // The other side of that rule: a path that any write path with no deny + // directory above it holds can be created, and keeps its placeholder. + describe.each([ + ['no read deny', []], + ['a read deny above them all', [A]], + ])( + 'with an absent deny path, by what holds it, and %s', + (_, readDenied) => { + beforeEach(() => { + mkdirSync(join(SUB, 'deeper')) + mkdirSync(join(AREA, 'proj2', 'in')) + }) + + // denyWithinAllow's directories, allowOnly, the directory of the absent + // deny path, and whether that directory ends up writable. + const D = S + '/deeper' + const HOLDERS: [string, string[], string[], string, boolean][] = [ + ['a covered write path', [P], [S], S, false], + ['two covered write paths', [P], [S, D], D, false], + ['one covered twice', [A, P], [S], S, false], + [ + 'one covered, a prefix beside it', + [P + '2'], + [P, P + '2/in'], + P + '2/in', + false, + ], + ['one beside the deny', [P], [S, O], O, true], + [ + 'one beside it, its name a prefix', + [P], + [S, P + '2'], + P + '2', + true, + ], + ['one above the deny, beside it', [S], [P], P, true], + ] + + it.each(HOLDERS)( + 'plans for one that %s holds', + async (_name, deny, allow, dir, writable) => { + const absent = join(BASE, dir, '.secret') + const command = await wrap( + [...under(deny), absent], + under(readDenied), + under(allow), + ) + + expect(countMounts(command, '--ro-bind', '/dev/null', absent)).toBe( + writable ? 1 : 0, + ) + expect(decidingMount(command, absent)).toStartWith('--ro-bind ') + }, + ) + + it.skipIf(!BWRAP_CAN_NAMESPACE).each(HOLDERS)( + 'keeps one that %s holds uncreatable', + async (_name, deny, allow, dir, writable) => { + const absent = join(BASE, dir, '.secret') + + await runSandboxed( + [...under(deny), absent], + under(readDenied), + under(allow), + `echo x > ${absent}; echo x > ${join(BASE, dir, 'new')}`, + ) + cleanupBwrapMountPoints() + + expect(existsSync(absent)).toBe(false) + expect(existsSync(join(BASE, dir, 'new'))).toBe(writable) + }, + ) + }, + ) + + // Each wrap plans from its own configuration and the host as it finds it. + describe('with the deny above there for one command and not the next', () => { + it.skipIf(!BWRAP_CAN_NAMESPACE).each([ + ['first', [true, false]], + ['second', [false, true]], + ])('plans each command on its own, there %s', async (_name, commands) => { + const absent = join(SUB, '.secret') + for (const covered of commands) { + rmSync(join(SUB, 'new'), { force: true }) + + const command = await SandboxManager.wrapWithSandbox( + `echo BOOTED; echo x > ${absent}; echo x > ${join(SUB, 'new')}`, + undefined, + { + filesystem: { + denyRead: [], + allowWrite: [SUB], + denyWrite: covered ? [PROJ, absent] : [absent], + }, + }, + ) + expect(countMounts(command, '--ro-bind', '/dev/null', absent)).toBe( + covered ? 0 : 1, + ) + expect(run(command).stdout).toContain('BOOTED') + + // A mount point stays on the host until the clean-up, empty. + expect(readdirSync(SUB).sort()).toEqual( + covered ? ['settings.json'] : ['.secret', 'new', 'settings.json'], + ) + if (!covered) expect(readFileSync(absent, 'utf8')).toBe('') + SandboxManager.cleanupAfterCommand() + expect(existsSync(absent)).toBe(false) + } + }) + }) + + // The mandatory denies follow the same rule as the configured ones. + describe('with the working directory outside every allowed write path', () => { + it.each([ + '.git/hooks', + '.vscode', + '.idea', + '.claude/commands', + '.claude/agents', + ])('holds an allowed write path beneath %s read-only', async name => { + const inner = join(BASE, name, 'inner') + mkdirSync(inner, { recursive: true }) + + const command = await wrap([], [], [inner]) + + expect(decidingMount(command, inner)).toBe( + `--ro-bind ${join(BASE, name)} ${join(BASE, name)}`, + ) + }) + + it('leaves an allowed write path beside them writable', async () => { + const objects = join(BASE, '.git', 'objects') + mkdirSync(objects, { recursive: true }) + mkdirSync(join(BASE, '.git', 'hooks')) + + const command = await wrap([], [], [objects]) + + expect(decidingMount(command, objects)).toBe( + `--bind ${objects} ${objects}`, + ) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE)( + 'refuses a write beneath .git/hooks and takes one beside it', + async () => { + const inner = join(BASE, '.git', 'hooks', 'inner') + const objects = join(BASE, '.git', 'objects') + mkdirSync(inner, { recursive: true }) + mkdirSync(objects) + + await runSandboxed( + [], + [], + [inner, objects], + `echo x > ${join(inner, 'new')}; echo x > ${join(objects, 'new')}`, + ) + + expect(existsSync(join(inner, 'new'))).toBe(false) + expect(existsSync(join(objects, 'new'))).toBe(true) + }, + ) + }) + + // The deny's bind shows the host's directory again, everything the read + // section hid beneath it included, so all of that goes back on top of it: + // each tmpfs with what it restores, then the file masks. + describe('with read denies beneath the deny', () => { + const HIDDEN = 'HIDDEN' + let SECRET: string // read-denied directory beside SUB + let PUB: string // allowRead directory inside SECRET + let VAULT: string // credential-denied directory beside SUB + let TOKEN: string // read-denied file beside SUB + let CRED: string // credential-denied file beside SUB + let NETRC: string // masked credential file beside SUB + let PRIVATE: string // read-denied directory inside SUB + let ENV: string // read-denied file inside SUB + let STORE: string + let FAKE: string + + beforeEach(() => { + SECRET = join(PROJ, 'secret') + PUB = join(SECRET, 'pub') + VAULT = join(PROJ, 'vault') + PRIVATE = join(SUB, 'private') + STORE = join(BASE, 'store') + for (const dir of [PUB, VAULT, PRIVATE, STORE]) { + mkdirSync(dir, { recursive: true }) + } + TOKEN = join(PROJ, 'token') + CRED = join(PROJ, 'cred') + NETRC = join(PROJ, 'netrc') + ENV = join(SUB, '.env') + for (const file of [ + TOKEN, + CRED, + NETRC, + ENV, + join(SECRET, 'key'), + join(VAULT, 'key'), + join(PRIVATE, 'key'), + ]) { + writeFileSync(file, HIDDEN + '\n') + } + writeFileSync(join(PUB, 'note'), 'PUBLIC\n') + FAKE = join(STORE, 'netrc.fake') + writeFileSync(FAKE, 'SENTINEL\n') + }) + + const wrapAll = (denyWithinAllow: string[], command = 'echo hello') => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [SECRET, VAULT, TOKEN, CRED, PRIVATE, ENV], + credentialDenyOnly: [VAULT, CRED], + allowWithinDeny: [PUB], + }, + writeConfig: { allowOnly: [SUB], denyWithinAllow }, + maskedFileBinds: [{ realPath: NETRC, fakePath: FAKE }], + maskedFileStoreDir: STORE, + }) + + const readSection = () => [ + `--tmpfs ${SECRET}`, + `--ro-bind ${PUB} ${PUB}`, + `--tmpfs ${VAULT}`, + `--tmpfs ${PRIVATE}`, + `--ro-bind /dev/null ${TOKEN}`, + `--ro-bind /dev/null ${CRED}`, + `--ro-bind /dev/null ${ENV}`, + `--ro-bind ${FAKE} ${NETRC}`, + ] + + it('puts every one of them back after the bind, the masks last', async () => { + const mounts = mountsOf(await wrapAll([PROJ])) + + expect( + mounts.slice(mounts.indexOf(`--ro-bind ${PROJ} ${PROJ}`)), + ).toEqual([ + `--ro-bind ${PROJ} ${PROJ}`, + ...readSection(), + `--ro-bind ${STORE} ${STORE}`, + '--dev /dev', + '--proc /proc', + ]) + }) + + it('mounts each of them once with no write deny above', async () => { + const mounts = mountsOf(await wrapAll([])) + + expect(mounts.filter(m => m.includes(PROJ)).sort()).toEqual( + [`--bind ${SUB} ${SUB}`, ...readSection()].sort(), + ) + }) + + it.skipIf(!BWRAP_CAN_NAMESPACE).each([ + ['a write deny above', true], + ['no write deny', false], + ])('shows none of them with %s', async (_name, denied) => { + const reads = [ + TOKEN, + CRED, + ENV, + join(SECRET, 'key'), + join(VAULT, 'key'), + join(PRIVATE, 'key'), + ] + const result = run( + await wrapAll( + denied ? [PROJ] : [], + `sh -c 'echo BOOTED; cat ${reads.join(' ')} ${NETRC} ${join(PUB, 'note')} ${FILE}; echo x > ${join(SUB, 'new')}; echo x >> ${join(PUB, 'note')}; echo x >> ${ENV}'`, + ), + ) + + expect(result.stderr).not.toContain('bwrap:') + expect(result.stdout).toBe('BOOTED\nSENTINEL\nPUBLIC\n{}\n') + expect(existsSync(join(SUB, 'new'))).toBe(!denied) + expect(readFileSync(join(PUB, 'note'), 'utf8')).toBe('PUBLIC\n') + expect(readFileSync(ENV, 'utf8')).toBe(HIDDEN + '\n') + }) + + // A read deny at or above the write deny hides it, and binding it would + // show it: the allowed write path alone comes back, read-only. + it.each([ + ['at', () => PROJ], + ['above', () => AREA], + ])( + 'does not bind a deny with a read deny %s it', + async (_where, hidden) => { + const command = await wrap([PROJ], [hidden(), ENV], [SUB]) + + expect(countMounts(command, '--ro-bind', PROJ, PROJ)).toBe(0) + expect(decidingMount(command, join(SUB, 'new'))).toBe( + `--ro-bind ${SUB} ${SUB}`, + ) + expect(decidingMount(command, TOKEN)).toBe(`--tmpfs ${hidden()}`) + expect(decidingMount(command, ENV)).toBe(`--ro-bind /dev/null ${ENV}`) + }, + ) + + it.skipIf(!BWRAP_CAN_NAMESPACE).each([ + ['at', () => PROJ], + ['above', () => AREA], + ])( + 'refuses the write and shows nothing with a read deny %s the deny', + async (_where, hidden) => { + const result = await runSandboxed( + [PROJ], + [hidden(), ENV], + [SUB], + `cat ${TOKEN} ${ENV} ${FILE}; echo x > ${join(SUB, 'new')}`, + ) + + expect(result.stdout).toBe('BOOTED\n{}\n') + expect(existsSync(join(SUB, 'new'))).toBe(false) + }, + ) + }) + }) + /** * '/' is a legal allowOnly entry — normalization keeps it — and the allow * loop binds it writable. Every deny then depends on the one predicate From 26e8f9b338a9e6725649da7c24c31e2df17ced4d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 18:18:20 +0000 Subject: [PATCH 2/2] linux: no placeholder where no mount point can be made For an absent denyWrite path inside an allowed write path bubblewrap makes a mount point on the host, as this user, before the command runs. Where it cannot, no sandboxed command starts: a Windows drive's protected folder under WSL 2, or any absent deny path under /usr. mountPointFor decides what every absent deny path gets. A placeholder is left out only where the sandboxed command cannot create the path either: the parent refuses and the refusal is not this user's to lift. On any doubt it stays. On a 9p file system only the other side knows, so the mount point is made at wrap time. --- README.md | 3 + src/sandbox/linux-sandbox-utils.ts | 318 +++++--- test/sandbox/linux-ancestor-pin.test.ts | 6 +- .../linux-unmakeable-mount-point.test.ts | 731 ++++++++++++++++++ 4 files changed, 940 insertions(+), 118 deletions(-) create mode 100644 test/sandbox/linux-unmakeable-mount-point.test.ts diff --git a/README.md b/README.md index e8e4160bc..81b759c68 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,9 @@ Filesystem restrictions are enforced at the OS level: **Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, 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. +- 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. + **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index cb2bcbbb5..f693afdbf 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -22,6 +22,7 @@ import { isAbsenceErrno, isAtOrUnder, isStrictlyUnder, + properAncestors, nameLocation, getDangerousDirectories, workingDirectory, @@ -273,6 +274,97 @@ 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' + +/** 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 + * keeps the placeholder. */ +const O_PATH = 0o10000000 +/** statfs(2)'s type for 9p, which is how WSL 2 mounts a Windows drive. */ +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. + * + * 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). + */ +function makeableMountPoint( + dest: string, + wanted: MountPointKind, +): MountPointKind | undefined { + /** The errno `attempt` is refused with. Any other failure is a doubt. */ + const refusalOf = (attempt: () => void): string | undefined => { + try { + attempt() + return undefined + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return code + throw err + } + } + let fd: number | undefined + try { + // One descriptor answers every question, through /proc: a command still + // running could put another directory at the path between two of them. + // Its link differs from the path when a symlink was followed on the way. + fd = fs.openSync( + path.dirname(dest), + O_PATH | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, + ) + const parent = `/proc/self/fd/${fd}` + if (fs.readlinkSync(parent) !== path.dirname(dest)) return wanted + const { uid, mode } = fs.fstatSync(fd) + // What the kernel asks of a directory before it creates anything in it. + const refusal = refusalOf(() => + fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK), + ) + + if (fs.statfsSync(parent).type !== V9FS_MAGIC) { + // EACCES is the word of the mode or an ACL, which the owner can change, + // and bubblewrap's user namespace overrides it for the owner. A + // read-only file system (EROFS) and an immutable directory (EPERM) + // refuse whoever asks from in there. + return refusal === undefined || + (refusal === 'EACCES' && uid === process.geteuid?.()) + ? wanted + : undefined + } + + // On a Windows drive the Windows ACL decides, and nothing on this side + // tells it: every directory there is this user's, with a mode that lets it + // write, also one that takes subfolders and no files. So the mount point + // is made here instead of by bubblewrap, which finds it there. + if (refusal === undefined) { + const entry = `${parent}/${path.basename(dest)}` + if ( + wanted === 'file' && + refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === + undefined + ) { + return 'file' + } + if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { + return '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 + } catch { + return wanted + } finally { + if (fd !== undefined) fs.closeSync(fd) + } +} + /** * 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 @@ -1908,6 +2000,10 @@ async function generateFilesystemArgs( // spellings. Landings and allowed write paths are canonical, so the extra // spelling can only match more of them, never fewer. const denyWriteRawDests = new Map() + // Placeholder dests where no mount point can be made (see mountPointFor). + // Planned like the others, so that their ancestors are pinned and counted + // alike, and left out at emission. + const unmakeableDenyDests = new Set() // The shared empty directory this call's placeholders bind from, resolved at // most once per wrap: resolving again mid-wrap (the cached path having been // tampered with in between) would leave the binds already emitted pointing @@ -2384,10 +2480,8 @@ async function generateFilesystemArgs( // --ro-bind /dev/null hits a char device on the second pass and // bwrap's ensure_file() falls through to creat() on a read-only mount. const seenDenyWrite = new Set() - // Placeholder destination -> the index of its source in denyWriteArgs, so - // a later deny reaching the same destination can upgrade a /dev/null - // placeholder to the directory form in place (see the emission below). - const placeholderSourceArgIndex = new Map() + // Every directory a deny path lies beneath, there or not. + const denyPathAncestors = new Set() // PRE-PASS (order-independent): record the directories the loop below // re-binds read-only, BEFORE any stub decision, so the read-only // conclusion does not depend on where an enclosing directory appears in @@ -2415,6 +2509,9 @@ async function generateFilesystemArgs( if (resolvedPath === null || resolvedPath.startsWith('/dev/')) { continue } + for (const above of properAncestors(resolvedPath)) { + denyPathAncestors.add(above) + } // Same defense-in-depth re-check as the loop: there the --ro-bind is // replaced by a symlink mask, so such a directory must not be recorded // as re-bound here. @@ -2571,6 +2668,87 @@ async function generateFilesystemArgs( } return covered } + // The placeholder dests decided so far. + const decidedMountPoints = new Set() + const decidedMountPointOver = (p: string): string | undefined => + [p, ...properAncestors(p)].find(at => decidedMountPoints.has(at)) + /** + * 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. + * + * 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 | undefined } | undefined => { + const none = (why: string): undefined => { + logForDebugging( + `[Sandbox Linux] No placeholder for non-existent deny path ${absentPath}: ${why}`, + ) + return undefined + } + // One mount point per destination. Deny paths are deduplicated on the + // deny path, but a placeholder lands on the first MISSING component, so + // two denies can share one: denyWrite '/.claude' together with the + // mandatory '/.claude/commands', in a project with no `.claude/`. + // Asked of the record, not of the host, where the component may be + // there by now (see makeableMountPoint). + const decided = decidedMountPointOver(absentPath) + if (decided !== undefined) return none(`decided with ${decided}`) + + // Read-only in the sandbox, where bwrap could not creat() a mount point + // either. No allowed write path holds it, which leaves it to the initial + // --ro-bind / /, or only covered ones do (see uncoveredWritePaths); or a + // recorded deny directory covers it and survives the + // coveringDirIsUnsafe vetoes (see the INVARIANT at its definition). The + // pre-pass has recorded every such directory, wherever it appears in + // denyPaths; recorded directories exist, so one that covers the path is + // at or above its deepest existing ancestor. + if ( + !uncoveredWritePaths.some(allowedPath => + isAtOrUnder(absentPath, allowedPath), + ) || + coveredBySafeReadOnlyDenyDir(absentPath) + ) { + return none('it is read-only there') + } + // Nothing can be created under a file: a git worktree's .git is one. + // 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') + } + + // A component with a deny path beneath it is covered by an empty + // read-only directory, which blocks creating it and everything below it + // exactly as /dev/null does and stays traversable; as a file it breaks + // tools that expect a directory there. Whatever the order of denyPaths. + const dest = findFirstNonExistentComponent(absentPath) + decidedMountPoints.add(dest) + const kind = makeableMountPoint( + dest, + denyPathAncestors.has(dest) ? 'directory' : 'file', + ) + if (kind === undefined) { + // 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) + } else { + // INVARIANT: only a path found absent here is tracked, for + // cleanupBwrapMountPoints() to remove. An existing file that looks + // like a mount point may be the caller's own, or that of another + // process's sandbox still running, whose deny goes with it. + bwrapMountPoints.add(dest) + registerExitCleanupHandler() + } + return { dest, kind } + } for (const [index, pathPattern] of denyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length @@ -2650,120 +2828,31 @@ async function generateFilesystemArgs( // Handle non-existent paths by mounting /dev/null to block creation. // Without this, a sandboxed process could mkdir+write a denied path that // doesn't exist yet, bypassing the deny rule entirely. - // - // bwrap creates empty files on the host as mount points for these binds. - // We track them in bwrapMountPoints so cleanupBwrapMountPoints() can - // remove them after the command exits. - // - // INVARIANT: only a path found absent here is tracked. An existing file - // that looks like such a mount point may be the caller's own, or that of - // another process's sandbox still running, whose deny goes with it. - if (!fs.existsSync(normalizedPath)) { - // Fix 1 (worktree): If any existing component above the deny path is - // a file (not a directory), skip the deny entirely. You can't mkdir - // under a file, so the deny path can never be created. This handles - // git worktrees where .git is a file. Asked of what lies above the - // path, not of the path: another sandbox's bubblewrap may have made a - // mount point at it since the look above, and a file there is no - // reason to leave it unbound. - if (hasFileAncestor(path.dirname(normalizedPath))) { - logForDebugging( - `[Sandbox Linux] Skipping deny path with file ancestor (cannot create paths under a file): ${normalizedPath}`, - ) - continue - } - - // Find the deepest existing ancestor directory - let ancestorPath = path.dirname(normalizedPath) - while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) { - ancestorPath = path.dirname(ancestorPath) - } - - // Only protect if the existing ancestor is within an allowed write path. - // If not, the path is already read-only from --ro-bind / /. - // (Equality on the absent normalizedPath itself is unreachable, and - // so is an allow entry beneath it — allow entries exist, the deny - // path does not.) - const ancestorIsWithinAllowedPath = - isWithinAnyAllowedWritePath(ancestorPath) || - isWithinAnyAllowedWritePath(normalizedPath) - - // An ancestor inside a directory that an earlier deny re-bound - // read-only (e.g. an explicit denyWrite on the project dir) is - // already read-only in the sandbox: the deny path cannot be created - // there, and stubbing it would make bwrap creat() a mount point - // inside that read-only mount and abort. The order-independent - // pre-pass above has already recorded every directory the loop - // re-binds read-only, so a covering directory deny is visible here - // regardless of where it appears in denyPaths. A recorded covering - // directory is evidence for skipping only if it survives the - // coveringDirIsUnsafe vetoes (see the INVARIANT at its definition). - // (Tested on the absent path itself: a recorded directory that - // covers it is at-or-above its deepest existing ancestor, since - // recorded directories exist.) So is a path that only covered write - // paths hold (see uncoveredWritePaths). - const ancestorIsWithinReadOnlyDeny = - coveredBySafeReadOnlyDenyDir(normalizedPath) || - !uncoveredWritePaths.some(allowedPath => - isAtOrUnder(normalizedPath, allowedPath), - ) - - if (ancestorIsWithinAllowedPath && !ancestorIsWithinReadOnlyDeny) { - const firstNonExistent = findFirstNonExistentComponent(normalizedPath) - - // Fix 2: If firstNonExistent is an intermediate component (not the - // leaf deny path itself), mount a read-only empty directory instead - // 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 - const source = isIntermediate - ? (emptySource ??= ensureEmptyMountSourceDir()) - : '/dev/null' - - // One mount point per destination. Deny paths are deduplicated on - // the deny path, but a placeholder lands on the first MISSING - // component, so two denies sharing one arrive here with a single - // destination — denyWrite '/.claude' together with the - // mandatory '/.claude/commands', in a project with no - // `.claude/`. Two binds there make bwrap refuse to start when they - // disagree about the destination's kind ("Can't mkdir : Not a - // directory"). The directory form wins the disagreement: an empty - // read-only directory blocks creating the destination and everything - // below it exactly as /dev/null does, and stays traversable for the - // deeper deny that asked for a directory. - const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent) - if (placeholderAt !== undefined) { - if (isIntermediate) denyWriteArgs[placeholderAt] = source - logForDebugging( - `[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`, - ) - continue - } - denyWriteArgs.push('--ro-bind', source, firstNonExistent) - placeholderSourceArgIndex.set( - firstNonExistent, - denyWriteArgs.length - 2, + if ( + decidedMountPointOver(normalizedPath) !== undefined || + !fs.existsSync(normalizedPath) + ) { + const placeholder = mountPointFor(normalizedPath) + if (placeholder !== undefined) { + const { dest, kind } = placeholder + denyWriteArgs.push( + '--ro-bind', + kind === 'directory' + ? (emptySource ??= ensureEmptyMountSourceDir()) + : '/dev/null', + dest, ) - // 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 + // 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(firstNonExistent, rawPath) - bwrapMountPoints.add(firstNonExistent) - registerExitCleanupHandler() - logForDebugging( - `[Sandbox Linux] Mounted ${ - isIntermediate ? 'empty dir' : '/dev/null' - } at ${firstNonExistent} to block creation of ${normalizedPath}`, - ) - } else if (ancestorIsWithinReadOnlyDeny) { - logForDebugging( - `[Sandbox Linux] Skipping non-existent deny path inside a read-only denied directory (already uncreatable): ${normalizedPath}`, - ) - } else { + denyWriteRawDests.set(dest, rawPath) logForDebugging( - `[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`, + 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' + } at ${dest} to block creation of ${normalizedPath}`, ) } continue @@ -3095,6 +3184,7 @@ async function generateFilesystemArgs( const restoredReadOnlyWritePaths = new Set() for (let i = 0; i < denyWriteArgs.length; i += 3) { const dest = denyWriteArgs[i + 2]! + if (unmakeableDenyDests.has(dest)) continue const rawDest = denyWriteRawDests.get(dest) ?? dest // A mask's landing, not its dest: the landing is where the mask's bind // actually sits, and this deny's dest is canonical, so the two are diff --git a/test/sandbox/linux-ancestor-pin.test.ts b/test/sandbox/linux-ancestor-pin.test.ts index d33c91d7a..300a21e3d 100644 --- a/test/sandbox/linux-ancestor-pin.test.ts +++ b/test/sandbox/linux-ancestor-pin.test.ts @@ -1509,11 +1509,11 @@ describe.if(isLinux)('Linux sandbox — denyWrite ancestor pinning', () => { // The WORKDIR /app shape: a protected path one level below '/' has no // ancestor to pin, and the cover is the only thing that keeps its // top-level directory a mountpoint. /usr holds no other protected path, - // so the cover can only come from this deny. + // so the cover can only come from this deny, whether or not this user can + // have a mount point made in /usr. const absent = '/usr/srt-ancestor-pin-probe' const command = await wrap({ allowWrite: ['/'], denyWrite: [absent] }) - expect(command).toContain(`--ro-bind /dev/null ${absent}`) expect(command).toContain('--bind /usr /usr') expect(command).not.toContain('--ro-bind /usr /usr') }) @@ -1529,8 +1529,6 @@ describe.if(isLinux)('Linux sandbox — denyWrite ancestor pinning', () => { denyWrite: [`/proc/${probe}`, `/sys/${probe}`, `/usr/${probe}`], }) - expect(command).toContain(`--ro-bind /dev/null /proc/${probe}`) - expect(command).toContain(`--ro-bind /dev/null /sys/${probe}`) expect(command).toContain('--bind /usr /usr') expect(command).not.toContain('--bind /proc /proc') expect(command).not.toContain('--bind /sys /sys') diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts new file mode 100644 index 000000000..e6a6c40ba --- /dev/null +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -0,0 +1,731 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawnSync } from 'node:child_process' +import { basename, dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' +import { countMounts, mountsOf } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' + +const REPO_ROOT = join(import.meta.dir, '../..') +const V9FS_MAGIC = 0x01021997 + +// The originals, for the stand-ins below to fall through to. +const real = { + accessSync: fs.accessSync, + chmodSync: fs.chmodSync, + closeSync: fs.closeSync, + fstatSync: fs.fstatSync, + mkdirSync: fs.mkdirSync, + openSync: fs.openSync, + readlinkSync: fs.readlinkSync, + statfsSync: fs.statfsSync, +} +type Stubbed = keyof typeof real + +const errno = (code: string): Error => + Object.assign(new Error(`${code}: injected`), { code }) + +/** Whether `spelling` is `target` as the wrap names it: by a descriptor on + * its directory, or on itself. */ +function names(spelling: unknown, target: string): boolean { + const viaFd = (p: string, dir: string): boolean => + /^\/proc\/self\/fd\/\d+$/.test(p) && real.readlinkSync(p) === dir + return ( + typeof spelling === 'string' && + (viaFd(spelling, target) || + (basename(spelling) === basename(target) && + viaFd(dirname(spelling), dirname(target)))) + ) +} + +/** + * An absent deny path is blocked by a placeholder, whose mount point + * bubblewrap makes on the host, as this user, before the command runs. Where + * neither it nor the command can create the path there is none to make, and + * the sandbox starts without. + */ +describe.if(isLinux)('A placeholder where no mount point can be made', () => { + let BASE: string + let ROOT: string // the allowed write path + let DIR: string // a directory in it, holding the absent deny paths + const savedCwd = process.cwd() + const spies: Array<{ mockRestore(): void }> = [] + + const CAN_RUN = bwrapCanNamespace() + // Directories of another's that this user cannot write: not root's view. + const UNPRIVILEGED = ['/usr', '/etc'].every(dir => { + try { + fs.accessSync(dir, fs.constants.W_OK) + return false + } catch { + return fs.statSync(dir).uid !== process.geteuid?.() + } + }) + + beforeEach(() => { + BASE = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'no-mount-point-'))) + ROOT = join(BASE, 'root') + DIR = join(ROOT, 'dir') + fs.mkdirSync(DIR, { recursive: true }) + // Outside the allowed write path, so that the mandatory denies add no + // placeholders of their own. + process.chdir(BASE) + }) + + afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore() + standIns.clear() + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + fs.rmSync(BASE, { recursive: true, force: true }) + }) + + /** Stands in for fs[name] until the test ends. A stand-in gets the + * arguments, and answers `undefined` to leave the call to the next one, the + * original being the last. */ + type StandIn = (...args: unknown[]) => { value: unknown } | undefined + const standIns = new Map() + function stub(name: Stubbed, standIn: StandIn): void { + const sofar = standIns.get(name) + if (sofar !== undefined) { + sofar.push(standIn) + return + } + const all = [standIn] + standIns.set(name, all) + const original = real[name] as (...args: unknown[]) => unknown + spies.push( + spyOn(fs, name).mockImplementation(((...args: unknown[]) => { + for (const each of all) { + const stood = each(...args) + if (stood !== undefined) return stood.value + } + return original(...args) + }) as never), + ) + } + /** fs[name] fails with `code` when asked of `target`. */ + const refuse = (name: Stubbed, target: string, code = 'EACCES'): void => + stub(name, spelling => { + if (names(spelling, target)) throw errno(code) + return undefined + }) + /** `directory` belongs to somebody else. */ + const ownedByAnother = (directory: string): void => { + const { ino } = fs.statSync(directory) + stub('fstatSync', fd => { + const stats = real.fstatSync(fd as number) + if (stats.ino !== ino) return undefined + return { value: Object.assign(stats, { uid: stats.uid + 1 }) } + }) + } + /** Everything is on a Windows drive under WSL 2. */ + const onWindowsDrive = (): void => + stub('statfsSync', p => ({ + value: { ...real.statfsSync(p as string), type: V9FS_MAGIC }, + })) + + const wrap = ( + denyWithinAllow: string[], + command = 'true', + allowOnly = [ROOT], + ): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + writeConfig: { allowOnly, denyWithinAllow }, + }) + + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 15000 }) + + /** The mounts that land at `dest`. */ + const mountsAt = (command: string, dest: string): string[] => + mountsOf(command).filter(mount => mount.endsWith(` ${dest}`)) + const emptyDirectoryAt = (dest: string) => + expect.stringMatching( + new RegExp(`^--ro-bind \\S*claude-empty-\\S+ ${dest}$`), + ) + + describe('by what the parent directory answers', () => { + // What access() says of the parent, whose it is, and whether the + // placeholder is planned. + const ANSWERS: [string, string | undefined, boolean, boolean][] = [ + ['writable', undefined, true, true], + ['writable, and of another', undefined, false, true], + ['unwritable by its mode, and of this user', 'EACCES', true, true], + ['unwritable by its mode, and of another', 'EACCES', false, false], + ['on a read-only file system, and of this user', 'EROFS', true, false], + ['on a read-only file system, and of another', 'EROFS', false, false], + ['immutable, and of this user', 'EPERM', true, false], + ['failing to answer, and of another', 'EIO', false, true], + ] + + it.each(ANSWERS)( + 'plans for a parent that is %s', + async (_name, code, own, planned) => { + if (code !== undefined) refuse('accessSync', DIR, code) + if (!own) ownedByAnother(DIR) + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const command = await wrap([leaf, nested]) + + expect(mountsAt(command, leaf)).toEqual( + planned ? [`--ro-bind /dev/null ${leaf}`] : [], + ) + expect(mountsAt(command, join(DIR, 'absent'))).toEqual( + planned ? [emptyDirectoryAt(join(DIR, 'absent'))] : [], + ) + // The parent is pinned either way, beneath the allow bind. + expect(mountsOf(command).indexOf(`--ro-bind ${DIR} ${DIR}`)).toBe(1) + // And the question is asked without a trace. + expect(fs.readdirSync(DIR)).toEqual([]) + }, + ) + + it.skipIf(!CAN_RUN)( + 'keeps a parent that takes no mount point from being moved aside', + async () => { + refuse('accessSync', DIR) + ownedByAnother(DIR) + const aside = join(ROOT, 'aside') + + // In truth DIR is this user's, in a directory it may write: only the + // pin stands in the way. + const result = run( + await wrap( + [join(DIR, 'policy.json')], + `echo BOOTED; mv ${DIR} ${aside}; rmdir ${DIR}; echo x > ${join(ROOT, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(result.stderr).toMatch(/mv: .*busy/) + expect(result.stderr).toMatch(/rmdir: .*busy/) + expect(fs.readdirSync(ROOT).sort()).toEqual(['dir', 'new']) + }, + ) + + it('takes nothing that turns up at the path for its own', async () => { + refuse('accessSync', DIR) + ownedByAnother(DIR) + const denied = join(DIR, 'policy.json') + + await wrap([denied]) + fs.writeFileSync(denied, '') + cleanupBwrapMountPoints() + + expect(fs.existsSync(denied)).toBe(true) + }) + + it('asks for write and search together', async () => { + const asked: unknown[] = [] + stub('accessSync', (spelling, mode) => { + if (names(spelling, DIR)) asked.push(mode) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + + expect(asked).toEqual([fs.constants.W_OK | fs.constants.X_OK]) + }) + }) + + describe('on any doubt', () => { + beforeEach(() => { + // Every answer below would leave the placeholder out. + refuse('accessSync', DIR) + ownedByAnother(DIR) + }) + + const planned = async (): Promise => { + const denied = join(DIR, 'policy.json') + expect(mountsAt(await wrap([denied]), denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + } + + it('leaves it out when there is none', async () => { + const denied = join(DIR, 'policy.json') + expect(mountsAt(await wrap([denied]), denied)).toEqual([]) + }) + + it('plans it when the parent cannot be opened', async () => { + stub('openSync', p => { + if (p === DIR) throw errno('EMFILE') + return undefined + }) + await planned() + }) + + it('plans it when /proc does not say where the descriptor leads', async () => { + stub('readlinkSync', p => { + if (names(p, DIR)) throw errno('ENOENT') + return undefined + }) + await planned() + }) + + it('plans it when the file system cannot be told', async () => { + refuse('statfsSync', DIR, 'ENOSYS') + await planned() + }) + + it('closes the descriptor whatever the answer', async () => { + const open: unknown[] = [] + stub('openSync', (p, flags) => { + if (p !== DIR) return undefined + open.push(real.openSync(DIR, flags as number)) + return { value: open.at(-1) } + }) + stub('closeSync', fd => { + if (open.includes(fd)) open.splice(open.indexOf(fd), 1) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + refuse('statfsSync', DIR, 'ENOSYS') + await wrap([join(DIR, 'other.json')]) + + expect(open).toEqual([]) + }) + }) + + // A command that is still running may write ROOT, and can put something + // else at a path in it at any moment. /usr stands for a directory that + // answers "no mount point": it is another's, and this user cannot write it. + describe.if(UNPRIVILEGED)('with the parent swapped under the wrap', () => { + const swapOnce = ( + name: Stubbed, + when: (p: unknown) => boolean, + swap: () => void, + ) => { + let done = false + stub(name, p => { + if (!done && when(p)) { + done = true + swap() + } + return undefined + }) + } + + it('plans the placeholder when the parent is a symlink as it is opened', async () => { + swapOnce( + 'openSync', + p => p === DIR, + () => { + fs.rmdirSync(DIR) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + + it('plans it when a directory above the parent is a symlink as it is opened', async () => { + const parent = join(DIR, 'share') + fs.mkdirSync(parent) + swapOnce( + 'openSync', + p => p === parent, + () => { + fs.renameSync(DIR, join(ROOT, 'aside')) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(parent, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + + it('plans it when the parent is swapped once it is open', async () => { + swapOnce( + 'fstatSync', + () => true, + () => { + fs.renameSync(DIR, join(ROOT, 'aside')) + fs.symlinkSync('/usr', DIR) + }, + ) + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toHaveLength(1) + }) + }) + + // So that it is the directory that was opened that answers, whatever the + // path names by then. + it.each([ + ['off a Windows drive', false], + ['on a Windows drive', true], + ])( + 'asks everything of one descriptor on the parent, %s', + async (_, drive) => { + const opened: unknown[][] = [] + const asked: string[] = [] + const note = (p: unknown): void => { + // From the open to the close. + if (opened.length === 1) asked.push(String(p).replace(/\d+/, 'N')) + } + stub('openSync', (p, flags) => { + if (p === DIR) opened.push([p, flags]) + else note(p) + // Refused, like mkdir below, so that the questions go on to the last. + if (names(p, join(DIR, 'policy.json'))) throw errno('EACCES') + return undefined + }) + for (const name of [ + 'readlinkSync', + 'accessSync', + 'statfsSync', + 'mkdirSync', + 'chmodSync', + ] as const) { + stub(name, p => { + note(p) + if (name === 'mkdirSync') throw errno('EACCES') + return undefined + }) + } + if (drive) onWindowsDrive() + stub('closeSync', () => { + opened.push([]) + return undefined + }) + + await wrap([join(DIR, 'policy.json')]) + + expect(opened[0]).toEqual([ + DIR, + 0o10000000 | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, + ]) + const parent = '/proc/self/fd/N' + expect(asked).toEqual([ + ...[parent, parent, parent], + ...(drive + ? [`${parent}/policy.json`, `${parent}/policy.json`, parent] + : []), + ]) + }, + ) + + describe('on a Windows drive under WSL 2', () => { + beforeEach(onWindowsDrive) + + const listing = () => fs.readdirSync(DIR).sort() + + it('makes the mount point itself, and the clean-up removes it', async () => { + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const command = await wrap([leaf, nested]) + + expect(mountsAt(command, leaf)).toEqual([`--ro-bind /dev/null ${leaf}`]) + expect(mountsAt(command, join(DIR, 'absent'))).toEqual([ + emptyDirectoryAt(join(DIR, 'absent')), + ]) + expect(listing()).toEqual(['absent', 'policy.json']) + expect(fs.statSync(leaf).size).toBe(0) + expect(fs.statSync(leaf).mode & 0o777).toBe(0o444) + expect(fs.readdirSync(join(DIR, 'absent'))).toEqual([]) + cleanupBwrapMountPoints() + expect(listing()).toEqual([]) + }) + + it('covers a file with a directory in a folder that takes subfolders and no files', async () => { + const denied = join(DIR, 'policy.json') + refuse('openSync', denied) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toEqual([emptyDirectoryAt(denied)]) + expect(fs.statSync(denied).isDirectory()).toBe(true) + cleanupBwrapMountPoints() + expect(listing()).toEqual([]) + }) + + it.each([ + ['a file', 'policy.json', 'policy.json'], + ['a directory', 'absent/policy.json', 'absent'], + ])( + 'leaves %s out in a folder that takes nothing and is beyond this user', + async (_kind, denied, dest) => { + refuse('openSync', join(DIR, dest)) + refuse('mkdirSync', join(DIR, dest), 'EPERM') + refuse('chmodSync', DIR) + + const command = await wrap([join(DIR, denied)]) + + expect(mountsAt(command, join(DIR, dest))).toEqual([]) + expect(countMounts(command, '--ro-bind', DIR, DIR)).toBe(1) + expect(listing()).toEqual([]) + }, + ) + + it('plans the kind asked for when the name is refused in a folder this user governs', async () => { + const denied = join(DIR, 'policy.json') + fs.chmodSync(DIR, 0o2750) + refuse('openSync', denied) + refuse('mkdirSync', denied) + const modes: unknown[] = [] + stub('chmodSync', (p, mode) => { + if (names(p, DIR)) modes.push(mode) + return undefined + }) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + // The mode it has, so that nothing changes. + expect(modes).toEqual([0o2750]) + }) + + it.each([ + ['plans it', 'governs', true], + ['leaves it out', 'does not govern', false], + ])( + '%s, and tries nothing, where access() refuses a folder this user %s', + async (_verdict, _governs, planned) => { + const denied = join(DIR, 'policy.json') + refuse('accessSync', DIR) + if (!planned) refuse('chmodSync', DIR, 'EROFS') + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toHaveLength(planned ? 1 : 0) + expect(listing()).toEqual([]) + }, + ) + + it('tries no file where a directory is wanted', async () => { + const tried: unknown[] = [] + stub('openSync', p => { + if (names(p, join(DIR, 'absent'))) tried.push(p) + return undefined + }) + refuse('mkdirSync', join(DIR, 'absent')) + refuse('chmodSync', DIR) + + await wrap([join(DIR, 'absent', 'policy.json')]) + + expect(tried).toEqual([]) + expect(listing()).toEqual([]) + }) + + it('plans it, and takes nothing for its own, when the path is there by the time it is tried', async () => { + const denied = join(DIR, 'policy.json') + stub('openSync', p => { + if (names(p, denied)) fs.writeFileSync(denied, 'theirs') + return undefined + }) + + const command = await wrap([denied]) + + expect(mountsAt(command, denied)).toHaveLength(1) + cleanupBwrapMountPoints() + expect(fs.readFileSync(denied, 'utf8')).toBe('theirs') + }) + + // What one deny path's mount point puts on the host, the next is not + // misled by: the plan is the same in either order, and as off the drive. + describe.each([ + ['a missing folder', ['absent/policy.json', 'absent/policy.d/x']], + ['a path and one beneath it', ['absent', 'absent/commands']], + ])('with deny paths that share %s', (_name, denied) => { + it.each([ + ['as listed', (list: string[]) => list], + ['reversed', (list: string[]) => [...list].reverse()], + ])('plans one directory, %s', async (_order, ordered) => { + const command = await wrap(ordered(denied.map(d => join(DIR, d)))) + + expect(mountsOf(command).filter(m => m.includes(`${DIR}/`))).toEqual([ + emptyDirectoryAt(join(DIR, 'absent')), + ]) + expect(listing()).toEqual(['absent']) + expect(fs.readdirSync(join(DIR, 'absent'))).toEqual([]) + }) + }) + + it('removes what it made when the wrap then fails', async () => { + const wrapped = wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [ROOT], + denyWithinAllow: [join(DIR, 'policy.json')], + }, + binShell: 'no-such-shell', + }) + + expect(wrapped).rejects.toThrow('no-such-shell') + await wrapped.catch(() => {}) + expect(listing()).toEqual([]) + }) + + it.skipIf(!CAN_RUN)( + 'runs on the mount points it made, and the paths cannot be created', + async () => { + const leaf = join(DIR, 'policy.json') + const nested = join(DIR, 'absent', 'policy.json') + + const result = run( + await wrap( + [leaf, nested], + `echo BOOTED; echo x > ${leaf}; echo x > ${nested}; echo x > ${join(DIR, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(result.stderr).not.toContain('bwrap:') + cleanupBwrapMountPoints() + expect(listing()).toEqual(['new']) + }, + ) + }) + + it('asks nothing of the host for a path that needs no placeholder', async () => { + const opened: unknown[] = [] + stub('openSync', p => { + opened.push(p) + return undefined + }) + fs.writeFileSync(join(ROOT, 'file'), '') + + await wrap( + [ + ROOT, // covers DIR, the one allowed write path + join(DIR, 'covered.json'), + join(BASE, 'outside.json'), + ], + 'true', + [DIR], + ) + + expect(opened).toEqual([]) + }) + + it('asks once for deny paths that share a missing directory', async () => { + const opened: unknown[] = [] + stub('openSync', p => { + opened.push(p) + return undefined + }) + + await wrap([join(DIR, 'absent', 'a'), join(DIR, 'absent', 'b', 'c')]) + + expect(opened).toEqual([DIR]) + }) + + describe.if(UNPRIVILEGED)('in directories of another user', () => { + const PROBE = 'srt-no-mount-point-probe' + const denied = [`/usr/${PROBE}/policy.json`, `/etc/${PROBE}`, `/${PROBE}`] + const dests = [`/usr/${PROBE}`, `/etc/${PROBE}`, `/${PROBE}`] + + it('plans no placeholder, and keeps their top-level directories mount points', async () => { + const command = await wrap(denied, 'true', ['/']) + + for (const dest of dests) expect(mountsAt(command, dest)).toEqual([]) + expect(countMounts(command, '--bind', '/usr', '/usr')).toBe(1) + expect(countMounts(command, '--bind', '/etc', '/etc')).toBe(1) + }) + + it.skipIf(!CAN_RUN).each([ + ['"/"', ['/']], + ['the directories themselves', ['/usr', '/etc']], + ])( + 'starts with %s allowed, and the paths cannot be created', + async (_name, allowOnly) => { + const result = run( + await wrap( + denied, + `echo BOOTED; mkdir ${dests[0]}; echo x > ${dests[1]}; echo x > ${dests[2]}; mv /usr/share /usr/aside; echo x > ${join(DIR, 'new')}`, + [...allowOnly, ROOT], + ), + ) + + expect(result.stderr).not.toContain('bwrap:') + expect(result.stdout).toBe('BOOTED\n') + expect(dests.filter(dest => fs.existsSync(dest))).toEqual([]) + expect(fs.readdirSync(DIR)).toEqual(['new']) + }, + ) + }) + + describe('in a directory of this user that its mode closes', () => { + beforeEach(() => fs.chmodSync(DIR, 0o555)) + afterEach(() => fs.chmodSync(DIR, 0o755)) + + it.skipIf(process.geteuid?.() === 0)('plans the placeholder', async () => { + const denied = join(DIR, 'policy.json') + + expect(mountsAt(await wrap([denied]), denied)).toEqual([ + `--ro-bind /dev/null ${denied}`, + ]) + }) + + it.skipIf(!CAN_RUN)( + 'holds the path against a command that opens the directory', + async () => { + const denied = join(DIR, 'policy.json') + + const result = run( + await wrap( + [denied], + `echo BOOTED; chmod 755 ${DIR}; echo x > ${denied}; echo x > ${join(DIR, 'new')}`, + ), + ) + + expect(result.stdout).toBe('BOOTED\n') + expect(fs.readFileSync(denied, 'utf8')).toBe('') + expect(fs.existsSync(join(DIR, 'new'))).toBe(true) + }, + ) + }) + + // A read-only file system that this user owns everything on: a sandbox + // around the wrap and its own, with DIR bound read-only. + it.skipIf(!CAN_RUN)( + 'starts with an absent deny path on a read-only file system', + () => { + const denied = join(DIR, 'policy.json') + const script = ` + const { wrapCommandWithSandboxLinux } = await import('./src/sandbox/linux-sandbox-utils.ts') + const { spawnSync } = await import('node:child_process') + const command = await wrapCommandWithSandboxLinux({ + command: 'echo BOOTED; echo x > ${denied}; echo x > ${join(ROOT, 'new')}', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['${ROOT}'], denyWithinAllow: ['${denied}'] }, + }) + const { stdout, stderr } = spawnSync(command, { shell: true, encoding: 'utf8' }) + console.log(JSON.stringify({ command, stdout, stderr }))` + const outer = spawnSync( + 'bwrap', + [ + ...['--unshare-user', '--bind', '/', '/', '--ro-bind', DIR, DIR], + ...['--dev', '/dev', '--proc', '/proc', '--chdir', REPO_ROOT], + ...[process.execPath, '-e', script], + ], + { encoding: 'utf8', timeout: 60_000 }, + ) + const { command, stdout, stderr } = JSON.parse( + outer.stdout.trim().split('\n').at(-1) ?? '{}', + ) as { command: string; stdout: string; stderr: string } + + expect(stderr).not.toContain('bwrap:') + expect(stdout).toBe('BOOTED\n') + expect(mountsAt(command, denied)).toEqual([]) + expect(fs.readdirSync(DIR)).toEqual([]) + expect(fs.readdirSync(ROOT).sort()).toEqual(['dir', 'new']) + }, + ) +})