Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,8 @@ $ srt 'echo "bad" > .git/hooks/pre-commit'

**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach. macOS uses glob patterns which block both existing and new files.

**Pinned directories (Linux):** Every directory between a protected path and the allowed write root is bind-mounted over itself so it cannot be renamed or removed from inside the sandbox: `mv` of a nested repository's parent fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves an empty husk behind (as it already did for `.git/hooks`). Reads, writes and creation inside a pinned directory are unaffected, but a rename that crosses a pin boundary returns `EXDEV` to callers without a copy fallback (`mv` copies instead).

**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance. You can configure this with `mandatoryDenySearchDepth`:

```json
Expand Down
448 changes: 408 additions & 40 deletions src/sandbox/linux-sandbox-utils.ts

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions test/sandbox/compute-ancestor-pins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'bun:test'
import { computeAncestorPins } from '../../src/sandbox/linux-sandbox-utils.js'

// Pure walk over the deny-dest seeds; probes are injected so this runs on
// every platform.
describe('computeAncestorPins', () => {
const under = (root: string) => (dir: string) =>
dir === root || dir.startsWith(root + '/')
const none = () => false
const probes = (
roots: string[],
overrides: Partial<Parameters<typeof computeAncestorPins>[1]> = {},
) => ({
isWithinAllowedWrite: (dir: string) => roots.some(r => under(r)(dir)),
isAllowedWriteRoot: (dir: string) => roots.includes(dir),
isExcluded: none,
containsReadDenyTmpfs: none,
isAbsent: none,
...overrides,
})

it('pins every directory strictly between the dest and the write root', () => {
expect(computeAncestorPins(['/w/a/b/.git/config'], probes(['/w']))).toEqual(
['/w/a', '/w/a/b', '/w/a/b/.git'],
)
})

it('pins nothing for a dest outside every write root', () => {
expect(computeAncestorPins(['/x/a/leaf'], probes(['/w']))).toEqual([])
})

it('skips directories at or below a deny dest via isExcluded', () => {
const pins = computeAncestorPins(
['/w/app', '/w/app/repo/.git/config'],
probes(['/w'], { isExcluded: under('/w/app') }),
)
expect(pins).toEqual([])
})

it('skips a directory at or above a read-deny tmpfs', () => {
const tmpfs = '/w/x/y'
const pins = computeAncestorPins(
['/w/x/y/z/.git/config'],
probes(['/w'], {
containsReadDenyTmpfs: dir => under(dir)(tmpfs),
}),
)
expect(pins).toEqual(['/w/x/y/z', '/w/x/y/z/.git'])
})

it('skips absent directories', () => {
const pins = computeAncestorPins(
['/w/a/missing/leaf'],
probes(['/w'], { isAbsent: dir => dir === '/w/a/missing' }),
)
expect(pins).toEqual(['/w/a'])
})

it('continues past a nested write root up to the outermost one', () => {
const pins = computeAncestorPins(
['/w/x/y/z/.git/config'],
probes(['/w', '/w/x/y/z']),
)
expect(pins).toEqual(['/w/x', '/w/x/y', '/w/x/y/z/.git'])
})

it('orders pins shallow-first across seeds and dedupes shared prefixes', () => {
const pins = computeAncestorPins(
['/w/a/b/c/leaf', '/w/a/d/leaf', '/w/e/leaf'],
probes(['/w']),
)
expect(pins).toEqual(['/w/a', '/w/e', '/w/a/b', '/w/a/d', '/w/a/b/c'])
})
})
215 changes: 215 additions & 0 deletions test/sandbox/linux-ancestor-pin-errno.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, it, expect, afterEach, spyOn } from 'bun:test'
import { spawnSync } from 'node:child_process'
import * as fs from 'fs'
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js'
import { isLinux } from '../helpers/platform.js'

// The ancestor-pin walk distinguishes absence (ENOENT/ENOTDIR) from other
// errnos: an unreadable ancestor is still pinned, and an unreadable pin
// component aborts the wrap. Errnos are injected via fs spies (a root
// container sees no real EACCES); each spy asserts its own hit count so a
// non-intercepting mock cannot pass vacuously. Nothing here executes bwrap.
describe.if(isLinux)(
'Linux sandbox — ancestor-pin errno discrimination',
() => {
const errnoError = (code: string, message: string) =>
Object.assign(new Error(message), { code })
const EACCES = () => errnoError('EACCES', 'EACCES: permission denied')

const created: string[] = []
const spies: Array<{ mockRestore: () => void }> = []
afterEach(() => {
for (const spy of spies.splice(0)) spy.mockRestore()
for (const dir of created.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})

function makeTree(): string {
// proj/a/b/.git/config — pins expected for a, a/b, a/b/.git
const proj = realpathSync(mkdtempSync(join(tmpdir(), 'pin-errno-')))
created.push(proj)
mkdirSync(join(proj, 'a', 'b', '.git'), { recursive: true })
writeFileSync(join(proj, 'a', 'b', '.git', 'config'), '[core]\n')
return proj
}

async function wrap(
proj: string,
extra: Partial<Parameters<typeof wrapCommandWithSandboxLinux>[0]> = {},
): Promise<string> {
return wrapCommandWithSandboxLinux({
command: 'true',
needsNetworkRestriction: false,
allowAllUnixSockets: true,
writeConfig: {
allowOnly: [proj],
denyWithinAllow: [join(proj, 'a', 'b', '.git', 'config')],
},
...extra,
})
}

it('baseline: ancestors of a denyWrite target are pinned', async () => {
const proj = makeTree()
const wrapped = await wrap(proj)
const pin = join(proj, 'a', 'b')
expect(wrapped).toContain(`--bind ${pin} ${pin}`)
})

it('pins an ancestor whose stat fails with EACCES', async () => {
const proj = makeTree()
const target = join(proj, 'a', 'b')
const realStat = fs.statSync
const realExists = fs.existsSync
let statHits = 0
let existsHits = 0
spies.push(
spyOn(fs, 'statSync').mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === target) {
statHits++
throw EACCES()
}
return (realStat as (...a: unknown[]) => unknown)(p, ...rest)
}) as typeof fs.statSync),
)
spies.push(
spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => {
if (String(p) === target) {
existsHits++
return false
}
return realExists(p)
}) as typeof fs.existsSync),
)
const wrapped = await wrap(proj)
expect(statHits + existsHits).toBeGreaterThan(0)
expect(wrapped).toContain(`--bind ${target} ${target}`)
})

it('skips the pin of a genuinely absent (ENOENT) ancestor', async () => {
const proj = makeTree()
const missingParent = join(proj, 'a', 'missing')
const wrapped = await wrap(proj, {
writeConfig: {
allowOnly: [proj],
denyWithinAllow: [join(missingParent, 'leaf')],
},
})
expect(wrapped).not.toContain(`--bind ${missingParent} ${missingParent}`)
})

it('aborts the wrap when lstat of a pin component fails with EACCES', async () => {
const proj = makeTree()
const component = join(proj, 'a')
const realLstat = fs.lstatSync
let lstatHits = 0
spies.push(
spyOn(fs, 'lstatSync').mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === component) {
lstatHits++
throw EACCES()
}
return (realLstat as (...a: unknown[]) => unknown)(p, ...rest)
}) as typeof fs.lstatSync),
)
// eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime
await expect(wrap(proj)).rejects.toThrow(
/cannot verify .*Fix its permissions/s,
)
expect(lstatHits).toBeGreaterThan(0)
})

it('drops only the pin when a component vanished (ENOENT) and still builds the wrap', async () => {
const proj = makeTree()
const component = join(proj, 'a')
const realLstat = fs.lstatSync
spies.push(
spyOn(fs, 'lstatSync').mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === component) {
throw errnoError('ENOENT', 'ENOENT: no such file or directory')
}
return (realLstat as (...a: unknown[]) => unknown)(p, ...rest)
}) as typeof fs.lstatSync),
)
const wrapped = await wrap(proj)
expect(wrapped).not.toContain(`--bind ${component} ${component}`)
expect(wrapped).toContain('bwrap')
})

it('seeds ancestors of a denyRead file whose first stat fails with EACCES', async () => {
const proj = makeTree()
mkdirSync(join(proj, 'secrets'))
const denyReadFile = join(proj, 'secrets', 'token')
writeFileSync(denyReadFile, 'x')
const realStat = fs.statSync
let statHits = 0
// Permission flip: unreadable while the seed walk stats (first), readable
// again when the denyRead loop stats and emits the mask.
let failedOnce = false
spies.push(
spyOn(fs, 'statSync').mockImplementation(((
p: fs.PathLike,
...rest: unknown[]
) => {
if (String(p) === denyReadFile && !failedOnce) {
failedOnce = true
statHits++
throw EACCES()
}
return (realStat as (...a: unknown[]) => unknown)(p, ...rest)
}) as typeof fs.statSync),
)
const wrapped = await wrap(proj, {
readConfig: { denyOnly: [denyReadFile], allowWithinDeny: [] },
})
const seedAncestor = join(proj, 'secrets')
expect(statHits).toBeGreaterThan(0)
expect(wrapped).toContain(`--bind ${seedAncestor} ${seedAncestor}`)
})

it('seeds ancestors of a FIFO denyRead entry (every non-directory is masked)', async () => {
const proj = makeTree()
mkdirSync(join(proj, 'secrets'))
const fifoPath = join(proj, 'secrets', 'pipe.fifo')
const mk = spawnSync('mkfifo', [fifoPath])
if (mk.status !== 0) {
throw new Error('mkfifo unavailable')
}
const wrapped = await wrap(proj, {
readConfig: { denyOnly: [fifoPath], allowWithinDeny: [] },
})
const seedAncestor = join(proj, 'secrets')
expect(wrapped).toContain(`--bind ${seedAncestor} ${seedAncestor}`)
expect(wrapped).toContain(`--ro-bind /dev/null ${fifoPath}`)
})

it('seeds nothing for a genuinely absent denyRead file', async () => {
const proj = makeTree()
const absent = join(proj, 'secrets', 'gone')
const wrapped = await wrap(proj, {
readConfig: { denyOnly: [absent], allowWithinDeny: [] },
})
const seedAncestor = join(proj, 'secrets')
expect(wrapped).not.toContain(`--bind ${seedAncestor} ${seedAncestor}`)
})
},
)
49 changes: 49 additions & 0 deletions test/sandbox/linux-ancestor-pin-implicit-tmpfs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'bun:test'
import { existsSync } from 'node:fs'
import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js'
import { isLinux } from '../helpers/platform.js'

// The read section mounts an implicit tmpfs at /etc/ssh/ssh_config.d whenever
// readConfig is defined, even with an empty denyOnly, so the pin walk's tmpfs
// exclusion must key on readConfig itself or it pins /etc/ssh above it.
const HOST_SHAPE_PRESENT =
isLinux &&
existsSync('/etc/ssh/ssh_config.d') &&
existsSync('/etc/ssh/ssh_config')

describe.if(HOST_SHAPE_PRESENT)(
'Linux sandbox — implicit ssh_config.d tmpfs vs ancestor-pin exclusion',
() => {
const baseParams = {
command: 'true',
needsNetworkRestriction: false,
allowAllUnixSockets: true,
}

it('does not pin /etc/ssh above the implicit ssh_config.d tmpfs', async () => {
const wrapped = await wrapCommandWithSandboxLinux({
...baseParams,
readConfig: { denyOnly: [], allowWithinDeny: [] },
writeConfig: {
allowOnly: ['/etc'],
denyWithinAllow: ['/etc/ssh/ssh_config'],
},
})
expect(wrapped).toMatch(/--tmpfs \/etc\/ssh\/ssh_config\.d(?: |$)/)
expect(wrapped).not.toMatch(/--bind \/etc\/ssh \/etc\/ssh(?: |$)/)
})

it('pins /etc/ssh when there is no readConfig and hence no implicit tmpfs', async () => {
const wrapped = await wrapCommandWithSandboxLinux({
...baseParams,
readConfig: undefined,
writeConfig: {
allowOnly: ['/etc'],
denyWithinAllow: ['/etc/ssh/ssh_config'],
},
})
expect(wrapped).not.toContain('--tmpfs /etc/ssh/ssh_config.d')
expect(wrapped).toMatch(/--bind \/etc\/ssh \/etc\/ssh(?: |$)/)
})
},
)
Loading
Loading