Files
sandbox-runtime/test/sandbox/wrap-with-sandbox.test.ts
Alice T'Poteat 41a57927c7 fix: allow filesystem root traversal when denyRead includes '/' (#190)
* Fix allowRead carve-outs when denyRead covers filesystem root

denyRead: ['/'] + allowRead: [<project>] denied everything on both
platforms (follow-up to #166, closes #10).

macOS: (deny file-read* (subpath "/")) blocks the root inode itself; no
allowWithinDeny subpath covers "/", so dyld SIGABRTs before exec. Emit
(allow file-read* (literal "/")) so path traversal through root works.
Exposes `ls /` dirent names but no subtree contents.

Linux: two issues. --tmpfs / wipes every prior mount (ro-bind /, write
binds, denyWrite ro-binds), and the carve-out prefix check
startsWith('/' + '/') never matches. Expand a root deny into its
children (minus /proc, /dev, /sys) so the existing per-dir tmpfs +
re-bind logic applies. Also re-bind any allowWrite paths that land
under a tmpfs'd deny dir (previously they went read-only), and buffer
denyWrite ro-binds until after denyRead processing so .git/hooks
protection survives a tmpfs over an ancestor.

* Dedup denyWrite entries post-normalization to prevent bwrap failure

Two denyWrite entries that converge to the same path after
normalizePathForSandbox() produced a duplicate
--ro-bind /dev/null <dest>. On the second bind, <dest> is a char device
(the first bind's mount); bwrap's ensure_file() only short-circuits on
S_ISREG, so it falls through to creat() and fails on the now-read-only
mount. Every sandboxed command errors out.

Common trigger: same path specified in two config surfaces that feed
into denyWrite, e.g. a file-edit permission deny and a
sandbox.filesystem.denyWrite entry for the same file.

linuxGetMandatoryDenyPaths() already does [...new Set(denyPaths)] but
that's pre-normalization — ~/.foo and the expanded absolute path differ
as strings there.

* Wire allow-read and wrap-with-sandbox tests into CI

test:integration only listed integration.test.ts explicitly; these two
files were never run despite containing sandbox-exec/bwrap integration
tests. The root-deny and dedup tests added in this PR need CI coverage.

* Fix Linux test assumptions: seccomp binary path, ro-bind src+dest count

Root deny hides the repo's vendor/seccomp/ dir, so apply-seccomp
can't load inside the sandbox and bash returns 127. Bypass seccomp
with allowAllUnixSockets: true — socket blocking is orthogonal here.

Dedup assertion was off by 2x: --ro-bind <p> <p> contains <p> twice.
The fix works (was 4 occurrences, now 2).

* Narrow allowRead skip check to writes actually re-bound under this tmpfs

The skip check introduced in 3cdb468 was too broad: it skipped any
allowPath under any allowWrite, not just writes that were re-bound in
the current tmpfs iteration. With allowWrite as an ancestor of denyRead
(e.g. allowWrite: [~], denyRead: [~/.ssh], allowRead: [~/.ssh/known_hosts])
the write path isn't wiped and isn't re-bound, but the skip check still
matched — known_hosts was left sitting in the empty tmpfs.

Narrow the predicate: only skip if the write path itself is under the
tmpfs'd deny dir (i.e. it was re-bound just above).
2026-03-30 15:42:43 -07:00

759 lines
23 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import { SandboxManager } from '../../src/sandbox/sandbox-manager.js'
import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js'
import { getPlatform } from '../../src/utils/platform.js'
import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js'
import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Create a test configuration with network access
*/
function createTestConfig(): SandboxRuntimeConfig {
return {
network: {
allowedDomains: ['example.com', 'api.github.com'],
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh'],
allowWrite: ['.', '/tmp'],
denyWrite: ['.env'],
},
}
}
function skipIfUnsupportedPlatform(): boolean {
const platform = getPlatform()
return platform !== 'linux' && platform !== 'macos'
}
describe('wrapWithSandbox customConfig', () => {
beforeAll(async () => {
if (skipIfUnsupportedPlatform()) {
return
}
await SandboxManager.initialize(createTestConfig())
})
afterAll(async () => {
if (skipIfUnsupportedPlatform()) {
return
}
await SandboxManager.reset()
})
describe('without customConfig', () => {
it('uses main config values', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'echo hello'
const wrapped = await SandboxManager.wrapWithSandbox(command)
// Should wrap the command (not return it as-is)
expect(wrapped).not.toBe(command)
expect(wrapped.length).toBeGreaterThan(command.length)
})
})
describe('with customConfig filesystem overrides', () => {
it('uses custom allowWrite when provided', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'echo hello'
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
filesystem: {
denyRead: [],
allowWrite: [], // Override to block all writes
denyWrite: [],
},
})
// Should still wrap the command
expect(wrapped).not.toBe(command)
expect(wrapped.length).toBeGreaterThan(command.length)
})
it('uses custom denyRead when provided', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'cat /etc/passwd'
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
filesystem: {
denyRead: ['/etc/passwd'], // Block this specific file
allowWrite: [],
denyWrite: [],
},
})
expect(wrapped).not.toBe(command)
})
})
describe('with customConfig network overrides', () => {
it('blocks network when allowedDomains is empty', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'curl https://example.com'
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
network: {
allowedDomains: [], // Block all network
deniedDomains: [],
},
})
// Should wrap but without proxy env vars when allowedDomains is empty
expect(wrapped).not.toBe(command)
// The wrapped command should not contain proxy port references
// when there are no allowed domains (no network access needed)
// Note: This is implementation-specific and may need adjustment
})
it('uses main config network when customConfig.network is undefined', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'echo hello'
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
filesystem: {
denyRead: [],
allowWrite: [],
denyWrite: [],
},
// network is not provided, should use main config
})
expect(wrapped).not.toBe(command)
})
})
describe('readonly mode simulation', () => {
it('can create a fully restricted sandbox config', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'ls -la'
// This is what BashTool passes for readonly commands
const readonlyConfig = {
network: {
allowedDomains: [], // Block all network
deniedDomains: [],
},
filesystem: {
denyRead: [],
allowWrite: [], // Block all writes
denyWrite: [],
},
}
const wrapped = await SandboxManager.wrapWithSandbox(
command,
undefined,
readonlyConfig,
)
// Should wrap the command with restrictions
expect(wrapped).not.toBe(command)
expect(wrapped.length).toBeGreaterThan(command.length)
})
})
describe('partial config merging', () => {
it('only overrides specified filesystem fields', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'echo test'
// Only override allowWrite, should use main config for denyRead/denyWrite
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
filesystem: {
denyRead: [], // Override denyRead
allowWrite: ['/custom/path'], // Override allowWrite
denyWrite: [], // Override denyWrite
},
})
expect(wrapped).not.toBe(command)
})
it('only overrides specified network fields', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const command = 'echo test'
// Only override allowedDomains
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, {
network: {
allowedDomains: ['custom.example.com'],
deniedDomains: [],
},
})
expect(wrapped).not.toBe(command)
})
})
})
/**
* Tests for restriction pattern semantics
*
* These test the platform functions directly to verify:
* - Read (deny-only): undefined or empty denyOnly = no restrictions
* - Write (allow-only): undefined = no restrictions, any config = restrictions
* - Network: needsNetworkRestriction = false means no network sandbox
*/
describe('restriction pattern semantics', () => {
const command = 'echo hello'
describe('no sandboxing needed (early return)', () => {
it('returns command unchanged when no restrictions on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// No network, empty read deny, no write config = no sandboxing
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: [] },
writeConfig: undefined,
})
expect(result).toBe(command)
})
it('returns command unchanged when no restrictions on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
// No network, empty read deny, no write config = no sandboxing
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: [] },
writeConfig: undefined,
})
expect(result).toBe(command)
})
it('returns command unchanged with undefined readConfig on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: undefined,
writeConfig: undefined,
})
expect(result).toBe(command)
})
it('returns command unchanged with undefined readConfig on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: false,
readConfig: undefined,
writeConfig: undefined,
})
expect(result).toBe(command)
})
})
describe('read restrictions (deny-only pattern)', () => {
it('empty denyOnly means no read restrictions on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// Only write restrictions, empty read = should sandbox but no read rules
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
})
// Should wrap because of write restrictions
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
})
it('non-empty denyOnly means has read restrictions on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: ['/secret'] },
writeConfig: undefined,
})
// Should wrap because of read restrictions
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
})
})
describe('write restrictions (allow-only pattern)', () => {
it('undefined writeConfig means no write restrictions on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// Has read restrictions but no write = should sandbox
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: ['/secret'] },
writeConfig: undefined,
})
expect(result).not.toBe(command)
})
it('empty allowOnly means maximally restrictive (has restrictions) on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// Empty allowOnly = no writes allowed = has restrictions
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: [], denyWithinAllow: [] },
})
// Should wrap because empty allowOnly is still a restriction
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
})
it('any writeConfig means has restrictions on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: [], denyWithinAllow: [] },
})
// Should wrap because writeConfig is defined
expect(result).not.toBe(command)
expect(result).toContain('sandbox-exec')
})
})
describe('network restrictions', () => {
it('needsNetworkRestriction false skips network sandbox on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: ['/secret'] },
writeConfig: undefined,
})
// Should wrap for filesystem but not include network args
expect(result).not.toBe(command)
expect(result).not.toContain('--unshare-net')
})
it('needsNetworkRestriction false skips network sandbox on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: false,
readConfig: { denyOnly: ['/secret'] },
writeConfig: undefined,
})
// Should wrap for filesystem
expect(result).not.toBe(command)
expect(result).toContain('sandbox-exec')
})
// Tests for the empty allowedDomains fix (CVE fix)
// Empty allowedDomains should block all network, not allow all
it('needsNetworkRestriction true without proxy sockets blocks all network on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// Network restriction enabled but no proxy sockets = block all network
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: true,
httpSocketPath: undefined, // No proxy available
socksSocketPath: undefined, // No proxy available
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
})
// Should wrap with --unshare-net to block all network
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
expect(result).toContain('--unshare-net')
// Should NOT contain proxy-related environment variables since no proxy
expect(result).not.toContain('HTTP_PROXY')
})
it('needsNetworkRestriction true without proxy ports blocks all network on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
// Network restriction enabled but no proxy ports = block all network
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: true,
httpProxyPort: undefined, // No proxy available
socksProxyPort: undefined, // No proxy available
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
})
// Should wrap with sandbox-exec
expect(result).not.toBe(command)
expect(result).toContain('sandbox-exec')
// The sandbox profile should NOT contain "(allow network*)" since restrictions are enabled
// Note: We can't easily check the profile content, but we verify it doesn't skip sandboxing
})
it('needsNetworkRestriction true with proxy allows filtered network on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
// Create temporary socket files for the test
const fs = await import('fs')
const os = await import('os')
const path = await import('path')
const tmpDir = os.tmpdir()
const httpSocket = path.join(tmpDir, `test-http-${Date.now()}.sock`)
const socksSocket = path.join(tmpDir, `test-socks-${Date.now()}.sock`)
// Create dummy socket files
fs.writeFileSync(httpSocket, '')
fs.writeFileSync(socksSocket, '')
try {
const result = await wrapCommandWithSandboxLinux({
command,
needsNetworkRestriction: true,
httpSocketPath: httpSocket,
socksSocketPath: socksSocket,
httpProxyPort: 3128,
socksProxyPort: 1080,
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
})
// Should wrap with network namespace isolation
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
expect(result).toContain('--unshare-net')
// Should bind the socket files
expect(result).toContain(httpSocket)
expect(result).toContain(socksSocket)
} finally {
// Cleanup
fs.unlinkSync(httpSocket)
fs.unlinkSync(socksSocket)
}
})
it('needsNetworkRestriction true with proxy allows filtered network on macOS', () => {
if (getPlatform() !== 'macos') {
return
}
const result = wrapCommandWithSandboxMacOS({
command,
needsNetworkRestriction: true,
httpProxyPort: 3128,
socksProxyPort: 1080,
readConfig: { denyOnly: [] },
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
})
// Should wrap with sandbox-exec and proxy env vars
expect(result).not.toBe(command)
expect(result).toContain('sandbox-exec')
// Should set proxy environment variables
expect(result).toContain('HTTP_PROXY')
expect(result).toContain('HTTPS_PROXY')
})
})
})
/**
* Tests for the empty allowedDomains vulnerability fix
*
* These tests verify that when allowedDomains is explicitly set to an empty array [],
* network access is blocked (as documented) rather than allowed (the bug).
*
* Documentation states: "Empty array = no network access"
* Bug behavior: Empty array = full unrestricted network access
* Fixed behavior: Empty array = network isolation enabled, all network blocked
*/
describe('empty allowedDomains network blocking (CVE fix)', () => {
const command = 'curl https://example.com'
describe('SandboxManager.wrapWithSandbox with empty allowedDomains', () => {
beforeAll(async () => {
if (skipIfUnsupportedPlatform()) {
return
}
// Initialize with domains so proxy starts, then test with empty customConfig
await SandboxManager.initialize({
network: {
allowedDomains: ['example.com'],
deniedDomains: [],
},
filesystem: {
denyRead: [],
allowWrite: ['/tmp'],
denyWrite: [],
},
})
})
afterAll(async () => {
if (skipIfUnsupportedPlatform()) {
return
}
await SandboxManager.reset()
})
it('empty allowedDomains in customConfig triggers network restriction on Linux', async () => {
if (getPlatform() !== 'linux') {
return
}
const result = await SandboxManager.wrapWithSandbox(command, undefined, {
network: {
allowedDomains: [], // Empty = block all network (documented behavior)
deniedDomains: [],
},
filesystem: {
denyRead: [],
allowWrite: ['/tmp'],
denyWrite: [],
},
})
// With the fix, empty allowedDomains should trigger network isolation
expect(result).not.toBe(command)
expect(result).toContain('bwrap')
expect(result).toContain('--unshare-net')
})
it('empty allowedDomains in customConfig triggers network restriction on macOS', async () => {
if (getPlatform() !== 'macos') {
return
}
const result = await SandboxManager.wrapWithSandbox(command, undefined, {
network: {
allowedDomains: [], // Empty = block all network (documented behavior)
deniedDomains: [],
},
filesystem: {
denyRead: [],
allowWrite: ['/tmp'],
denyWrite: [],
},
})
// With the fix, empty allowedDomains should trigger sandbox
expect(result).not.toBe(command)
expect(result).toContain('sandbox-exec')
})
it('non-empty allowedDomains still works correctly', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const result = await SandboxManager.wrapWithSandbox(command, undefined, {
network: {
allowedDomains: ['example.com'], // Specific domain allowed
deniedDomains: [],
},
filesystem: {
denyRead: [],
allowWrite: ['/tmp'],
denyWrite: [],
},
})
// Should still wrap with sandbox
expect(result).not.toBe(command)
// Should have proxy environment variables for filtering
expect(result).toContain('HTTP_PROXY')
})
it('undefined network config in customConfig falls back to main config', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const result = await SandboxManager.wrapWithSandbox(command, undefined, {
// No network config - should fall back to main config which has example.com
filesystem: {
denyRead: [],
allowWrite: ['/tmp'],
denyWrite: [],
},
})
// Should wrap with sandbox using main config's network settings
expect(result).not.toBe(command)
// Main config has example.com, so proxy should be set up
expect(result).toContain('HTTP_PROXY')
})
})
})
describe('allowWrite glob suffix handling', () => {
const command = 'echo hello'
it('allowWrite with /** suffix includes path in sandbox command', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const testDir = join(tmpdir(), `srt-test-glob-allow-${Date.now()}`)
mkdirSync(testDir, { recursive: true })
try {
await SandboxManager.reset()
await SandboxManager.initialize({
network: { allowedDomains: [], deniedDomains: [] },
filesystem: {
denyRead: [],
allowWrite: [`${testDir}/**`],
denyWrite: [],
},
})
const result = await SandboxManager.wrapWithSandbox(command)
expect(result).not.toBe(command)
expect(result).toContain(testDir)
} finally {
await SandboxManager.reset()
rmSync(testDir, { recursive: true, force: true })
}
})
it('denyWrite with /** suffix within allowed parent includes both paths', async () => {
if (skipIfUnsupportedPlatform()) {
return
}
const parentDir = join(tmpdir(), `srt-test-glob-deny-${Date.now()}`)
const childDir = join(parentDir, 'denied')
mkdirSync(childDir, { recursive: true })
try {
await SandboxManager.reset()
await SandboxManager.initialize({
network: { allowedDomains: [], deniedDomains: [] },
filesystem: {
denyRead: [],
allowWrite: [parentDir],
denyWrite: [`${childDir}/**`],
},
})
const result = await SandboxManager.wrapWithSandbox(command)
expect(result).not.toBe(command)
expect(result).toContain(parentDir)
expect(result).toContain(childDir)
} finally {
await SandboxManager.reset()
rmSync(parentDir, { recursive: true, force: true })
}
})
// Regression: two denyWrite entries that converge to the same path after
// normalizePathForSandbox() produced a duplicate --ro-bind /dev/null <dest>.
// Second bind finds a char device at <dest>; bwrap's ensure_file() doesn't
// short-circuit on S_ISCHR and falls through to creat() on a read-only mount.
it('dedups denyWrite entries that normalize to the same path (Linux)', async () => {
if (getPlatform() !== 'linux') {
return
}
const parentDir = join(tmpdir(), `srt-test-dup-deny-${Date.now()}`)
const childFile = join(parentDir, 'dup.txt')
mkdirSync(parentDir, { recursive: true })
writeFileSync(childFile, '')
try {
await SandboxManager.reset()
await SandboxManager.initialize({
network: { allowedDomains: [], deniedDomains: [] },
filesystem: {
denyRead: [],
allowWrite: [parentDir],
// Trailing slash and bare form both realpath to childFile
denyWrite: [childFile, childFile + '/'],
},
})
const result = await SandboxManager.wrapWithSandbox(command)
// One --ro-bind <path> <path> contains the path twice (src + dest).
// Without dedup this was 4 occurrences (two binds).
const occurrences = result.split(childFile).length - 1
expect(occurrences).toBe(2)
} finally {
await SandboxManager.reset()
rmSync(parentDir, { recursive: true, force: true })
}
})
})