From 646d9f64a741916612c851e5bc03a48af6c4a039 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 13:37:24 -0700 Subject: [PATCH 1/6] Run full test suite in CI and migrate platform skips to describe.if MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was running test:unit + test:integration, a curated subset of 5 files. Most test files were never run in CI. Switch to `npm test` which runs everything. Drop the test:unit/test:integration scripts. Migrate the inline `if (skipIfNotLinux()) return` pattern to bun's native `describe.if()`/`it.if()`. The old pattern made wrong-platform tests show as pass (zero assertions, green checkmark) instead of skip — CI's test count looked the same regardless of what actually ran. New test/helpers/platform.ts exports isLinux/isMacOS/isSupportedPlatform. Delete ~310 lines of unreachable tests from seccomp-filter.test.ts: - skipIfNotAnt() gate checked USER_TYPE env var that nothing sets - Two tests called wrapCommandWithSandboxLinux() with no restrictions, which returns the command unwrapped at the early-return check — expect("echo test").not.toContain("apply-seccomp") was vacuously true Pin allow-read root-deny tests to /bin/bash — EXEC_DEPS doesn't list /opt/homebrew, so execvp failed on Macs with homebrew bash as SHELL. Add docker-tests CI job: unprivileged container on both arches, exercises enableWeakerNestedSandbox end-to-end. Drop push trigger from '**' to 'main' — PRs were running the full matrix twice (once for branch push, once for the PR event). --- .github/workflows/integration-tests.yml | 61 +- package.json | 2 - test/configurable-proxy-ports.test.ts | 236 ++- test/helpers/platform.ts | 7 + test/sandbox/allow-read.test.ts | 594 +++--- test/sandbox/glob-expand.test.ts | 50 +- test/sandbox/integration.test.ts | 598 ++---- .../sandbox/macos-allow-local-binding.test.ts | 18 +- test/sandbox/macos-pty.test.ts | 22 +- test/sandbox/macos-seatbelt.test.ts | 116 +- test/sandbox/mandatory-deny-paths.test.ts | 1856 ++++++++--------- test/sandbox/pid-namespace-isolation.test.ts | 220 +- test/sandbox/seccomp-filter.test.ts | 407 +--- test/sandbox/symlink-boundary.test.ts | 45 +- test/sandbox/symlink-write-path.test.ts | 34 +- test/sandbox/update-config.test.ts | 214 +- test/sandbox/wrap-with-sandbox.test.ts | 871 ++++---- 17 files changed, 2199 insertions(+), 3152 deletions(-) create mode 100644 test/helpers/platform.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index bbd6c5a4..e163d573 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: ['**'] + branches: ['main'] pull_request: branches: ['**'] @@ -73,19 +73,12 @@ jobs: - name: Build project run: npm run build - - name: Run unit tests - run: npm run test:unit + - name: Run tests + run: npm test - name: Run Node.js fallback tests run: node test/utils/which-node-test.mjs - - name: Run integration tests - run: npm run test:integration - - - name: Run mandatory-deny-paths tests (Linux) - if: matrix.os == 'linux' - run: bun test test/sandbox/mandatory-deny-paths.test.ts - - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -95,3 +88,51 @@ jobs: test-results/ *.log if-no-files-found: ignore + + docker-tests: + # Run the suite inside an unprivileged container — the environment + # enableWeakerNestedSandbox targets. seccomp unconfined so bwrap can + # unshare(CLONE_NEWUSER); no CAP_SYS_ADMIN so --proc /proc fails. + name: Tests (docker / ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - arch: x86-64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable unprivileged user namespaces on host + run: | + # The container shares the host kernel; this sysctl must be set on + # the host for bwrap/apply-seccomp to nest namespaces inside. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + sudo sysctl -w kernel.unprivileged_userns_clone=1 || true + + - name: Run tests in unprivileged container + run: | + docker run --rm \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + -v "${{ github.workspace }}:/work" \ + -w /work \ + ubuntu:24.04 \ + bash -c ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip git zsh + curl -fsSL https://bun.sh/install | bash + export PATH="$HOME/.bun/bin:$PATH" + curl -fsSL https://deb.nodesource.com/setup_18.x | bash - + apt-get install -y -qq nodejs + npm install + npm run build + npm test + ' diff --git a/package.json b/package.json index 786b9ff4..5ab68bd1 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,6 @@ "build:seccomp": "scripts/build-seccomp-binaries.sh", "clean": "rm -rf dist", "test": "bun test", - "test:unit": "bun test test/config-validation.test.ts test/sandbox/seccomp-filter.test.ts", - "test:integration": "bun test test/sandbox/integration.test.ts test/sandbox/allow-read.test.ts test/sandbox/wrap-with-sandbox.test.ts", "typecheck": "tsc --noEmit", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", diff --git a/test/configurable-proxy-ports.test.ts b/test/configurable-proxy-ports.test.ts index be47e76c..e09febee 100644 --- a/test/configurable-proxy-ports.test.ts +++ b/test/configurable-proxy-ports.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { describe, it, expect, afterAll } from 'bun:test' import { spawnSync } from 'node:child_process' import * as http from 'node:http' import * as net from 'node:net' 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 { isLinux } from './helpers/platform.js' /** * Integration tests for configurable proxy ports feature @@ -291,134 +291,142 @@ describe('Configurable Proxy Ports Integration Tests', () => { }) describe('End-to-end: External proxy actually handles requests', () => { - it('should route requests through external allow-all proxy, bypassing SRT filtering', async () => { - // Skip if not on Linux (where we have full sandbox integration) - if (getPlatform() !== 'linux') { - console.log('Skipping end-to-end test on non-Linux platform') - return - } - - // Create a simple HTTP CONNECT proxy that allows ALL connections (no filtering) - let externalProxyServer: http.Server | undefined - let externalProxyPort: number | undefined - - try { - externalProxyServer = http.createServer() - - // Handle HTTP CONNECT method for HTTPS tunneling - externalProxyServer.on('connect', (req, clientSocket, head) => { - const { port, hostname } = new URL(`http://${req.url}`) + it.if(isLinux)( + 'should route requests through external allow-all proxy, bypassing SRT filtering', + async () => { + // Create a simple HTTP CONNECT proxy that allows ALL connections (no filtering) + let externalProxyServer: http.Server | undefined + let externalProxyPort: number | undefined + + try { + externalProxyServer = http.createServer() + + // Handle HTTP CONNECT method for HTTPS tunneling + externalProxyServer.on('connect', (req, clientSocket, head) => { + const { port, hostname } = new URL(`http://${req.url}`) + + // Connect to target (allow everything - no filtering) + const serverSocket = net.connect( + parseInt(port) || 80, + hostname, + () => { + clientSocket.write( + 'HTTP/1.1 200 Connection Established\r\n\r\n', + ) + serverSocket.write(head) + serverSocket.pipe(clientSocket) + clientSocket.pipe(serverSocket) + }, + ) + + serverSocket.on('error', () => { + clientSocket.end() + }) - // Connect to target (allow everything - no filtering) - const serverSocket = net.connect(parseInt(port) || 80, hostname, () => { - clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') - serverSocket.write(head) - serverSocket.pipe(clientSocket) - clientSocket.pipe(serverSocket) + clientSocket.on('error', () => { + serverSocket.end() + }) }) - serverSocket.on('error', (err) => { - clientSocket.end() - }) + // Handle regular HTTP requests + externalProxyServer.on('request', (req, res) => { + const url = new URL(req.url!) + const options = { + hostname: url.hostname, + port: url.port || 80, + path: url.pathname + url.search, + method: req.method, + headers: req.headers, + } - clientSocket.on('error', (err) => { - serverSocket.end() - }) - }) - - // Handle regular HTTP requests - externalProxyServer.on('request', (req, res) => { - const url = new URL(req.url!) - const options = { - hostname: url.hostname, - port: url.port || 80, - path: url.pathname + url.search, - method: req.method, - headers: req.headers, - } + const proxyReq = http.request(options, proxyRes => { + res.writeHead(proxyRes.statusCode!, proxyRes.headers) + proxyRes.pipe(res) + }) - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode!, proxyRes.headers) - proxyRes.pipe(res) - }) + proxyReq.on('error', () => { + res.writeHead(502) + res.end('Bad Gateway') + }) - proxyReq.on('error', (err) => { - res.writeHead(502) - res.end('Bad Gateway') + req.pipe(proxyReq) }) - req.pipe(proxyReq) - }) - - // Start the external proxy on a random port - await new Promise((resolve, reject) => { - externalProxyServer!.listen(0, '127.0.0.1', () => { - const addr = externalProxyServer!.address() - if (addr && typeof addr === 'object') { - externalProxyPort = addr.port - console.log(`External allow-all proxy started on port ${externalProxyPort}`) - resolve() - } else { - reject(new Error('Failed to get proxy address')) - } + // Start the external proxy on a random port + await new Promise((resolve, reject) => { + externalProxyServer!.listen(0, '127.0.0.1', () => { + const addr = externalProxyServer!.address() + if (addr && typeof addr === 'object') { + externalProxyPort = addr.port + console.log( + `External allow-all proxy started on port ${externalProxyPort}`, + ) + resolve() + } else { + reject(new Error('Failed to get proxy address')) + } + }) + externalProxyServer!.on('error', reject) }) - externalProxyServer!.on('error', reject) - }) - - // Initialize SandboxManager with restrictive config but external proxy - const config: SandboxRuntimeConfig = { - network: { - allowedDomains: ['example.com'], // Only allow example.com - deniedDomains: [], - httpProxyPort: externalProxyPort, // Use our allow-all external proxy - }, - filesystem: { - denyRead: [], - allowWrite: [], - denyWrite: [], - }, - } - - await SandboxManager.initialize(config) - - // Verify the external proxy port is being used - expect(SandboxManager.getProxyPort()).toBe(externalProxyPort) - - // Try to access example.com (in allowlist) - // This verifies that requests are routed through the external proxy - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 5 http://example.com' - ) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + // Initialize SandboxManager with restrictive config but external proxy + const config: SandboxRuntimeConfig = { + network: { + allowedDomains: ['example.com'], // Only allow example.com + deniedDomains: [], + httpProxyPort: externalProxyPort, // Use our allow-all external proxy + }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + }, + } - // The request should succeed - expect(result.status).toBe(0) + await SandboxManager.initialize(config) - // Should NOT contain SRT's block message - const output = (result.stderr || result.stdout || '').toLowerCase() - expect(output).not.toContain('blocked by network allowlist') + // Verify the external proxy port is being used + expect(SandboxManager.getProxyPort()).toBe(externalProxyPort) - console.log('✓ Request to example.com succeeded through external proxy') - console.log('✓ This verifies SRT used the external proxy on the configured port') + // Try to access example.com (in allowlist) + // This verifies that requests are routed through the external proxy + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com', + ) - } finally { - // Clean up - await SandboxManager.reset() + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) - if (externalProxyServer) { - await new Promise((resolve) => { - externalProxyServer!.close(() => { - console.log('External proxy server closed') - resolve() + // The request should succeed + expect(result.status).toBe(0) + + // Should NOT contain SRT's block message + const output = (result.stderr || result.stdout || '').toLowerCase() + expect(output).not.toContain('blocked by network allowlist') + + console.log( + '✓ Request to example.com succeeded through external proxy', + ) + console.log( + '✓ This verifies SRT used the external proxy on the configured port', + ) + } finally { + // Clean up + await SandboxManager.reset() + + if (externalProxyServer) { + await new Promise(resolve => { + externalProxyServer!.close(() => { + console.log('External proxy server closed') + resolve() + }) }) - }) + } } - } - }) + }, + ) }) }) diff --git a/test/helpers/platform.ts b/test/helpers/platform.ts new file mode 100644 index 00000000..74e03c35 --- /dev/null +++ b/test/helpers/platform.ts @@ -0,0 +1,7 @@ +import { getPlatform } from '../../src/utils/platform.js' + +const platform = getPlatform() + +export const isLinux = platform === 'linux' +export const isMacOS = platform === 'macos' +export const isSupportedPlatform = isLinux || isMacOS diff --git a/test/sandbox/allow-read.test.ts b/test/sandbox/allow-read.test.ts index 55ea6e5b..db05568d 100644 --- a/test/sandbox/allow-read.test.ts +++ b/test/sandbox/allow-read.test.ts @@ -3,18 +3,10 @@ import { spawnSync } from 'node:child_process' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' import type { FsReadRestrictionConfig } from '../../src/sandbox/sandbox-schemas.js' - -function skipIfNotMacOS(): boolean { - return getPlatform() !== 'macos' -} - -function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' -} +import { isLinux, isMacOS, isSupportedPlatform } from '../helpers/platform.js' /** * Tests for the allowRead (allowWithinDeny) feature. @@ -33,9 +25,7 @@ describe('allowRead precedence over denyRead', () => { const TEST_ALLOWED_CONTENT = 'VISIBLE_DATA' beforeAll(() => { - if (getPlatform() !== 'macos' && getPlatform() !== 'linux') { - return - } + if (!isSupportedPlatform) return mkdirSync(TEST_ALLOWED_SUBDIR, { recursive: true }) writeFileSync(TEST_SECRET_FILE, TEST_SECRET_CONTENT) @@ -49,11 +39,7 @@ describe('allowRead precedence over denyRead', () => { }) describe('macOS Seatbelt', () => { - it('should deny reading a file in a denied directory', () => { - if (skipIfNotMacOS()) { - return - } - + it.if(isMacOS)('should deny reading a file in a denied directory', () => { const readConfig: FsReadRestrictionConfig = { denyOnly: [TEST_DENIED_DIR], allowWithinDeny: [], @@ -76,142 +62,137 @@ describe('allowRead precedence over denyRead', () => { expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) }) - it('should allow reading a file in an allowWithinDeny subdirectory', () => { - if (skipIfNotMacOS()) { - return - } - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [TEST_ALLOWED_SUBDIR], - } - - const wrappedCommand = wrapCommandWithSandboxMacOS({ - command: `cat ${TEST_ALLOWED_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) - }) - - it('should still deny reading files outside the re-allowed subdirectory', () => { - if (skipIfNotMacOS()) { - return - } - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [TEST_ALLOWED_SUBDIR], - } - - const wrappedCommand = wrapCommandWithSandboxMacOS({ - command: `cat ${TEST_SECRET_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + it.if(isMacOS)( + 'should allow reading a file in an allowWithinDeny subdirectory', + () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [TEST_ALLOWED_SUBDIR], + } + + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: `cat ${TEST_ALLOWED_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) + }, + ) - expect(result.status).not.toBe(0) - expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) - }) + it.if(isMacOS)( + 'should still deny reading files outside the re-allowed subdirectory', + () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [TEST_ALLOWED_SUBDIR], + } + + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: `cat ${TEST_SECRET_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) + }, + ) }) describe('Linux bwrap', () => { - it('should deny reading a file in a denied directory', async () => { - if (skipIfNotLinux()) { - return - } - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [], - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${TEST_SECRET_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - - expect(result.status).not.toBe(0) - expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) - }) - - it('should allow reading a file in an allowWithinDeny subdirectory', async () => { - if (skipIfNotLinux()) { - return - } - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [TEST_ALLOWED_SUBDIR], - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${TEST_ALLOWED_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) - }) - - it('should still deny reading files outside the re-allowed subdirectory', async () => { - if (skipIfNotLinux()) { - return - } - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [TEST_ALLOWED_SUBDIR], - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${TEST_SECRET_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) + it.if(isLinux)( + 'should deny reading a file in a denied directory', + async () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [], + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${TEST_SECRET_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) + }, + ) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + it.if(isLinux)( + 'should allow reading a file in an allowWithinDeny subdirectory', + async () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [TEST_ALLOWED_SUBDIR], + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${TEST_ALLOWED_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) + }, + ) - expect(result.status).not.toBe(0) - expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) - }) + it.if(isLinux)( + 'should still deny reading files outside the re-allowed subdirectory', + async () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [TEST_ALLOWED_SUBDIR], + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${TEST_SECRET_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) + }, + ) // Regression: the write-path skip check in the allowRead re-bind loop was // too broad — it skipped any allowPath under ANY allowWrite, not just @@ -219,33 +200,32 @@ describe('allowRead precedence over denyRead', () => { // ancestor of denyRead (not wiped, not re-bound), allowRead under it was // skipped and left sitting in the empty tmpfs. // Shape: allowWrite: [~], denyRead: [~/.ssh], allowRead: [~/.ssh/known_hosts]. - it('should re-allow under denyRead when allowWrite is an ancestor of the deny', async () => { - if (skipIfNotLinux()) { - return - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${TEST_ALLOWED_FILE}`, - needsNetworkRestriction: false, - readConfig: { - denyOnly: [TEST_DENIED_DIR], - allowWithinDeny: [TEST_ALLOWED_SUBDIR], - }, - writeConfig: { - allowOnly: [TEST_BASE_DIR], // ancestor of denyRead - denyWithinAllow: [], - }, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) - }) + it.if(isLinux)( + 'should re-allow under denyRead when allowWrite is an ancestor of the deny', + async () => { + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${TEST_ALLOWED_FILE}`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: [TEST_DENIED_DIR], + allowWithinDeny: [TEST_ALLOWED_SUBDIR], + }, + writeConfig: { + allowOnly: [TEST_BASE_DIR], // ancestor of denyRead + denyWithinAllow: [], + }, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(TEST_ALLOWED_CONTENT) + }, + ) }) }) @@ -281,9 +261,7 @@ describe('allowRead carve-out with denyRead at filesystem root (issue #10)', () ] beforeAll(() => { - if (getPlatform() !== 'macos' && getPlatform() !== 'linux') { - return - } + if (!isSupportedPlatform) return mkdirSync(TEST_DIR, { recursive: true }) writeFileSync(TEST_FILE, TEST_CONTENT) }) @@ -294,21 +272,20 @@ describe('allowRead carve-out with denyRead at filesystem root (issue #10)', () } }) - it('macOS: re-allows carve-out under a root-level deny', () => { - if (skipIfNotMacOS()) { - return - } - + it.if(isMacOS)('macOS: re-allows carve-out under a root-level deny', () => { const readConfig: FsReadRestrictionConfig = { denyOnly: ['/'], allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], } + // EXEC_DEPS covers /bin and /usr but not /opt/homebrew — pin the shell + // so denying the filesystem root doesn't break execvp on Homebrew-bash Macs. const wrappedCommand = wrapCommandWithSandboxMacOS({ command: `cat ${TEST_FILE}`, needsNetworkRestriction: false, readConfig, writeConfig: undefined, + binShell: '/bin/bash', }) const result = spawnSync(wrappedCommand, { @@ -321,123 +298,120 @@ describe('allowRead carve-out with denyRead at filesystem root (issue #10)', () expect(result.stdout).toContain(TEST_CONTENT) }) - it('macOS: still denies paths outside the carve-out under a root-level deny', () => { - if (skipIfNotMacOS()) { - return - } - - const outside = join(homedir(), '.bashrc') - const readConfig: FsReadRestrictionConfig = { - denyOnly: ['/'], - allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], - } - - const wrappedCommand = wrapCommandWithSandboxMacOS({ - command: `cat ${outside} 2>/dev/null; true`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - - // Process must exec (no SIGABRT) and stdout must be empty (cat denied) - expect(result.status).toBe(0) - expect(result.stdout).toBe('') - }) + it.if(isMacOS)( + 'macOS: still denies paths outside the carve-out under a root-level deny', + () => { + const outside = join(homedir(), '.bashrc') + const readConfig: FsReadRestrictionConfig = { + denyOnly: ['/'], + allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], + } - it('Linux: re-allows carve-out under a root-level deny', async () => { - if (skipIfNotLinux()) { - return - } + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: `cat ${outside} 2>/dev/null; true`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + binShell: '/bin/bash', + }) - const readConfig: FsReadRestrictionConfig = { - denyOnly: ['/'], - allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], - } + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - // allowAllUnixSockets: true bypasses the seccomp path — otherwise the - // apply-seccomp binary under /vendor/ is hidden by the root deny. - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${TEST_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - allowAllUnixSockets: true, - }) + // Process must exec (no SIGABRT) and stdout must be empty (cat denied) + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + }, + ) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + it.if(isLinux)( + 'Linux: re-allows carve-out under a root-level deny', + async () => { + const readConfig: FsReadRestrictionConfig = { + denyOnly: ['/'], + allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], + } - expect(result.status).toBe(0) - expect(result.stdout).toContain(TEST_CONTENT) - }) + // allowAllUnixSockets: true bypasses the seccomp path — otherwise the + // apply-seccomp binary under /vendor/ is hidden by the root deny. + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${TEST_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + allowAllUnixSockets: true, + }) - it('Linux: still denies paths outside the carve-out under a root-level deny', async () => { - if (skipIfNotLinux()) { - return - } + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - const outside = join(homedir(), '.bashrc') - const readConfig: FsReadRestrictionConfig = { - denyOnly: ['/'], - allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], - } + expect(result.status).toBe(0) + expect(result.stdout).toContain(TEST_CONTENT) + }, + ) - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `cat ${outside} 2>/dev/null; true`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - allowAllUnixSockets: true, - }) + it.if(isLinux)( + 'Linux: still denies paths outside the carve-out under a root-level deny', + async () => { + const outside = join(homedir(), '.bashrc') + const readConfig: FsReadRestrictionConfig = { + denyOnly: ['/'], + allowWithinDeny: [TEST_DIR, ...EXEC_DEPS], + } - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `cat ${outside} 2>/dev/null; true`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + allowAllUnixSockets: true, + }) - expect(result.status).toBe(0) - expect(result.stdout).toBe('') - }) + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - it('Linux: preserves write binds when denyRead ancestor wipes them', async () => { - if (skipIfNotLinux()) { - return - } + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + }, + ) - const writeTarget = join(TEST_DIR, 'written.txt') - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: `echo WRITE_OK > ${writeTarget} && cat ${writeTarget}`, - needsNetworkRestriction: false, - readConfig: { - denyOnly: ['/'], - allowWithinDeny: [...EXEC_DEPS], - }, - writeConfig: { - allowOnly: [TEST_DIR], - denyWithinAllow: [], - }, - allowAllUnixSockets: true, - }) + it.if(isLinux)( + 'Linux: preserves write binds when denyRead ancestor wipes them', + async () => { + const writeTarget = join(TEST_DIR, 'written.txt') + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: `echo WRITE_OK > ${writeTarget} && cat ${writeTarget}`, + needsNetworkRestriction: false, + readConfig: { + denyOnly: ['/'], + allowWithinDeny: [...EXEC_DEPS], + }, + writeConfig: { + allowOnly: [TEST_DIR], + denyWithinAllow: [], + }, + allowAllUnixSockets: true, + }) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - expect(result.status).toBe(0) - expect(result.stdout).toContain('WRITE_OK') - }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('WRITE_OK') + }, + ) }) /** @@ -446,33 +420,31 @@ describe('allowRead carve-out with denyRead at filesystem root (issue #10)', () describe('allowRead without denyRead does not trigger sandboxing', () => { const command = 'echo hello' - it('returns command unchanged on macOS when only allowWithinDeny is set', () => { - if (skipIfNotMacOS()) { - return - } - - const result = wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [], allowWithinDeny: ['/some/path'] }, - writeConfig: undefined, - }) - - expect(result).toBe(command) - }) + it.if(isMacOS)( + 'returns command unchanged on macOS when only allowWithinDeny is set', + () => { + const result = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [], allowWithinDeny: ['/some/path'] }, + writeConfig: undefined, + }) - it('returns command unchanged on Linux when only allowWithinDeny is set', async () => { - if (skipIfNotLinux()) { - return - } + expect(result).toBe(command) + }, + ) - const result = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [], allowWithinDeny: ['/some/path'] }, - writeConfig: undefined, - }) + it.if(isLinux)( + 'returns command unchanged on Linux when only allowWithinDeny is set', + async () => { + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [], allowWithinDeny: ['/some/path'] }, + writeConfig: undefined, + }) - expect(result).toBe(command) - }) + expect(result).toBe(command) + }, + ) }) diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 0d7c8601..cf2c6f0e 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -12,7 +12,7 @@ import { expandGlobPattern, globToRegex, } from '../../src/sandbox/sandbox-utils.js' -import { getPlatform } from '../../src/utils/platform.js' +import { isLinux } from '../helpers/platform.js' import { spawnSync } from 'node:child_process' /** @@ -189,7 +189,7 @@ describe('globToRegex (shared)', () => { // Tests for getFsReadConfig with glob expansion on Linux // ============================================================================ -describe('getFsReadConfig with glob patterns on Linux', () => { +describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { const RAW_BASE_DIR = join(tmpdir(), 'fsread-glob-test-' + Date.now()) const RAW_TEST_DIR = join(RAW_BASE_DIR, 'testdir') @@ -207,10 +207,6 @@ describe('getFsReadConfig with glob patterns on Linux', () => { }) it('should expand glob denyRead patterns to concrete paths on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -244,10 +240,6 @@ describe('getFsReadConfig with glob patterns on Linux', () => { }) it('should pass non-glob paths through unchanged on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -275,10 +267,6 @@ describe('getFsReadConfig with glob patterns on Linux', () => { }) it('should handle trailing /** by stripping suffix (existing behavior)', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -312,12 +300,8 @@ describe('getFsReadConfig with glob patterns on Linux', () => { // Tests for getLinuxGlobPatternWarnings // ============================================================================ -describe('getLinuxGlobPatternWarnings after fix', () => { +describe.if(isLinux)('getLinuxGlobPatternWarnings after fix', () => { it('should NOT warn about denyRead globs on Linux (they are now expanded)', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -345,10 +329,6 @@ describe('getLinuxGlobPatternWarnings after fix', () => { }) it('should still warn about allowWrite and denyWrite globs on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -380,7 +360,7 @@ describe('getLinuxGlobPatternWarnings after fix', () => { // Integration test: denyRead with glob patterns on Linux via sandbox // ============================================================================ -describe('denyRead with glob patterns - Linux integration', () => { +describe.if(isLinux)('denyRead with glob patterns - Linux integration', () => { const RAW_BASE_DIR = join(tmpdir(), 'glob-deny-integ-' + Date.now()) const RAW_TEST_DIR = join(RAW_BASE_DIR, 'testdir') let TEST_DIR: string @@ -400,10 +380,6 @@ describe('denyRead with glob patterns - Linux integration', () => { }) it('should block reading files matching *.env glob pattern via sandbox', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -439,10 +415,6 @@ describe('denyRead with glob patterns - Linux integration', () => { }) it('should allow reading files NOT matching glob pattern via sandbox', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -478,10 +450,6 @@ describe('denyRead with glob patterns - Linux integration', () => { }) it('should block reading with literal path (regression test)', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) @@ -516,10 +484,6 @@ describe('denyRead with glob patterns - Linux integration', () => { }) it('should block reading with ** recursive glob via sandbox', async () => { - if (getPlatform() !== 'linux') { - return - } - // Create a nested file mkdirSync(join(RAW_TEST_DIR, 'nested'), { recursive: true }) writeFileSync(join(RAW_TEST_DIR, 'nested', 'deep.env'), 'DEEP_SECRET') @@ -561,7 +525,7 @@ describe('denyRead with glob patterns - Linux integration', () => { // Tests for wrapWithSandbox with glob denyRead via customConfig // ============================================================================ -describe('wrapWithSandbox with glob denyRead customConfig', () => { +describe.if(isLinux)('wrapWithSandbox with glob denyRead customConfig', () => { const RAW_BASE_DIR = join(tmpdir(), 'wrap-sandbox-glob-test-' + Date.now()) const RAW_TEST_DIR = join(RAW_BASE_DIR, 'testdir') let TEST_DIR: string @@ -580,10 +544,6 @@ describe('wrapWithSandbox with glob denyRead customConfig', () => { }) it('should expand glob denyRead in customConfig on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } - const { SandboxManager } = await import( '../../src/sandbox/sandbox-manager.js' ) diff --git a/test/sandbox/integration.test.ts b/test/sandbox/integration.test.ts index a5154e9b..798f0e2c 100644 --- a/test/sandbox/integration.test.ts +++ b/test/sandbox/integration.test.ts @@ -10,7 +10,7 @@ import { import type { Server } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getPlatform } from '../../src/utils/platform.js' +import { isLinux } from '../helpers/platform.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' import { generateSeccompFilter } from '../../src/sandbox/generate-seccomp-filter.js' @@ -32,10 +32,6 @@ function createTestConfig(testDir: string): SandboxRuntimeConfig { } } -function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' -} - // ============================================================================ // Helper Function // ============================================================================ @@ -57,17 +53,13 @@ function assertPrecompiledBpfInUse(): void { // Main Test Suite // ============================================================================ -describe('Sandbox Integration Tests', () => { +describe.if(isLinux)('Sandbox Integration Tests', () => { const TEST_SOCKET_PATH = '/tmp/claude-test.sock' // Use a directory within the repository (which is the CWD) const TEST_DIR = join(process.cwd(), '.sandbox-test-tmp') let socketServer: Server | null = null beforeAll(async () => { - if (skipIfNotLinux()) { - return - } - // Create test directory if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }) @@ -103,10 +95,6 @@ describe('Sandbox Integration Tests', () => { }) afterAll(async () => { - if (skipIfNotLinux()) { - return - } - // Clean up socket server if (socketServer) { socketServer.close() @@ -132,20 +120,12 @@ describe('Sandbox Integration Tests', () => { describe('With Pre-compiled BPF', () => { beforeAll(() => { - if (skipIfNotLinux()) { - return - } - console.log('\n=== Testing with Pre-compiled BPF ===') assertPrecompiledBpfInUse() }) describe('Unix Socket Restrictions', () => { it('should block Unix socket connections with seccomp', async () => { - if (skipIfNotLinux()) { - return - } - // Wrap command with sandbox const command = await SandboxManager.wrapWithSandbox( `echo "Test message" | nc -U ${TEST_SOCKET_PATH}`, @@ -170,10 +150,6 @@ describe('Sandbox Integration Tests', () => { describe('Network Restrictions', () => { it('should block HTTP requests to non-allowlisted domains', async () => { - if (skipIfNotLinux()) { - return - } - const command = await SandboxManager.wrapWithSandbox( 'curl -s http://blocked-domain.example', ) @@ -189,10 +165,6 @@ describe('Sandbox Integration Tests', () => { }) it('should block HTTP requests to anthropic.com (not in allowlist)', async () => { - if (skipIfNotLinux()) { - return - } - // Use --max-time to timeout quickly, and --show-error to see proxy errors const command = await SandboxManager.wrapWithSandbox( 'curl -s --show-error --max-time 2 https://www.anthropic.com', @@ -217,10 +189,6 @@ describe('Sandbox Integration Tests', () => { }) it('should allow HTTP requests to allowlisted domains', async () => { - if (skipIfNotLinux()) { - return - } - // Note: example.com should be in the allowlist via .claude/settings.json const command = await SandboxManager.wrapWithSandbox( 'curl -s http://example.com', @@ -241,10 +209,6 @@ describe('Sandbox Integration Tests', () => { describe('Filesystem Restrictions', () => { it('should block writes outside current working directory', async () => { - if (skipIfNotLinux()) { - return - } - const testFile = join(tmpdir(), 'sandbox-blocked-write.txt') // Clean up if exists @@ -270,10 +234,6 @@ describe('Sandbox Integration Tests', () => { }) it('should allow writes within current working directory', async () => { - if (skipIfNotLinux()) { - return - } - // Ensure test directory exists if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }) @@ -323,10 +283,6 @@ describe('Sandbox Integration Tests', () => { }) it('should allow reads from anywhere', async () => { - if (skipIfNotLinux()) { - return - } - // Try reading from home directory const command = await SandboxManager.wrapWithSandbox( 'head -n 5 ~/.bashrc', @@ -348,10 +304,6 @@ describe('Sandbox Integration Tests', () => { }) it('should allow writes in seccomp-only mode (no network restrictions)', async () => { - if (skipIfNotLinux()) { - return - } - // Import wrapCommandWithSandboxLinux to call directly const { wrapCommandWithSandboxLinux } = await import( '../../src/sandbox/linux-sandbox-utils.js' @@ -404,10 +356,6 @@ describe('Sandbox Integration Tests', () => { describe('Command Execution', () => { it('should execute basic commands successfully', async () => { - if (skipIfNotLinux()) { - return - } - const command = await SandboxManager.wrapWithSandbox( 'echo "Hello from sandbox"', ) @@ -423,10 +371,6 @@ describe('Sandbox Integration Tests', () => { }) it('should handle complex command pipelines', async () => { - if (skipIfNotLinux()) { - return - } - const command = await SandboxManager.wrapWithSandbox( 'echo "line1\nline2\nline3" | grep line2', ) @@ -445,10 +389,6 @@ describe('Sandbox Integration Tests', () => { describe('Shell Selection (binShell parameter)', () => { it('should execute commands with zsh when binShell is specified', async () => { - if (skipIfNotLinux()) { - return - } - // Check if zsh is available const zshCheck = spawnSync('which zsh', { shell: true, @@ -477,10 +417,6 @@ describe('Sandbox Integration Tests', () => { }) it('should use zsh syntax successfully with binShell=zsh', async () => { - if (skipIfNotLinux()) { - return - } - // Check if zsh is available const zshCheck = spawnSync('which zsh', { shell: true, @@ -508,10 +444,6 @@ describe('Sandbox Integration Tests', () => { }) it('should default to bash when binShell is not specified', async () => { - if (skipIfNotLinux()) { - return - } - // Check for bash-specific variable const command = await SandboxManager.wrapWithSandbox( 'echo "Shell: $BASH_VERSION"', @@ -531,10 +463,6 @@ describe('Sandbox Integration Tests', () => { describe('Security Boundaries', () => { it('should isolate PID namespace - sandboxed processes cannot see host PIDs', async () => { - if (skipIfNotLinux()) { - return - } - // Use /proc to check PID namespace isolation // Inside sandbox, should only see sandbox PIDs in /proc const command = await SandboxManager.wrapWithSandbox( @@ -556,10 +484,6 @@ describe('Sandbox Integration Tests', () => { }) it('should prevent symlink-based filesystem escape attempts', async () => { - if (skipIfNotLinux()) { - return - } - // Note: Reads are allowed from anywhere, so test WRITE escape attempt const linkInAllowed = join(TEST_DIR, 'escape-link-write') const targetOutside = '/tmp/escape-test-' + Date.now() + '.txt' @@ -594,10 +518,6 @@ describe('Sandbox Integration Tests', () => { }) it('should terminate background processes when sandbox exits', async () => { - if (skipIfNotLinux()) { - return - } - // Create a unique marker file that a background process will touch const markerFile = join(TEST_DIR, 'background-process-marker.txt') @@ -640,10 +560,6 @@ describe('Sandbox Integration Tests', () => { }) it('should kill child processes when sandbox is terminated via SIGTERM (--die-with-parent)', async () => { - if (skipIfNotLinux()) { - return - } - // This test verifies the --die-with-parent flag is working. // Without it, child processes would continue running after timeout kills bwrap. const markerFile = join(TEST_DIR, 'sigterm-test-marker.txt') @@ -688,10 +604,6 @@ describe('Sandbox Integration Tests', () => { }) it('should not leave orphan processes after timeout kills sandbox', async () => { - if (skipIfNotLinux()) { - return - } - // Create a unique marker that only our test process would have const uniqueMarker = `sandbox-orphan-test-${Date.now()}` const markerFile = join(TEST_DIR, 'orphan-test.txt') @@ -736,10 +648,6 @@ describe('Sandbox Integration Tests', () => { }) it('should prevent privilege escalation attempts', async () => { - if (skipIfNotLinux()) { - return - } - // Test 1: Setuid binaries cannot actually elevate privileges // Note: The setuid bit CAN be set on files in writable directories, // but bwrap ensures it doesn't grant actual privilege escalation @@ -790,10 +698,6 @@ describe('Sandbox Integration Tests', () => { }) it('should enforce network restrictions across protocols and ports', async () => { - if (skipIfNotLinux()) { - return - } - // Test 1: HTTPS to blocked domain (not just HTTP) const command1 = await SandboxManager.wrapWithSandbox( 'curl -s --show-error --max-time 2 --connect-timeout 2 https://blocked-domain.example 2>&1 || echo "curl_failed"', @@ -869,10 +773,6 @@ describe('Sandbox Integration Tests', () => { }) it('should enforce wildcard domain pattern matching correctly', async () => { - if (skipIfNotLinux()) { - return - } - // Reset and reinitialize with wildcard pattern await SandboxManager.reset() await SandboxManager.initialize({ @@ -953,10 +853,6 @@ describe('Sandbox Integration Tests', () => { }) it('should prevent creation of special file types that could bypass restrictions', async () => { - if (skipIfNotLinux()) { - return - } - const fifoPath = join(TEST_DIR, 'test.fifo') const regularFile = join(TEST_DIR, 'regular.txt') const hardlinkPath = join(TEST_DIR, 'hardlink.txt') @@ -1055,331 +951,282 @@ describe('Sandbox Integration Tests', () => { * * The bug caused empty allowedDomains to allow ALL network access instead. */ -describe('Empty allowedDomains Network Blocking Integration', () => { - const TEST_DIR = join(process.cwd(), '.sandbox-test-empty-domains') - - beforeAll(async () => { - if (skipIfNotLinux()) { - return - } - - // Create test directory - if (!existsSync(TEST_DIR)) { - mkdirSync(TEST_DIR, { recursive: true }) - } - }) +describe.if(isLinux)( + 'Empty allowedDomains Network Blocking Integration', + () => { + const TEST_DIR = join(process.cwd(), '.sandbox-test-empty-domains') - afterAll(async () => { - if (skipIfNotLinux()) { - return - } - - // Clean up test directory - if (existsSync(TEST_DIR)) { - rmSync(TEST_DIR, { recursive: true, force: true }) - } - - await SandboxManager.reset() - }) - - describe('Network blocked with empty allowedDomains', () => { beforeAll(async () => { - if (skipIfNotLinux()) { - return + // Create test directory + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }) } - - // Initialize with empty allowedDomains - should block ALL network - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { - allowedDomains: [], // Empty = block all network (documented behavior) - deniedDomains: [], - }, - filesystem: { - denyRead: [], - allowWrite: [TEST_DIR], - denyWrite: [], - }, - }) }) - it('should block all HTTP requests when allowedDomains is empty', async () => { - if (skipIfNotLinux()) { - return + afterAll(async () => { + // Clean up test directory + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) } - // Try to access example.com - should be blocked - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 2 --connect-timeout 2 http://example.com 2>&1 || echo "network_failed"', - ) + await SandboxManager.reset() + }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, + describe('Network blocked with empty allowedDomains', () => { + beforeAll(async () => { + // Initialize with empty allowedDomains - should block ALL network + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: [], // Empty = block all network (documented behavior) + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) }) - // With empty allowedDomains, network should be completely blocked - // curl should fail with network-related error - const output = (result.stdout + result.stderr).toLowerCase() - - // Network should fail - either connection error, timeout, proxy rejection, or "network_failed" echo - const networkBlocked = - output.includes('network_failed') || - output.includes("couldn't connect") || - output.includes('connection refused') || - output.includes('network is unreachable') || - output.includes('name or service not known') || - output.includes('timed out') || - output.includes('connection timed out') || - output.includes('blocked by') || - result.status !== 0 - - expect(networkBlocked).toBe(true) - - // Should NOT contain successful HTML response - expect(output).not.toContain('example domain') - expect(output).not.toContain(' { - if (skipIfNotLinux()) { - return - } + it('should block all HTTP requests when allowedDomains is empty', async () => { + // Try to access example.com - should be blocked + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 --connect-timeout 2 http://example.com 2>&1 || echo "network_failed"', + ) - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 2 --connect-timeout 2 https://example.com 2>&1 || echo "network_failed"', - ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, + // With empty allowedDomains, network should be completely blocked + // curl should fail with network-related error + const output = (result.stdout + result.stderr).toLowerCase() + + // Network should fail - either connection error, timeout, proxy rejection, or "network_failed" echo + const networkBlocked = + output.includes('network_failed') || + output.includes("couldn't connect") || + output.includes('connection refused') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('timed out') || + output.includes('connection timed out') || + output.includes('blocked by') || + result.status !== 0 + + expect(networkBlocked).toBe(true) + + // Should NOT contain successful HTML response + expect(output).not.toContain('example domain') + expect(output).not.toContain(' { + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 --connect-timeout 2 https://example.com 2>&1 || echo "network_failed"', + ) - expect(networkBlocked).toBe(true) - }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - it('should block DNS lookups when allowedDomains is empty', async () => { - if (skipIfNotLinux()) { - return - } + const output = (result.stdout + result.stderr).toLowerCase() - // Try DNS lookup - should fail with no network - const command = await SandboxManager.wrapWithSandbox( - 'host example.com 2>&1 || nslookup example.com 2>&1 || echo "dns_failed"', - ) + // Network should fail + const networkBlocked = + output.includes('network_failed') || + output.includes("couldn't connect") || + output.includes('connection refused') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('timed out') || + output.includes('blocked by') || + result.status !== 0 - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, + expect(networkBlocked).toBe(true) }) - const output = (result.stdout + result.stderr).toLowerCase() - - // DNS should fail when network is blocked - const dnsBlocked = - output.includes('dns_failed') || - output.includes('connection timed out') || - output.includes('no servers could be reached') || - output.includes('network is unreachable') || - output.includes('name or service not known') || - output.includes('temporary failure') || - result.status !== 0 + it('should block DNS lookups when allowedDomains is empty', async () => { + // Try DNS lookup - should fail with no network + const command = await SandboxManager.wrapWithSandbox( + 'host example.com 2>&1 || nslookup example.com 2>&1 || echo "dns_failed"', + ) - expect(dnsBlocked).toBe(true) - }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - it('should block wget when allowedDomains is empty', async () => { - if (skipIfNotLinux()) { - return - } + const output = (result.stdout + result.stderr).toLowerCase() - const command = await SandboxManager.wrapWithSandbox( - 'wget -q --timeout=2 -O - http://example.com 2>&1 || echo "wget_failed"', - ) + // DNS should fail when network is blocked + const dnsBlocked = + output.includes('dns_failed') || + output.includes('connection timed out') || + output.includes('no servers could be reached') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('temporary failure') || + result.status !== 0 - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, + expect(dnsBlocked).toBe(true) }) - const output = (result.stdout + result.stderr).toLowerCase() - - // wget should fail - const wgetBlocked = - output.includes('wget_failed') || - output.includes('failed') || - output.includes('network is unreachable') || - output.includes('unable to resolve') || - result.status !== 0 - - expect(wgetBlocked).toBe(true) - }) + it('should block wget when allowedDomains is empty', async () => { + const command = await SandboxManager.wrapWithSandbox( + 'wget -q --timeout=2 -O - http://example.com 2>&1 || echo "wget_failed"', + ) - it('should allow local filesystem operations when network is blocked', async () => { - if (skipIfNotLinux()) { - return - } + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - // Even with network blocked, filesystem should work - const testFile = join(TEST_DIR, 'network-blocked-test.txt') - const testContent = 'test content with network blocked' + const output = (result.stdout + result.stderr).toLowerCase() - const command = await SandboxManager.wrapWithSandbox( - `echo "${testContent}" > ${testFile} && cat ${testFile}`, - ) + // wget should fail + const wgetBlocked = + output.includes('wget_failed') || + output.includes('failed') || + output.includes('network is unreachable') || + output.includes('unable to resolve') || + result.status !== 0 - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - cwd: TEST_DIR, - timeout: 5000, + expect(wgetBlocked).toBe(true) }) - expect(result.status).toBe(0) - expect(result.stdout).toContain(testContent) + it('should allow local filesystem operations when network is blocked', async () => { + // Even with network blocked, filesystem should work + const testFile = join(TEST_DIR, 'network-blocked-test.txt') + const testContent = 'test content with network blocked' - // Cleanup - if (existsSync(testFile)) { - unlinkSync(testFile) - } - }) - }) + const command = await SandboxManager.wrapWithSandbox( + `echo "${testContent}" > ${testFile} && cat ${testFile}`, + ) - describe('Network allowed with specific domains', () => { - beforeAll(async () => { - if (skipIfNotLinux()) { - return - } + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + cwd: TEST_DIR, + timeout: 5000, + }) - // Reinitialize with specific domain allowed - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { - allowedDomains: ['example.com'], // Only example.com allowed - deniedDomains: [], - }, - filesystem: { - denyRead: [], - allowWrite: [TEST_DIR], - denyWrite: [], - }, + expect(result.status).toBe(0) + expect(result.stdout).toContain(testContent) + + // Cleanup + if (existsSync(testFile)) { + unlinkSync(testFile) + } }) }) - it('should allow HTTP to explicitly allowed domain', async () => { - if (skipIfNotLinux()) { - return - } - - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 5 http://example.com 2>&1', - ) - - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 10000, + describe('Network allowed with specific domains', () => { + beforeAll(async () => { + // Reinitialize with specific domain allowed + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: ['example.com'], // Only example.com allowed + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) }) - // Should succeed and return HTML - expect(result.status).toBe(0) - expect(result.stdout).toContain('Example Domain') - }) - - it('should block HTTP to non-allowed domain', async () => { - if (skipIfNotLinux()) { - return - } + it('should allow HTTP to explicitly allowed domain', async () => { + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com 2>&1', + ) - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 2 http://anthropic.com 2>&1', - ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, + // Should succeed and return HTML + expect(result.status).toBe(0) + expect(result.stdout).toContain('Example Domain') }) - const output = result.stdout.toLowerCase() - // Should be blocked by proxy - expect(output).toContain('blocked by network allowlist') - }) - }) + it('should block HTTP to non-allowed domain', async () => { + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 http://anthropic.com 2>&1', + ) - describe('Contrast: empty vs undefined network config', () => { - it('empty allowedDomains should block network', async () => { - if (skipIfNotLinux()) { - return - } + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { - allowedDomains: [], // Explicitly empty - deniedDomains: [], - }, - filesystem: { - denyRead: [], - allowWrite: [TEST_DIR], - denyWrite: [], - }, + const output = result.stdout.toLowerCase() + // Should be blocked by proxy + expect(output).toContain('blocked by network allowlist') }) + }) - const command = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 2 http://example.com 2>&1 || echo "blocked"', - ) + describe('Contrast: empty vs undefined network config', () => { + it('empty allowedDomains should block network', async () => { + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: [], // Explicitly empty + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) - const result = spawnSync(command, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 http://example.com 2>&1 || echo "blocked"', + ) - // Should be blocked - const output = (result.stdout + result.stderr).toLowerCase() - const isBlocked = - output.includes('blocked') || - output.includes("couldn't connect") || - output.includes('network is unreachable') || - result.status !== 0 + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + // Should be blocked + const output = (result.stdout + result.stderr).toLowerCase() + const isBlocked = + output.includes('blocked') || + output.includes("couldn't connect") || + output.includes('network is unreachable') || + result.status !== 0 - expect(isBlocked).toBe(true) - expect(output).not.toContain('example domain') + expect(isBlocked).toBe(true) + expect(output).not.toContain('example domain') + }) }) - }) -}) + }, +) // ============================================================================ // Git over SSH through proxy (GIT_SSH_COMMAND) // Regression test for https://github.com/anthropic-experimental/sandbox-runtime/issues/161 // ============================================================================ -describe('Git over SSH through sandbox proxy', () => { +describe.if(isLinux)('Git over SSH through sandbox proxy', () => { const TEST_DIR = join(process.cwd(), '.sandbox-git-ssh-test-tmp') beforeAll(async () => { - if (skipIfNotLinux()) { - return - } - if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }) } @@ -1399,9 +1246,6 @@ describe('Git over SSH through sandbox proxy', () => { }) afterAll(async () => { - if (skipIfNotLinux()) { - return - } if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }) } @@ -1409,10 +1253,6 @@ describe('Git over SSH through sandbox proxy', () => { }) it('should set GIT_SSH_COMMAND to route SSH through socat proxy on Linux', async () => { - if (skipIfNotLinux()) { - return - } - const command = await SandboxManager.wrapWithSandbox( 'echo "$GIT_SSH_COMMAND"', ) @@ -1433,10 +1273,6 @@ describe('Git over SSH through sandbox proxy', () => { }) it('should resolve DNS and connect when running git over SSH', async () => { - if (skipIfNotLinux()) { - return - } - // Use /dev/null as identity to force clean publickey rejection without // depending on whatever keys or ssh-agent happen to be present in CI. // -o StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null avoids known_hosts writes. diff --git a/test/sandbox/macos-allow-local-binding.test.ts b/test/sandbox/macos-allow-local-binding.test.ts index e01ab782..36c419fd 100644 --- a/test/sandbox/macos-allow-local-binding.test.ts +++ b/test/sandbox/macos-allow-local-binding.test.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from 'bun:test' import { spawnSync } from 'node:child_process' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' - -function skipIfNotMacOS(): boolean { - return getPlatform() !== 'macos' -} +import { isMacOS } from '../helpers/platform.js' function runInSandbox( pythonCode: string, @@ -36,11 +32,9 @@ const bindIPv4 = (addr: string) => const bindIPv6DualStack = (addr: string) => `import socket; s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0); s.bind(('${addr}', 0)); print('BOUND'); s.close()` -describe('macOS Seatbelt allowLocalBinding', () => { +describe.if(isMacOS)('macOS Seatbelt allowLocalBinding', () => { describe('when allowLocalBinding is true', () => { it('should allow AF_INET bind to 127.0.0.1', () => { - if (skipIfNotMacOS()) return - const result = runInSandbox(bindIPv4('127.0.0.1'), true) expect(result.status).toBe(0) @@ -48,8 +42,6 @@ describe('macOS Seatbelt allowLocalBinding', () => { }) it('should allow AF_INET6 dual-stack bind to ::ffff:127.0.0.1', () => { - if (skipIfNotMacOS()) return - // This is the case that breaks Java/Gradle: an IPv6 dual-stack socket // binding to 127.0.0.1, which the kernel represents as ::ffff:127.0.0.1 const result = runInSandbox(bindIPv6DualStack('::ffff:127.0.0.1'), true) @@ -59,8 +51,6 @@ describe('macOS Seatbelt allowLocalBinding', () => { }) it('should allow AF_INET6 bind to ::1', () => { - if (skipIfNotMacOS()) return - const result = runInSandbox(bindIPv6DualStack('::1'), true) expect(result.status).toBe(0) @@ -70,16 +60,12 @@ describe('macOS Seatbelt allowLocalBinding', () => { describe('when allowLocalBinding is false', () => { it('should block AF_INET bind to 127.0.0.1', () => { - if (skipIfNotMacOS()) return - const result = runInSandbox(bindIPv4('127.0.0.1'), false) expect(result.status).not.toBe(0) }) it('should block AF_INET6 dual-stack bind to ::ffff:127.0.0.1', () => { - if (skipIfNotMacOS()) return - const result = runInSandbox(bindIPv6DualStack('::ffff:127.0.0.1'), false) expect(result.status).not.toBe(0) diff --git a/test/sandbox/macos-pty.test.ts b/test/sandbox/macos-pty.test.ts index bca0928f..6e2fa302 100644 --- a/test/sandbox/macos-pty.test.ts +++ b/test/sandbox/macos-pty.test.ts @@ -3,38 +3,24 @@ import { spawnSync } from 'node:child_process' import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import type { FsWriteRestrictionConfig } from '../../src/sandbox/sandbox-schemas.js' +import { isMacOS } from '../helpers/platform.js' -function skipIfNotMacOS(): boolean { - return getPlatform() !== 'macos' -} - -describe('macOS Seatbelt PTY Support', () => { +describe.if(isMacOS)('macOS Seatbelt PTY Support', () => { const TEST_BASE_DIR = join(tmpdir(), 'seatbelt-pty-test-' + Date.now()) beforeAll(() => { - if (skipIfNotMacOS()) { - return - } mkdirSync(TEST_BASE_DIR, { recursive: true }) }) afterAll(() => { - if (skipIfNotMacOS()) { - return - } if (existsSync(TEST_BASE_DIR)) { rmSync(TEST_BASE_DIR, { recursive: true, force: true }) } }) it('should allow PTY operations when allowPty is true', () => { - if (skipIfNotMacOS()) { - return - } - const outputFile = join(TEST_BASE_DIR, 'pty-output.txt') const writeConfig: FsWriteRestrictionConfig = { @@ -63,10 +49,6 @@ describe('macOS Seatbelt PTY Support', () => { }) it('should block PTY operations when allowPty is false', () => { - if (skipIfNotMacOS()) { - return - } - const outputFile = join(TEST_BASE_DIR, 'pty-blocked.txt') const writeConfig: FsWriteRestrictionConfig = { diff --git a/test/sandbox/macos-seatbelt.test.ts b/test/sandbox/macos-seatbelt.test.ts index c5382026..73cb0ce3 100644 --- a/test/sandbox/macos-seatbelt.test.ts +++ b/test/sandbox/macos-seatbelt.test.ts @@ -9,8 +9,8 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { isMacOS } from '../helpers/platform.js' import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, @@ -30,11 +30,7 @@ import type { * These tests use the actual sandbox profile generation code to ensure real-world coverage. */ -function skipIfNotMacOS(): boolean { - return getPlatform() !== 'macos' -} - -describe('macOS Seatbelt Read Bypass Prevention', () => { +describe.if(isMacOS)('macOS Seatbelt Read Bypass Prevention', () => { const TEST_BASE_DIR = join(tmpdir(), 'seatbelt-test-' + Date.now()) const TEST_DENIED_DIR = join(TEST_BASE_DIR, 'denied-dir') const TEST_SECRET_FILE = join(TEST_DENIED_DIR, 'secret.txt') @@ -49,10 +45,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { const TEST_GLOB_MOVED = join(TEST_BASE_DIR, 'moved-glob.txt') beforeAll(() => { - if (skipIfNotMacOS()) { - return - } - // Create test directory structure mkdirSync(TEST_DENIED_DIR, { recursive: true }) writeFileSync(TEST_SECRET_FILE, TEST_SECRET_CONTENT) @@ -64,10 +56,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) afterAll(() => { - if (skipIfNotMacOS()) { - return - } - // Clean up test directory if (existsSync(TEST_BASE_DIR)) { rmSync(TEST_BASE_DIR, { recursive: true, force: true }) @@ -76,10 +64,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { describe('Literal Path - Direct File Move Prevention', () => { it('should block moving a read-denied file to a readable location', () => { - if (skipIfNotMacOS()) { - return - } - // Use actual read restriction config with literal path const readConfig: FsReadRestrictionConfig = { denyOnly: [TEST_DENIED_DIR], @@ -114,10 +98,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) it('should still block reading the file (sanity check)', () => { - if (skipIfNotMacOS()) { - return - } - // Use actual read restriction config const readConfig: FsReadRestrictionConfig = { denyOnly: [TEST_DENIED_DIR], @@ -150,10 +130,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { describe('Literal Path - Ancestor Directory Move Prevention', () => { it('should block moving an ancestor directory of a read-denied file', () => { - if (skipIfNotMacOS()) { - return - } - // Use actual read restriction config const readConfig: FsReadRestrictionConfig = { denyOnly: [TEST_DENIED_DIR], @@ -188,10 +164,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) it('should block moving the grandparent directory', () => { - if (skipIfNotMacOS()) { - return - } - // Deny reading a specific file deep in the hierarchy const readConfig: FsReadRestrictionConfig = { denyOnly: [TEST_SECRET_FILE], @@ -227,10 +199,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { describe('Glob Pattern - File Move Prevention', () => { it('should block moving files matching a glob pattern (*.txt)', () => { - if (skipIfNotMacOS()) { - return - } - // Use glob pattern that matches all .txt files in glob-test directory const globPattern = join(TEST_GLOB_DIR, '*.txt') @@ -267,10 +235,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) it('should still block reading files matching the glob pattern', () => { - if (skipIfNotMacOS()) { - return - } - // Use glob pattern const globPattern = join(TEST_GLOB_DIR, '*.txt') @@ -303,10 +267,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) it('should block moving the parent directory containing glob-matched files', () => { - if (skipIfNotMacOS()) { - return - } - // Use glob pattern const globPattern = join(TEST_GLOB_DIR, '*.txt') @@ -344,10 +304,6 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { describe('Glob Pattern - Recursive Patterns', () => { it('should block moving files matching a recursive glob pattern (**/*.txt)', () => { - if (skipIfNotMacOS()) { - return - } - // Create nested directory structure const nestedDir = join(TEST_GLOB_DIR, 'nested') const nestedFile = join(nestedDir, 'nested-secret.txt') @@ -390,7 +346,7 @@ describe('macOS Seatbelt Read Bypass Prevention', () => { }) }) -describe('macOS Seatbelt Write Bypass Prevention', () => { +describe.if(isMacOS)('macOS Seatbelt Write Bypass Prevention', () => { const TEST_BASE_DIR = join(tmpdir(), 'seatbelt-write-test-' + Date.now()) const TEST_ALLOWED_DIR = join(TEST_BASE_DIR, 'allowed') const TEST_DENIED_DIR = join(TEST_ALLOWED_DIR, 'secrets') @@ -408,10 +364,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { const TEST_GLOB_RENAMED = join(TEST_BASE_DIR, 'renamed-glob') beforeAll(() => { - if (skipIfNotMacOS()) { - return - } - // Create test directory structure mkdirSync(TEST_DENIED_DIR, { recursive: true }) mkdirSync(TEST_GLOB_DIR, { recursive: true }) @@ -423,10 +375,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) afterAll(() => { - if (skipIfNotMacOS()) { - return - } - // Clean up test directory if (existsSync(TEST_BASE_DIR)) { rmSync(TEST_BASE_DIR, { recursive: true, force: true }) @@ -435,10 +383,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { describe('Literal Path - Direct Directory Move Prevention', () => { it('should block write bypass via directory rename (mv a c, write c/b, mv c a)', () => { - if (skipIfNotMacOS()) { - return - } - // Allow writing to TEST_ALLOWED_DIR but deny TEST_DENIED_DIR const writeConfig: FsWriteRestrictionConfig = { allowOnly: [TEST_ALLOWED_DIR], @@ -470,10 +414,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) it('should still block direct writes to denied paths (sanity check)', () => { - if (skipIfNotMacOS()) { - return - } - const writeConfig: FsWriteRestrictionConfig = { allowOnly: [TEST_ALLOWED_DIR], denyWithinAllow: [TEST_DENIED_DIR], @@ -506,10 +446,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { describe('Literal Path - Ancestor Directory Move Prevention', () => { it('should block moving an ancestor directory of a write-denied path', () => { - if (skipIfNotMacOS()) { - return - } - const writeConfig: FsWriteRestrictionConfig = { allowOnly: [TEST_ALLOWED_DIR], denyWithinAllow: [TEST_DENIED_FILE], @@ -542,10 +478,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) it('should block moving the grandparent directory', () => { - if (skipIfNotMacOS()) { - return - } - const writeConfig: FsWriteRestrictionConfig = { allowOnly: [TEST_ALLOWED_DIR], denyWithinAllow: [TEST_DENIED_FILE], @@ -580,10 +512,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { describe('Glob Pattern - File Move Prevention', () => { it('should block write bypass via moving glob-matched files', () => { - if (skipIfNotMacOS()) { - return - } - // Allow writing to TEST_ALLOWED_DIR but deny *.txt files in glob-test const globPattern = join(TEST_GLOB_DIR, '*.txt') @@ -616,10 +544,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) it('should still block direct writes to glob-matched files', () => { - if (skipIfNotMacOS()) { - return - } - const globPattern = join(TEST_GLOB_DIR, '*.txt') const writeConfig: FsWriteRestrictionConfig = { @@ -652,10 +576,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) it('should block moving the parent directory containing glob-matched files', () => { - if (skipIfNotMacOS()) { - return - } - const globPattern = join(TEST_GLOB_DIR, '*.txt') const writeConfig: FsWriteRestrictionConfig = { @@ -690,10 +610,6 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { describe('Glob Pattern - Recursive Patterns', () => { it('should block moving files matching a recursive glob pattern (**/*.txt)', () => { - if (skipIfNotMacOS()) { - return - } - // Create nested directory structure const nestedDir = join(TEST_GLOB_DIR, 'nested') const nestedFile = join(nestedDir, 'nested-secret.txt') @@ -750,33 +666,23 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { * and (allow network-bind/network-outbound (local/remote unix-socket ...)) for * bind/connect operations. */ -describe('macOS Seatbelt Unix Domain Socket Support', () => { +describe.if(isMacOS)('macOS Seatbelt Unix Domain Socket Support', () => { const TEST_BASE_DIR = join( tmpdir(), 'seatbelt-unix-socket-test-' + Date.now(), ) beforeAll(() => { - if (skipIfNotMacOS()) { - return - } mkdirSync(TEST_BASE_DIR, { recursive: true }) }) afterAll(() => { - if (skipIfNotMacOS()) { - return - } if (existsSync(TEST_BASE_DIR)) { rmSync(TEST_BASE_DIR, { recursive: true, force: true }) } }) it('should allow Unix domain socket creation and communication with allowAllUnixSockets', () => { - if (skipIfNotMacOS()) { - return - } - const socketPath = join(TEST_BASE_DIR, 'test.sock') const scriptPath = join(TEST_BASE_DIR, 'test_socket.py') @@ -828,10 +734,6 @@ describe('macOS Seatbelt Unix Domain Socket Support', () => { }) it('should allow Unix domain socket creation with specific allowUnixSockets paths', () => { - if (skipIfNotMacOS()) { - return - } - const socketPath = join(TEST_BASE_DIR, 'specific.sock') const scriptPath = join(TEST_BASE_DIR, 'test_specific_socket.py') @@ -882,10 +784,6 @@ describe('macOS Seatbelt Unix Domain Socket Support', () => { }) it('should block Unix domain socket bind when neither allowAllUnixSockets nor allowUnixSockets is set', () => { - if (skipIfNotMacOS()) { - return - } - const socketPath = join(TEST_BASE_DIR, 'blocked.sock') const scriptPath = join(TEST_BASE_DIR, 'test_blocked_socket.py') @@ -934,12 +832,8 @@ describe('macOS Seatbelt Unix Domain Socket Support', () => { }) }) -describe('macOS Seatbelt Process Enumeration', () => { +describe.if(isMacOS)('macOS Seatbelt Process Enumeration', () => { it('should allow enumerating all process IDs (kern.proc.all sysctl)', () => { - if (skipIfNotMacOS()) { - return - } - // This tests that psutil.pids() and similar process enumeration works. // The kern.proc.all sysctl is used by psutil to list all PIDs on the system. // Use case: IPython kernel shutdown needs to enumerate child processes. diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index a8b1128f..b78ef539 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -28,6 +28,7 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux, isSupportedPlatform } from '../helpers/platform.js' /** * Integration tests for mandatory deny paths. @@ -40,388 +41,112 @@ import { * Tests must chdir to TEST_DIR before generating sandbox commands. */ -function skipIfUnsupportedPlatform(): boolean { - const platform = getPlatform() - return platform !== 'linux' && platform !== 'macos' -} - -describe('Mandatory Deny Paths - Integration Tests', () => { - const TEST_DIR = join(tmpdir(), `mandatory-deny-integration-${Date.now()}`) - const ORIGINAL_CONTENT = 'ORIGINAL' - const MODIFIED_CONTENT = 'MODIFIED' - let originalCwd: string - - beforeAll(() => { - if (skipIfUnsupportedPlatform()) return - - originalCwd = process.cwd() - mkdirSync(TEST_DIR, { recursive: true }) - - // Create ALL dangerous files from DANGEROUS_FILES - writeFileSync(join(TEST_DIR, '.bashrc'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.bash_profile'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.gitconfig'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.gitmodules'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.zshrc'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.zprofile'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.profile'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.ripgreprc'), ORIGINAL_CONTENT) - writeFileSync(join(TEST_DIR, '.mcp.json'), ORIGINAL_CONTENT) - - // Create .git with hooks and config - mkdirSync(join(TEST_DIR, '.git', 'hooks'), { recursive: true }) - writeFileSync(join(TEST_DIR, '.git', 'config'), ORIGINAL_CONTENT) - writeFileSync( - join(TEST_DIR, '.git', 'hooks', 'pre-commit'), - ORIGINAL_CONTENT, - ) - writeFileSync(join(TEST_DIR, '.git', 'HEAD'), 'ref: refs/heads/main') - - // Create .vscode - mkdirSync(join(TEST_DIR, '.vscode'), { recursive: true }) - writeFileSync(join(TEST_DIR, '.vscode', 'settings.json'), ORIGINAL_CONTENT) - - // Create .idea - mkdirSync(join(TEST_DIR, '.idea'), { recursive: true }) - writeFileSync(join(TEST_DIR, '.idea', 'workspace.xml'), ORIGINAL_CONTENT) - - // Create .claude/commands and .claude/agents (should be blocked) - mkdirSync(join(TEST_DIR, '.claude', 'commands'), { recursive: true }) - mkdirSync(join(TEST_DIR, '.claude', 'agents'), { recursive: true }) - writeFileSync( - join(TEST_DIR, '.claude', 'commands', 'test.md'), - ORIGINAL_CONTENT, - ) - writeFileSync( - join(TEST_DIR, '.claude', 'agents', 'test-agent.md'), - ORIGINAL_CONTENT, - ) - - // Create a safe file that SHOULD be writable - writeFileSync(join(TEST_DIR, 'safe-file.txt'), ORIGINAL_CONTENT) - - // Create safe files within .git that SHOULD be writable (not hooks/config) - mkdirSync(join(TEST_DIR, '.git', 'objects'), { recursive: true }) - mkdirSync(join(TEST_DIR, '.git', 'refs', 'heads'), { recursive: true }) - writeFileSync( - join(TEST_DIR, '.git', 'objects', 'test-obj'), - ORIGINAL_CONTENT, - ) - writeFileSync( - join(TEST_DIR, '.git', 'refs', 'heads', 'main'), - ORIGINAL_CONTENT, - ) - writeFileSync(join(TEST_DIR, '.git', 'index'), ORIGINAL_CONTENT) - - // Create safe file within .claude that SHOULD be writable (not commands/agents) - writeFileSync( - join(TEST_DIR, '.claude', 'some-other-file.txt'), - ORIGINAL_CONTENT, - ) - }) - - afterAll(() => { - if (skipIfUnsupportedPlatform()) return - process.chdir(originalCwd) - rmSync(TEST_DIR, { recursive: true, force: true }) - }) - - beforeEach(() => { - if (skipIfUnsupportedPlatform()) return - // Must be in TEST_DIR for mandatory deny patterns to apply correctly - process.chdir(TEST_DIR) - }) - - afterEach(() => { - if (skipIfUnsupportedPlatform()) return - // Reset the active-sandbox counter and scrub any leftover mount points so - // each test starts clean. Tests that don't explicitly call - // cleanupBwrapMountPoints() would otherwise leak the counter. - cleanupBwrapMountPoints({ force: true }) - }) - - async function runSandboxedWrite( - filePath: string, - content: string, - ): Promise<{ success: boolean; stderr: string }> { - const platform = getPlatform() - const command = `echo '${content}' > '${filePath}'` - - // Allow writes to current directory, but mandatory denies should still block dangerous files - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [], // Empty - relying on mandatory denies - } - - let wrappedCommand: string - if (platform === 'macos') { - wrappedCommand = wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - }) - } else { - wrappedCommand = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - }) - } - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - - return { - success: result.status === 0, - stderr: result.stderr || '', - } - } - - describe('Dangerous files should be blocked', () => { - it('blocks writes to .bashrc', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.bashrc', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.bashrc', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .gitconfig', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.gitconfig', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.gitconfig', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .zshrc', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.zshrc', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.zshrc', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .mcp.json', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.mcp.json', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.mcp.json', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .bash_profile', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.bash_profile', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.bash_profile', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .zprofile', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.zprofile', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.zprofile', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .profile', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.profile', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.profile', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .gitmodules', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.gitmodules', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.gitmodules', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .ripgreprc', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.ripgreprc', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.ripgreprc', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - }) - - describe('Git hooks and config should be blocked', () => { - it('blocks writes to .git/config', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.git/config', MODIFIED_CONTENT) - - expect(result.success).toBe(false) - expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('blocks writes to .git/hooks/pre-commit', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.git/hooks/pre-commit', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( +describe.if(isSupportedPlatform)( + 'Mandatory Deny Paths - Integration Tests', + () => { + const TEST_DIR = join(tmpdir(), `mandatory-deny-integration-${Date.now()}`) + const ORIGINAL_CONTENT = 'ORIGINAL' + const MODIFIED_CONTENT = 'MODIFIED' + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + mkdirSync(TEST_DIR, { recursive: true }) + + // Create ALL dangerous files from DANGEROUS_FILES + writeFileSync(join(TEST_DIR, '.bashrc'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.bash_profile'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.gitconfig'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.gitmodules'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.zshrc'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.zprofile'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.profile'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.ripgreprc'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.mcp.json'), ORIGINAL_CONTENT) + + // Create .git with hooks and config + mkdirSync(join(TEST_DIR, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(TEST_DIR, '.git', 'config'), ORIGINAL_CONTENT) + writeFileSync( + join(TEST_DIR, '.git', 'hooks', 'pre-commit'), ORIGINAL_CONTENT, ) - }) - }) - - describe('Dangerous directories should be blocked', () => { - it('blocks writes to .vscode/', async () => { - if (skipIfUnsupportedPlatform()) return + writeFileSync(join(TEST_DIR, '.git', 'HEAD'), 'ref: refs/heads/main') - const result = await runSandboxedWrite( - '.vscode/settings.json', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.vscode/settings.json', 'utf8')).toBe( + // Create .vscode + mkdirSync(join(TEST_DIR, '.vscode'), { recursive: true }) + writeFileSync( + join(TEST_DIR, '.vscode', 'settings.json'), ORIGINAL_CONTENT, ) - }) - - it('blocks writes to .claude/commands/', async () => { - if (skipIfUnsupportedPlatform()) return - const result = await runSandboxedWrite( - '.claude/commands/test.md', - MODIFIED_CONTENT, - ) + // Create .idea + mkdirSync(join(TEST_DIR, '.idea'), { recursive: true }) + writeFileSync(join(TEST_DIR, '.idea', 'workspace.xml'), ORIGINAL_CONTENT) - expect(result.success).toBe(false) - expect(readFileSync('.claude/commands/test.md', 'utf8')).toBe( + // Create .claude/commands and .claude/agents (should be blocked) + mkdirSync(join(TEST_DIR, '.claude', 'commands'), { recursive: true }) + mkdirSync(join(TEST_DIR, '.claude', 'agents'), { recursive: true }) + writeFileSync( + join(TEST_DIR, '.claude', 'commands', 'test.md'), ORIGINAL_CONTENT, ) - }) - - it('blocks writes to .claude/agents/', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.claude/agents/test-agent.md', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.claude/agents/test-agent.md', 'utf8')).toBe( + writeFileSync( + join(TEST_DIR, '.claude', 'agents', 'test-agent.md'), ORIGINAL_CONTENT, ) - }) - - it('blocks writes to .idea/', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.idea/workspace.xml', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.idea/workspace.xml', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - }) - - describe('Safe files should still be writable', () => { - it('allows writes to regular files', async () => { - if (skipIfUnsupportedPlatform()) return - const result = await runSandboxedWrite('safe-file.txt', MODIFIED_CONTENT) + // Create a safe file that SHOULD be writable + writeFileSync(join(TEST_DIR, 'safe-file.txt'), ORIGINAL_CONTENT) - expect(result.success).toBe(true) - expect(readFileSync('safe-file.txt', 'utf8').trim()).toBe( - MODIFIED_CONTENT, + // Create safe files within .git that SHOULD be writable (not hooks/config) + mkdirSync(join(TEST_DIR, '.git', 'objects'), { recursive: true }) + mkdirSync(join(TEST_DIR, '.git', 'refs', 'heads'), { recursive: true }) + writeFileSync( + join(TEST_DIR, '.git', 'objects', 'test-obj'), + ORIGINAL_CONTENT, ) - }) - - it('allows writes to .git/objects (not hooks/config)', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.git/objects/test-obj', - MODIFIED_CONTENT, + writeFileSync( + join(TEST_DIR, '.git', 'refs', 'heads', 'main'), + ORIGINAL_CONTENT, ) + writeFileSync(join(TEST_DIR, '.git', 'index'), ORIGINAL_CONTENT) - expect(result.success).toBe(true) - expect(readFileSync('.git/objects/test-obj', 'utf8').trim()).toBe( - MODIFIED_CONTENT, + // Create safe file within .claude that SHOULD be writable (not commands/agents) + writeFileSync( + join(TEST_DIR, '.claude', 'some-other-file.txt'), + ORIGINAL_CONTENT, ) }) - it('allows writes to .git/refs/heads (not hooks/config)', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.git/refs/heads/main', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(true) - expect(readFileSync('.git/refs/heads/main', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) + afterAll(() => { + process.chdir(originalCwd) + rmSync(TEST_DIR, { recursive: true, force: true }) }) - it('allows writes to .git/index (not hooks/config)', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite('.git/index', MODIFIED_CONTENT) - - expect(result.success).toBe(true) - expect(readFileSync('.git/index', 'utf8').trim()).toBe(MODIFIED_CONTENT) + beforeEach(() => { + // Must be in TEST_DIR for mandatory deny patterns to apply correctly + process.chdir(TEST_DIR) }) - it('allows writes to .claude/ files outside commands/agents', async () => { - if (skipIfUnsupportedPlatform()) return - - const result = await runSandboxedWrite( - '.claude/some-other-file.txt', - MODIFIED_CONTENT, - ) - - expect(result.success).toBe(true) - expect(readFileSync('.claude/some-other-file.txt', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) + afterEach(() => { + // Reset the active-sandbox counter and scrub any leftover mount points so + // each test starts clean. Tests that don't explicitly call + // cleanupBwrapMountPoints() would otherwise leak the counter. + cleanupBwrapMountPoints({ force: true }) }) - }) - describe('allowGitConfig option', () => { - async function runSandboxedWriteWithGitConfig( + async function runSandboxedWrite( filePath: string, content: string, - allowGitConfig: boolean, ): Promise<{ success: boolean; stderr: string }> { const platform = getPlatform() const command = `echo '${content}' > '${filePath}'` + // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { allowOnly: ['.'], - denyWithinAllow: [], + denyWithinAllow: [], // Empty - relying on mandatory denies } let wrappedCommand: string @@ -431,7 +156,6 @@ describe('Mandatory Deny Paths - Integration Tests', () => { needsNetworkRestriction: false, readConfig: undefined, writeConfig, - allowGitConfig, }) } else { wrappedCommand = await wrapCommandWithSandboxLinux({ @@ -439,7 +163,6 @@ describe('Mandatory Deny Paths - Integration Tests', () => { needsNetworkRestriction: false, readConfig: undefined, writeConfig, - allowGitConfig, }) } @@ -455,707 +178,940 @@ describe('Mandatory Deny Paths - Integration Tests', () => { } } - it('blocks writes to .git/config when allowGitConfig is false (default)', async () => { - if (skipIfUnsupportedPlatform()) return - - // Reset .git/config to original content - writeFileSync('.git/config', ORIGINAL_CONTENT) - - const result = await runSandboxedWriteWithGitConfig( - '.git/config', - MODIFIED_CONTENT, - false, - ) - - expect(result.success).toBe(false) - expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) - }) - - it('allows writes to .git/config when allowGitConfig is true', async () => { - if (skipIfUnsupportedPlatform()) return + describe('Dangerous files should be blocked', () => { + it('blocks writes to .bashrc', async () => { + const result = await runSandboxedWrite('.bashrc', MODIFIED_CONTENT) - // Reset .git/config to original content - writeFileSync('.git/config', ORIGINAL_CONTENT) + expect(result.success).toBe(false) + expect(readFileSync('.bashrc', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - const result = await runSandboxedWriteWithGitConfig( - '.git/config', - MODIFIED_CONTENT, - true, - ) + it('blocks writes to .gitconfig', async () => { + const result = await runSandboxedWrite('.gitconfig', MODIFIED_CONTENT) - expect(result.success).toBe(true) - expect(readFileSync('.git/config', 'utf8').trim()).toBe(MODIFIED_CONTENT) - }) + expect(result.success).toBe(false) + expect(readFileSync('.gitconfig', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - it('still blocks writes to .git/hooks even when allowGitConfig is true', async () => { - if (skipIfUnsupportedPlatform()) return + it('blocks writes to .zshrc', async () => { + const result = await runSandboxedWrite('.zshrc', MODIFIED_CONTENT) - // Reset pre-commit to original content - writeFileSync('.git/hooks/pre-commit', ORIGINAL_CONTENT) + expect(result.success).toBe(false) + expect(readFileSync('.zshrc', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - const result = await runSandboxedWriteWithGitConfig( - '.git/hooks/pre-commit', - MODIFIED_CONTENT, - true, - ) + it('blocks writes to .mcp.json', async () => { + const result = await runSandboxedWrite('.mcp.json', MODIFIED_CONTENT) - expect(result.success).toBe(false) - expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( - ORIGINAL_CONTENT, - ) - }) - }) - - describe('Non-existent deny path protection and cleanup (Linux only)', () => { - // This tests that: - // 1. Non-existent deny paths within writable areas are blocked by mounting - // /dev/null at the first non-existent component - // 2. The mount point artifacts bwrap creates on the host are cleaned up - // by cleanupBwrapMountPoints() - // - // Background: When bwrap does --ro-bind /dev/null /nonexistent/path, it - // creates an empty file on the host as a mount point. Without cleanup, - // these "ghost dotfiles" persist and pollute the working directory. - - async function runSandboxedWriteWithDenyPaths( - command: string, - denyPaths: string[], - ): Promise<{ success: boolean; stdout: string; stderr: string }> { - const platform = getPlatform() - if (platform !== 'linux') { - return { success: true, stdout: '', stderr: '' } - } + expect(result.success).toBe(false) + expect(readFileSync('.mcp.json', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: denyPaths, - } + it('blocks writes to .bash_profile', async () => { + const result = await runSandboxedWrite( + '.bash_profile', + MODIFIED_CONTENT, + ) - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, + expect(result.success).toBe(false) + expect(readFileSync('.bash_profile', 'utf8')).toBe(ORIGINAL_CONTENT) }) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + it('blocks writes to .zprofile', async () => { + const result = await runSandboxedWrite('.zprofile', MODIFIED_CONTENT) - return { - success: result.status === 0, - stdout: result.stdout || '', - stderr: result.stderr || '', - } - } + expect(result.success).toBe(false) + expect(readFileSync('.zprofile', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - // --- Security: deny path blocking --- + it('blocks writes to .profile', async () => { + const result = await runSandboxedWrite('.profile', MODIFIED_CONTENT) - it('blocks creation of non-existent file when parent dir exists', async () => { - if (getPlatform() !== 'linux') return + expect(result.success).toBe(false) + expect(readFileSync('.profile', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - // .claude directory exists from beforeAll setup - // .claude/settings.json does NOT exist - const nonExistentFile = '.claude/settings.json' + it('blocks writes to .gitmodules', async () => { + const result = await runSandboxedWrite('.gitmodules', MODIFIED_CONTENT) - const result = await runSandboxedWriteWithDenyPaths( - `echo '{"hooks":{}}' > '${nonExistentFile}'`, - [join(TEST_DIR, nonExistentFile)], - ) + expect(result.success).toBe(false) + expect(readFileSync('.gitmodules', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - expect(result.success).toBe(false) - // Verify file content was NOT written (bwrap creates empty mount point) - const content = readFileSync(nonExistentFile, 'utf8') - expect(content).toBe('') + it('blocks writes to .ripgreprc', async () => { + const result = await runSandboxedWrite('.ripgreprc', MODIFIED_CONTENT) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + expect(readFileSync('.ripgreprc', 'utf8')).toBe(ORIGINAL_CONTENT) + }) }) - it('blocks creation of non-existent file when parent dir also does not exist', async () => { - if (getPlatform() !== 'linux') return - - const nonExistentPath = 'nonexistent-dir/settings.json' + describe('Git hooks and config should be blocked', () => { + it('blocks writes to .git/config', async () => { + const result = await runSandboxedWrite('.git/config', MODIFIED_CONTENT) - const result = await runSandboxedWriteWithDenyPaths( - `mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], - ) + expect(result.success).toBe(false) + expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - expect(result.success).toBe(false) - // bwrap mounts an empty read-only directory at first non-existent - // intermediate component, blocking mkdir inside it - const stat = statSync('nonexistent-dir') - expect(stat.isDirectory()).toBe(true) + it('blocks writes to .git/hooks/pre-commit', async () => { + const result = await runSandboxedWrite( + '.git/hooks/pre-commit', + MODIFIED_CONTENT, + ) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) }) - it('blocks creation of deeply nested non-existent path', async () => { - if (getPlatform() !== 'linux') return - - const nonExistentPath = 'a/b/c/file.txt' - - const result = await runSandboxedWriteWithDenyPaths( - `mkdir -p a/b/c && echo 'test' > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], - ) - - expect(result.success).toBe(false) - // bwrap mounts an empty read-only directory at 'a', blocking the - // entire subtree - const stat = statSync('a') - expect(stat.isDirectory()).toBe(true) - - cleanupBwrapMountPoints() - }) + describe('Dangerous directories should be blocked', () => { + it('blocks writes to .vscode/', async () => { + const result = await runSandboxedWrite( + '.vscode/settings.json', + MODIFIED_CONTENT, + ) - // --- Cleanup: mount point artifact removal --- + expect(result.success).toBe(false) + expect(readFileSync('.vscode/settings.json', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) - it('cleanupBwrapMountPoints removes mount point artifacts', async () => { - if (getPlatform() !== 'linux') return + it('blocks writes to .claude/commands/', async () => { + const result = await runSandboxedWrite( + '.claude/commands/test.md', + MODIFIED_CONTENT, + ) - const nonExistentPath = 'cleanup-test-dir/file.txt' + expect(result.success).toBe(false) + expect(readFileSync('.claude/commands/test.md', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) - await runSandboxedWriteWithDenyPaths(`echo test > '${nonExistentPath}'`, [ - join(TEST_DIR, nonExistentPath), - ]) + it('blocks writes to .claude/agents/', async () => { + const result = await runSandboxedWrite( + '.claude/agents/test-agent.md', + MODIFIED_CONTENT, + ) - // Mount point artifact should exist on host after bwrap exits - expect(existsSync('cleanup-test-dir')).toBe(true) + expect(result.success).toBe(false) + expect(readFileSync('.claude/agents/test-agent.md', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) - // Clean up - cleanupBwrapMountPoints() + it('blocks writes to .idea/', async () => { + const result = await runSandboxedWrite( + '.idea/workspace.xml', + MODIFIED_CONTENT, + ) - // Artifact should be gone - expect(existsSync('cleanup-test-dir')).toBe(false) + expect(result.success).toBe(false) + expect(readFileSync('.idea/workspace.xml', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) }) - it('cleanupBwrapMountPoints removes multiple mount points from a single command', async () => { - if (getPlatform() !== 'linux') return - - // Two non-existent deny paths in different subtrees - const path1 = 'ghost-dir-a/secret.txt' - const path2 = 'ghost-dir-b/secret.txt' - - await runSandboxedWriteWithDenyPaths(`mkdir -p ghost-dir-a ghost-dir-b`, [ - join(TEST_DIR, path1), - join(TEST_DIR, path2), - ]) - - // Both mount point artifacts should exist - expect(existsSync('ghost-dir-a')).toBe(true) - expect(existsSync('ghost-dir-b')).toBe(true) + describe('Safe files should still be writable', () => { + it('allows writes to regular files', async () => { + const result = await runSandboxedWrite( + 'safe-file.txt', + MODIFIED_CONTENT, + ) - cleanupBwrapMountPoints() + expect(result.success).toBe(true) + expect(readFileSync('safe-file.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) - // Both should be cleaned up - expect(existsSync('ghost-dir-a')).toBe(false) - expect(existsSync('ghost-dir-b')).toBe(false) - }) + it('allows writes to .git/objects (not hooks/config)', async () => { + const result = await runSandboxedWrite( + '.git/objects/test-obj', + MODIFIED_CONTENT, + ) - it('cleanupBwrapMountPoints preserves non-empty directories', async () => { - if (getPlatform() !== 'linux') return + expect(result.success).toBe(true) + expect(readFileSync('.git/objects/test-obj', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) - const nonExistentPath = 'preserve-test-dir/file.txt' + it('allows writes to .git/refs/heads (not hooks/config)', async () => { + const result = await runSandboxedWrite( + '.git/refs/heads/main', + MODIFIED_CONTENT, + ) - await runSandboxedWriteWithDenyPaths(`echo test > '${nonExistentPath}'`, [ - join(TEST_DIR, nonExistentPath), - ]) + expect(result.success).toBe(true) + expect(readFileSync('.git/refs/heads/main', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) - // Simulate something else creating content in the mount point directory - // (e.g., another process created files here legitimately) - const mountPoint = join(TEST_DIR, 'preserve-test-dir') - if (existsSync(mountPoint)) { - // Create a file inside — cleanup should NOT delete non-empty directories - writeFileSync(join(mountPoint, 'real-file.txt'), 'real content') - } + it('allows writes to .git/index (not hooks/config)', async () => { + const result = await runSandboxedWrite('.git/index', MODIFIED_CONTENT) - cleanupBwrapMountPoints() + expect(result.success).toBe(true) + expect(readFileSync('.git/index', 'utf8').trim()).toBe(MODIFIED_CONTENT) + }) - // Directory with real content should be preserved - if (existsSync(mountPoint)) { - expect(statSync(mountPoint).isDirectory()).toBe(true) - const content = readFileSync(join(mountPoint, 'real-file.txt'), 'utf8') - expect(content).toBe('real content') - // Manual cleanup for this test - rmSync(mountPoint, { recursive: true, force: true }) - } - }) + it('allows writes to .claude/ files outside commands/agents', async () => { + const result = await runSandboxedWrite( + '.claude/some-other-file.txt', + MODIFIED_CONTENT, + ) - it('cleanupBwrapMountPoints is safe to call when there are no mount points', () => { - // Should not throw - cleanupBwrapMountPoints() - cleanupBwrapMountPoints() + expect(result.success).toBe(true) + expect(readFileSync('.claude/some-other-file.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) }) - // --- Concurrent sandbox mount point cleanup --- - // - // When two sandboxed commands run concurrently and one finishes first, - // cleanupBwrapMountPoints() must NOT delete mount point files that the - // still-running sandbox depends on. Deleting a mountpoint's dentry on the - // host detaches the bind mount in the child namespace, so the deny rule - // stops applying inside the still-running sandbox. - - it('defers mount point cleanup while another sandbox is still running', async () => { - if (getPlatform() !== 'linux') return - - const raceDir = join(TEST_DIR, 'race-test') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) - - const originalDir = process.cwd() - process.chdir(raceDir) + describe('allowGitConfig option', () => { + async function runSandboxedWriteWithGitConfig( + filePath: string, + content: string, + allowGitConfig: boolean, + ): Promise<{ success: boolean; stderr: string }> { + const platform = getPlatform() + const command = `echo '${content}' > '${filePath}'` - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') const writeConfig = { allowOnly: ['.'], - denyWithinAllow: [protectedFile], + denyWithinAllow: [], } - // Sandbox A: long-running command that sleeps then tries to write - // to the denied path. The write should be blocked. - // allowAllUnixSockets skips seccomp (environment-dependent) while - // keeping the filesystem isolation we're testing. - const wrappedA = await wrapCommandWithSandboxLinux({ - command: `sleep 2; echo '{"hooks":{}}' > .claude/settings.json`, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', code => resolve(code)) - }) - - // Wait for bwrap A to start and create the mount point on the host - await new Promise(r => setTimeout(r, 500)) - expect(existsSync(protectedFile)).toBe(true) + let wrappedCommand: string + if (platform === 'macos') { + wrappedCommand = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + allowGitConfig, + }) + } else { + wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + allowGitConfig, + }) + } - // Sandbox B: short command. When it finishes, the caller invokes - // cleanupBwrapMountPoints() — simulating the real-world race. - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, }) - spawnSync(wrappedB, { shell: true, encoding: 'utf8', timeout: 10000 }) - - // This is what the caller does after every command completes. - // Without deferral, this would delete sandbox A's mount point too. - cleanupBwrapMountPoints() - - // Wait for sandbox A to attempt its write - await exitA - - // The deny rule must have held — the file should not contain the - // write from sandbox A. If cleanup had deleted the mount point - // early, A's bind mount would have detached and the write would - // have landed on the host. - const content = existsSync(protectedFile) - ? readFileSync(protectedFile, 'utf8') - : '' - expect(content).not.toContain('hooks') - - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) - } - }, 15000) - - it('defers cleanup when two sandboxes share the same non-existent deny path', async () => { - if (getPlatform() !== 'linux') return - const raceDir = join(TEST_DIR, 'race-test-2') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) - - const originalDir = process.cwd() - process.chdir(raceDir) - - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [protectedFile], + return { + success: result.status === 0, + stderr: result.stderr || '', } + } - // Generate both wrapped commands BEFORE spawning, so both see the - // deny path as non-existent and both add it to bwrapMountPoints. - const wrappedA = await wrapCommandWithSandboxLinux({ - command: `sleep 2; echo WRITTEN > .claude/settings.json`, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'sleep 0.5', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) + it('blocks writes to .git/config when allowGitConfig is false (default)', async () => { + // Reset .git/config to original content + writeFileSync('.git/config', ORIGINAL_CONTENT) - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', code => resolve(code)) - }) + const result = await runSandboxedWriteWithGitConfig( + '.git/config', + MODIFIED_CONTENT, + false, + ) - // Sandbox B runs and finishes first - spawnSync(wrappedB, { shell: true, encoding: 'utf8', timeout: 10000 }) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) + }) - await exitA + it('allows writes to .git/config when allowGitConfig is true', async () => { + // Reset .git/config to original content + writeFileSync('.git/config', ORIGINAL_CONTENT) - const content = existsSync(protectedFile) - ? readFileSync(protectedFile, 'utf8') - : '' - expect(content).not.toContain('WRITTEN') + const result = await runSandboxedWriteWithGitConfig( + '.git/config', + MODIFIED_CONTENT, + true, + ) - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) - } - }, 15000) + expect(result.success).toBe(true) + expect(readFileSync('.git/config', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) - it('deferred cleanup runs once all concurrent sandboxes finish', async () => { - if (getPlatform() !== 'linux') return + it('still blocks writes to .git/hooks even when allowGitConfig is true', async () => { + // Reset pre-commit to original content + writeFileSync('.git/hooks/pre-commit', ORIGINAL_CONTENT) - const raceDir = join(TEST_DIR, 'race-test-3') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) + const result = await runSandboxedWriteWithGitConfig( + '.git/hooks/pre-commit', + MODIFIED_CONTENT, + true, + ) - const originalDir = process.cwd() - process.chdir(raceDir) + expect(result.success).toBe(false) + expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + }) - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [protectedFile], + describe.if(isLinux)( + 'Non-existent deny path protection and cleanup (Linux only)', + () => { + // This tests that: + // 1. Non-existent deny paths within writable areas are blocked by mounting + // /dev/null at the first non-existent component + // 2. The mount point artifacts bwrap creates on the host are cleaned up + // by cleanupBwrapMountPoints() + // + // Background: When bwrap does --ro-bind /dev/null /nonexistent/path, it + // creates an empty file on the host as a mount point. Without cleanup, + // these "ghost dotfiles" persist and pollute the working directory. + + async function runSandboxedWriteWithDenyPaths( + command: string, + denyPaths: string[], + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const platform = getPlatform() + if (platform !== 'linux') { + return { success: true, stdout: '', stderr: '' } + } + + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: denyPaths, + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + } } - const wrappedA = await wrapCommandWithSandboxLinux({ - command: 'sleep 1', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) + // --- Security: deny path blocking --- - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', () => resolve()) - }) + it('blocks creation of non-existent file when parent dir exists', async () => { + // .claude directory exists from beforeAll setup + // .claude/settings.json does NOT exist + const nonExistentFile = '.claude/settings.json' - await new Promise(r => setTimeout(r, 300)) - expect(existsSync(protectedFile)).toBe(true) + const result = await runSandboxedWriteWithDenyPaths( + `echo '{"hooks":{}}' > '${nonExistentFile}'`, + [join(TEST_DIR, nonExistentFile)], + ) - spawnSync(wrappedB, { shell: true, encoding: 'utf8', timeout: 10000 }) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + // Verify file content was NOT written (bwrap creates empty mount point) + const content = readFileSync(nonExistentFile, 'utf8') + expect(content).toBe('') - // Cleanup deferred — mount point still present while A runs - expect(existsSync(protectedFile)).toBe(true) + cleanupBwrapMountPoints() + }) - await exitA - cleanupBwrapMountPoints() + it('blocks creation of non-existent file when parent dir also does not exist', async () => { + const nonExistentPath = 'nonexistent-dir/settings.json' - // Both sandboxes done — mount point now cleaned up - expect(existsSync(protectedFile)).toBe(false) - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) - } - }, 15000) + const result = await runSandboxedWriteWithDenyPaths( + `mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) - it('non-existent .git/hooks deny does not turn .git into a file, breaking git', async () => { - if (getPlatform() !== 'linux') return + expect(result.success).toBe(false) + // bwrap mounts an empty read-only directory at first non-existent + // intermediate component, blocking mkdir inside it + const stat = statSync('nonexistent-dir') + expect(stat.isDirectory()).toBe(true) - // When .git doesn't exist yet, denying .git/hooks causes - // findFirstNonExistentComponent to return .git itself. bwrap then does - // --ro-bind /dev/null .git, creating .git as a FILE (not a directory). - // Inside the sandbox, every git command fails because .git is a file. + cleanupBwrapMountPoints() + }) - // Use a clean directory with NO .git - const noGitDir = join(TEST_DIR, 'no-git-dir') - mkdirSync(noGitDir, { recursive: true }) + it('blocks creation of deeply nested non-existent path', async () => { + const nonExistentPath = 'a/b/c/file.txt' - const originalDir = process.cwd() - process.chdir(noGitDir) + const result = await runSandboxedWriteWithDenyPaths( + `mkdir -p a/b/c && echo 'test' > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) - try { - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], - } + expect(result.success).toBe(false) + // bwrap mounts an empty read-only directory at 'a', blocking the + // entire subtree + const stat = statSync('a') + expect(stat.isDirectory()).toBe(true) - // This calls linuxGetMandatoryDenyPaths which unconditionally adds - // .git/hooks to the deny list. When .git doesn't exist, - // findFirstNonExistentComponent returns .git and bwrap mounts - // /dev/null there — making .git a file. - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'git init && git status', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, + cleanupBwrapMountPoints() }) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + // --- Cleanup: mount point artifact removal --- - // git init + git status should succeed — .git must be creatable as - // a directory, not blocked by a /dev/null file mount. - expect(result.status).toBe(0) + it('cleanupBwrapMountPoints removes mount point artifacts', async () => { + const nonExistentPath = 'cleanup-test-dir/file.txt' - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(noGitDir, { recursive: true, force: true }) - } - }) + await runSandboxedWriteWithDenyPaths( + `echo test > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) - it('git worktree with .git as a file does not break sandboxed commands', async () => { - if (getPlatform() !== 'linux') return + // Mount point artifact should exist on host after bwrap exits + expect(existsSync('cleanup-test-dir')).toBe(true) - // Reproduces the bug reported by nvidia/netflix with git worktrees: - // In a worktree, .git is a FILE (e.g., "gitdir: /path/to/.git/worktrees/foo"), - // not a directory. The mandatory deny list includes .git/hooks, but since - // .git is a file, .git/hooks doesn't exist. The non-existent path handling - // tries to mount /dev/null at .git/hooks, but bwrap can't create a mount - // point under .git because it's a file — causing every command to fail. + // Clean up + cleanupBwrapMountPoints() - const worktreeDir = join(TEST_DIR, 'fake-worktree') - mkdirSync(worktreeDir, { recursive: true }) + // Artifact should be gone + expect(existsSync('cleanup-test-dir')).toBe(false) + }) - // Simulate a git worktree: .git is a file, not a directory - writeFileSync( - join(worktreeDir, '.git'), - 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', - ) + it('cleanupBwrapMountPoints removes multiple mount points from a single command', async () => { + // Two non-existent deny paths in different subtrees + const path1 = 'ghost-dir-a/secret.txt' + const path2 = 'ghost-dir-b/secret.txt' - const originalDir = process.cwd() - process.chdir(worktreeDir) + await runSandboxedWriteWithDenyPaths( + `mkdir -p ghost-dir-a ghost-dir-b`, + [join(TEST_DIR, path1), join(TEST_DIR, path2)], + ) - try { - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], - } + // Both mount point artifacts should exist + expect(existsSync('ghost-dir-a')).toBe(true) + expect(existsSync('ghost-dir-b')).toBe(true) - // linuxGetMandatoryDenyPaths adds .git/hooks to deny list. - // .git exists as a file, so .git/hooks doesn't exist. - // The code will try to mount /dev/null at .git/hooks, but bwrap - // can't create a mount point there because .git is a file. - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'echo hello', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - }) + cleanupBwrapMountPoints() - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, + // Both should be cleaned up + expect(existsSync('ghost-dir-a')).toBe(false) + expect(existsSync('ghost-dir-b')).toBe(false) }) - // A simple echo should succeed — the .git-as-file worktree layout - // should not cause the sandbox to fail. - expect(result.status).toBe(0) - expect(result.stdout.trim()).toBe('hello') - - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(worktreeDir, { recursive: true, force: true }) - } - }) + it('cleanupBwrapMountPoints preserves non-empty directories', async () => { + const nonExistentPath = 'preserve-test-dir/file.txt' + + await runSandboxedWriteWithDenyPaths( + `echo test > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + // Simulate something else creating content in the mount point directory + // (e.g., another process created files here legitimately) + const mountPoint = join(TEST_DIR, 'preserve-test-dir') + if (existsSync(mountPoint)) { + // Create a file inside — cleanup should NOT delete non-empty directories + writeFileSync(join(mountPoint, 'real-file.txt'), 'real content') + } + + cleanupBwrapMountPoints() + + // Directory with real content should be preserved + if (existsSync(mountPoint)) { + expect(statSync(mountPoint).isDirectory()).toBe(true) + const content = readFileSync( + join(mountPoint, 'real-file.txt'), + 'utf8', + ) + expect(content).toBe('real content') + // Manual cleanup for this test + rmSync(mountPoint, { recursive: true, force: true }) + } + }) - it('does not leave ghost dotfiles after command + cleanup cycle', async () => { - if (getPlatform() !== 'linux') return + it('cleanupBwrapMountPoints is safe to call when there are no mount points', () => { + // Should not throw + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + }) - // This is the exact scenario from issue #85: running a sandboxed command - // should NOT leave .bashrc, .gitconfig, etc. in the working directory. - // - // The mandatory deny list includes paths like ~/.bashrc, ~/.gitconfig. - // When CWD is within an allowed write path and these dotfiles don't exist - // in CWD, the old code left empty mount point files behind. + // --- Concurrent sandbox mount point cleanup --- + // + // When two sandboxed commands run concurrently and one finishes first, + // cleanupBwrapMountPoints() must NOT delete mount point files that the + // still-running sandbox depends on. Deleting a mountpoint's dentry on the + // host detaches the bind mount in the child namespace, so the deny rule + // stops applying inside the still-running sandbox. + + it('defers mount point cleanup while another sandbox is still running', async () => { + const raceDir = join(TEST_DIR, 'race-test') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + // Sandbox A: long-running command that sleeps then tries to write + // to the denied path. The write should be blocked. + // allowAllUnixSockets skips seccomp (environment-dependent) while + // keeping the filesystem isolation we're testing. + const wrappedA = await wrapCommandWithSandboxLinux({ + command: `sleep 2; echo '{"hooks":{}}' > .claude/settings.json`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', code => resolve(code)) + }) + + // Wait for bwrap A to start and create the mount point on the host + await new Promise(r => setTimeout(r, 500)) + expect(existsSync(protectedFile)).toBe(true) + + // Sandbox B: short command. When it finishes, the caller invokes + // cleanupBwrapMountPoints() — simulating the real-world race. + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // This is what the caller does after every command completes. + // Without deferral, this would delete sandbox A's mount point too. + cleanupBwrapMountPoints() + + // Wait for sandbox A to attempt its write + await exitA + + // The deny rule must have held — the file should not contain the + // write from sandbox A. If cleanup had deleted the mount point + // early, A's bind mount would have detached and the write would + // have landed on the host. + const content = existsSync(protectedFile) + ? readFileSync(protectedFile, 'utf8') + : '' + expect(content).not.toContain('hooks') + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('defers cleanup when two sandboxes share the same non-existent deny path', async () => { + const raceDir = join(TEST_DIR, 'race-test-2') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + // Generate both wrapped commands BEFORE spawning, so both see the + // deny path as non-existent and both add it to bwrapMountPoints. + const wrappedA = await wrapCommandWithSandboxLinux({ + command: `sleep 2; echo WRITTEN > .claude/settings.json`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'sleep 0.5', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', code => resolve(code)) + }) + + // Sandbox B runs and finishes first + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + cleanupBwrapMountPoints() + + await exitA + + const content = existsSync(protectedFile) + ? readFileSync(protectedFile, 'utf8') + : '' + expect(content).not.toContain('WRITTEN') + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('deferred cleanup runs once all concurrent sandboxes finish', async () => { + const raceDir = join(TEST_DIR, 'race-test-3') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + const wrappedA = await wrapCommandWithSandboxLinux({ + command: 'sleep 1', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', () => resolve()) + }) + + await new Promise(r => setTimeout(r, 300)) + expect(existsSync(protectedFile)).toBe(true) + + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + cleanupBwrapMountPoints() + + // Cleanup deferred — mount point still present while A runs + expect(existsSync(protectedFile)).toBe(true) + + await exitA + cleanupBwrapMountPoints() + + // Both sandboxes done — mount point now cleaned up + expect(existsSync(protectedFile)).toBe(false) + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('non-existent .git/hooks deny does not turn .git into a file, breaking git', async () => { + // When .git doesn't exist yet, denying .git/hooks causes + // findFirstNonExistentComponent to return .git itself. bwrap then does + // --ro-bind /dev/null .git, creating .git as a FILE (not a directory). + // Inside the sandbox, every git command fails because .git is a file. + + // Use a clean directory with NO .git + const noGitDir = join(TEST_DIR, 'no-git-dir') + mkdirSync(noGitDir, { recursive: true }) + + const originalDir = process.cwd() + process.chdir(noGitDir) + + try { + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + // This calls linuxGetMandatoryDenyPaths which unconditionally adds + // .git/hooks to the deny list. When .git doesn't exist, + // findFirstNonExistentComponent returns .git and bwrap mounts + // /dev/null there — making .git a file. + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'git init && git status', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // git init + git status should succeed — .git must be creatable as + // a directory, not blocked by a /dev/null file mount. + expect(result.status).toBe(0) + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(noGitDir, { recursive: true, force: true }) + } + }) - // Use a clean subdirectory with no dotfiles - const cleanDir = join(TEST_DIR, 'clean-subdir') - mkdirSync(cleanDir, { recursive: true }) + it('git worktree with .git as a file does not break sandboxed commands', async () => { + // Reproduces the bug reported by nvidia/netflix with git worktrees: + // In a worktree, .git is a FILE (e.g., "gitdir: /path/to/.git/worktrees/foo"), + // not a directory. The mandatory deny list includes .git/hooks, but since + // .git is a file, .git/hooks doesn't exist. The non-existent path handling + // tries to mount /dev/null at .git/hooks, but bwrap can't create a mount + // point under .git because it's a file — causing every command to fail. + + const worktreeDir = join(TEST_DIR, 'fake-worktree') + mkdirSync(worktreeDir, { recursive: true }) + + // Simulate a git worktree: .git is a file, not a directory + writeFileSync( + join(worktreeDir, '.git'), + 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', + ) + + const originalDir = process.cwd() + process.chdir(worktreeDir) + + try { + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + // linuxGetMandatoryDenyPaths adds .git/hooks to deny list. + // .git exists as a file, so .git/hooks doesn't exist. + // The code will try to mount /dev/null at .git/hooks, but bwrap + // can't create a mount point there because .git is a file. + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // A simple echo should succeed — the .git-as-file worktree layout + // should not cause the sandbox to fail. + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('hello') + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(worktreeDir, { recursive: true, force: true }) + } + }) - const originalDir = process.cwd() - process.chdir(cleanDir) + it('does not leave ghost dotfiles after command + cleanup cycle', async () => { + // This is the exact scenario from issue #85: running a sandboxed command + // should NOT leave .bashrc, .gitconfig, etc. in the working directory. + // + // The mandatory deny list includes paths like ~/.bashrc, ~/.gitconfig. + // When CWD is within an allowed write path and these dotfiles don't exist + // in CWD, the old code left empty mount point files behind. + + // Use a clean subdirectory with no dotfiles + const cleanDir = join(TEST_DIR, 'clean-subdir') + mkdirSync(cleanDir, { recursive: true }) + + const originalDir = process.cwd() + process.chdir(cleanDir) + + try { + // Run a simple command through the sandbox + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // Run cleanup (as the CLI / Claude Code would) + cleanupBwrapMountPoints() + + // Verify no ghost dotfiles were left behind + const { readdirSync } = await import('node:fs') + const files = readdirSync(cleanDir) + const ghostDotfiles = files.filter(f => f.startsWith('.')) + expect(ghostDotfiles).toEqual([]) + } finally { + process.chdir(originalDir) + rmSync(cleanDir, { recursive: true, force: true }) + } + }) + }, + ) - try { - // Run a simple command through the sandbox - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], + describe.if(isLinux)( + 'Symlink replacement attack protection (Linux only)', + () => { + // This tests the fix for symlink replacement attacks where an attacker + // could delete a symlink and create a real directory with malicious content + + async function runSandboxedCommandWithDenyPaths( + command: string, + denyPaths: string[], + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const platform = getPlatform() + if (platform !== 'linux') { + return { success: true, stdout: '', stderr: '' } + } + + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: denyPaths, + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + } } - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'echo hello', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, + it('blocks symlink replacement attack on .claude directory', async () => { + // Setup: Create a symlink .claude -> decoy (simulating malicious git repo) + const decoyDir = 'symlink-decoy' + const claudeSymlink = 'symlink-claude' + mkdirSync(decoyDir, { recursive: true }) + writeFileSync(join(decoyDir, 'settings.json'), '{}') + symlinkSync(decoyDir, claudeSymlink) + + try { + // The deny path is the settings.json through the symlink + const denyPath = join(TEST_DIR, claudeSymlink, 'settings.json') + + // Attacker tries to: + // 1. Delete the symlink + // 2. Create a real directory + // 3. Create malicious settings.json + const result = await runSandboxedCommandWithDenyPaths( + `rm ${claudeSymlink} && mkdir ${claudeSymlink} && echo '{"hooks":{}}' > ${claudeSymlink}/settings.json`, + [denyPath], + ) + + // The attack should fail - symlink is protected with /dev/null mount + expect(result.success).toBe(false) + + // Verify the symlink still exists on host (was not deleted) + expect(existsSync(claudeSymlink)).toBe(true) + } finally { + // Cleanup + rmSync(claudeSymlink, { force: true }) + rmSync(decoyDir, { recursive: true, force: true }) + } }) - spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, + it('blocks deletion of symlink in protected path', async () => { + // Setup: Create a symlink + const targetDir = 'symlink-target-dir' + const symlinkPath = 'protected-symlink' + mkdirSync(targetDir, { recursive: true }) + writeFileSync(join(targetDir, 'file.txt'), 'content') + symlinkSync(targetDir, symlinkPath) + + try { + const denyPath = join(TEST_DIR, symlinkPath, 'file.txt') + + // Try to just delete the symlink + const result = await runSandboxedCommandWithDenyPaths( + `rm ${symlinkPath}`, + [denyPath], + ) + + // Should fail - symlink is mounted with /dev/null + expect(result.success).toBe(false) + + // Symlink should still exist + expect(existsSync(symlinkPath)).toBe(true) + } finally { + rmSync(symlinkPath, { force: true }) + rmSync(targetDir, { recursive: true, force: true }) + } }) - - // Run cleanup (as the CLI / Claude Code would) - cleanupBwrapMountPoints() - - // Verify no ghost dotfiles were left behind - const { readdirSync } = await import('node:fs') - const files = readdirSync(cleanDir) - const ghostDotfiles = files.filter(f => f.startsWith('.')) - expect(ghostDotfiles).toEqual([]) - } finally { - process.chdir(originalDir) - rmSync(cleanDir, { recursive: true, force: true }) - } - }) - }) - - describe('Symlink replacement attack protection (Linux only)', () => { - // This tests the fix for symlink replacement attacks where an attacker - // could delete a symlink and create a real directory with malicious content - - async function runSandboxedCommandWithDenyPaths( - command: string, - denyPaths: string[], - ): Promise<{ success: boolean; stdout: string; stderr: string }> { - const platform = getPlatform() - if (platform !== 'linux') { - return { success: true, stdout: '', stderr: '' } - } - - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: denyPaths, - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - - return { - success: result.status === 0, - stdout: result.stdout || '', - stderr: result.stderr || '', - } - } - - it('blocks symlink replacement attack on .claude directory', async () => { - if (getPlatform() !== 'linux') return - - // Setup: Create a symlink .claude -> decoy (simulating malicious git repo) - const decoyDir = 'symlink-decoy' - const claudeSymlink = 'symlink-claude' - mkdirSync(decoyDir, { recursive: true }) - writeFileSync(join(decoyDir, 'settings.json'), '{}') - symlinkSync(decoyDir, claudeSymlink) - - try { - // The deny path is the settings.json through the symlink - const denyPath = join(TEST_DIR, claudeSymlink, 'settings.json') - - // Attacker tries to: - // 1. Delete the symlink - // 2. Create a real directory - // 3. Create malicious settings.json - const result = await runSandboxedCommandWithDenyPaths( - `rm ${claudeSymlink} && mkdir ${claudeSymlink} && echo '{"hooks":{}}' > ${claudeSymlink}/settings.json`, - [denyPath], - ) - - // The attack should fail - symlink is protected with /dev/null mount - expect(result.success).toBe(false) - - // Verify the symlink still exists on host (was not deleted) - expect(existsSync(claudeSymlink)).toBe(true) - } finally { - // Cleanup - rmSync(claudeSymlink, { force: true }) - rmSync(decoyDir, { recursive: true, force: true }) - } - }) - - it('blocks deletion of symlink in protected path', async () => { - if (getPlatform() !== 'linux') return - - // Setup: Create a symlink - const targetDir = 'symlink-target-dir' - const symlinkPath = 'protected-symlink' - mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, 'file.txt'), 'content') - symlinkSync(targetDir, symlinkPath) - - try { - const denyPath = join(TEST_DIR, symlinkPath, 'file.txt') - - // Try to just delete the symlink - const result = await runSandboxedCommandWithDenyPaths( - `rm ${symlinkPath}`, - [denyPath], - ) - - // Should fail - symlink is mounted with /dev/null - expect(result.success).toBe(false) - - // Symlink should still exist - expect(existsSync(symlinkPath)).toBe(true) - } finally { - rmSync(symlinkPath, { force: true }) - rmSync(targetDir, { recursive: true, force: true }) - } - }) - }) -}) + }, + ) + }, +) describe('macGetMandatoryDenyPatterns - Unit Tests', () => { it('includes .git/config in deny patterns when allowGitConfig is false', () => { diff --git a/test/sandbox/pid-namespace-isolation.test.ts b/test/sandbox/pid-namespace-isolation.test.ts index 14ebf829..5bcb7291 100644 --- a/test/sandbox/pid-namespace-isolation.test.ts +++ b/test/sandbox/pid-namespace-isolation.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeAll } from 'bun:test' import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { getPlatform } from '../../src/utils/platform.js' import { getApplySeccompBinaryPath, getPreGeneratedBpfPath, @@ -10,6 +9,7 @@ import { wrapCommandWithSandboxLinux, checkLinuxDependencies, } from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' /** * Tests for the nested PID namespace isolation in apply-seccomp. @@ -23,10 +23,6 @@ import { * bwrap wrapper to verify both layers hold. */ -function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' -} - let applySeccomp: string | null = null let bpfFilter: string | null = null @@ -45,15 +41,12 @@ function runApplySeccomp( } } -describe('apply-seccomp PID namespace isolation', () => { +describe.if(isLinux)('apply-seccomp PID namespace isolation', () => { beforeAll(() => { - if (skipIfNotLinux()) return applySeccomp = getApplySeccompBinaryPath() bpfFilter = getPreGeneratedBpfPath() - }) - - it('has the apply-seccomp binary and BPF filter available', () => { - if (skipIfNotLinux()) return + // On Linux CI with vendor/seccomp files present these always resolve. + // If they're null, every test below would silently no-op — fail here. expect(applySeccomp).toBeTruthy() expect(bpfFilter).toBeTruthy() expect(existsSync(applySeccomp!)).toBe(true) @@ -65,8 +58,6 @@ describe('apply-seccomp PID namespace isolation', () => { // ------------------------------------------------------------------ it('runs the command as PID 2 under an apply-seccomp init (PID 1)', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'sh', '-c', @@ -78,8 +69,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('shows only the inner namespace in /proc', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'sh', '-c', @@ -97,8 +86,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('forwards exit codes from the inner command', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - expect(runApplySeccomp(['sh', '-c', 'exit 0']).status).toBe(0) expect(runApplySeccomp(['sh', '-c', 'exit 1']).status).toBe(1) expect(runApplySeccomp(['sh', '-c', 'exit 42']).status).toBe(42) @@ -106,15 +93,11 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('forwards signal exits as 128+signo', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp(['sh', '-c', 'kill -TERM $$']) expect(r.status).toBe(128 + 15) }) it('forwards SIGTERM from the outside through both inits to the command', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - // PID 1 drops signals it has no handler for. apply-seccomp's inner init // must install handlers so SIGTERM from the caller actually reaches the // workload. @@ -136,8 +119,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('reaps orphaned grandchildren without leaking zombies', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - // Spawn a grandchild that outlives its parent; inner init (PID 1) // must reap it. If reaping is broken this either hangs or leaves // the grandchild running — the timeout catches both. @@ -148,8 +129,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('exits when the main command exits, even with a long-running background process', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - // Inner init must return as soon as the worker exits, not wait for // reparented background children. PID 1 exiting tears down the // namespace and SIGKILLs the straggler. @@ -164,8 +143,6 @@ describe('apply-seccomp PID namespace isolation', () => { // ------------------------------------------------------------------ it('blocks AF_UNIX socket creation', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -178,8 +155,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('allows AF_INET socket creation', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -190,8 +165,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('blocks io_uring_setup (IORING_OP_SOCKET bypass of socket() filter)', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - // IORING_OP_SOCKET (Linux 5.19+) creates sockets in kernel context, // bypassing seccomp's socket() rule. The filter must block // io_uring_setup so no ring can be created. @@ -213,8 +186,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('blocks io_uring_enter (covers inherited ring fd)', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -234,8 +205,6 @@ describe('apply-seccomp PID namespace isolation', () => { // ------------------------------------------------------------------ it('denies ptrace(PTRACE_ATTACH) against PID 1', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -253,8 +222,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('denies opening /proc/1/mem for writing', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -273,8 +240,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('runs the user command with zero effective capabilities', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - // bwrap passes CAP_SYS_ADMIN (ambient) so apply-seccomp can nest a // PID+mount namespace. apply-seccomp must clear the ambient set before // exec so the workload cannot, e.g., umount /proc to reveal the outer @@ -285,8 +250,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('denies umount(/proc) from the user command', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -303,8 +266,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('denies process_vm_writev against PID 1', () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter) return - const r = runApplySeccomp([ 'python3', '-c', @@ -331,8 +292,6 @@ describe('apply-seccomp PID namespace isolation', () => { // ------------------------------------------------------------------ it('fails closed when the BPF filter is missing', () => { - if (skipIfNotLinux() || !applySeccomp) return - const r = spawnSync( applySeccomp!, ['/nonexistent/filter.bpf', 'echo', 'x'], @@ -346,8 +305,6 @@ describe('apply-seccomp PID namespace isolation', () => { }) it('fails closed when the BPF filter is malformed', () => { - if (skipIfNotLinux() || !applySeccomp) return - // /etc/hostname is not a multiple of 8 bytes and not a valid BPF program. const r = spawnSync(applySeccomp!, ['/etc/hostname', 'echo', 'leaked'], { stdio: 'pipe', @@ -358,99 +315,92 @@ describe('apply-seccomp PID namespace isolation', () => { }) }) -describe('Full bwrap integration — outer processes are unreachable', () => { - beforeAll(() => { - if (skipIfNotLinux()) return - applySeccomp = getApplySeccompBinaryPath() - bpfFilter = getPreGeneratedBpfPath() - }) - - function canRunBwrap(): boolean { - return checkLinuxDependencies().errors.length === 0 - } - - it('hides outer-namespace helpers (socat analogue) from the inner command', async () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter || !canRunBwrap()) - return - - // Spawn a background `sleep` in the outer bwrap namespace (stand-in for - // socat), then run apply-seccomp. The inner command must not see `sleep` - // in /proc. - const wrapped = await wrapCommandWithSandboxLinux({ - command: [ - 'sleep 30 &', - 'SLEEP_OUTER=$!', - 'for p in /proc/[0-9]*; do cat "$p/comm" 2>/dev/null; done | grep -qx sleep', - 'OUTER_SAW=$?', - `${applySeccomp} ${bpfFilter} sh -c '` + - 'for p in /proc/[0-9]*; do cat "$p/comm" 2>/dev/null; done | grep -qx sleep; ' + - 'echo "INNER_SAW=$?"' + - `'`, - 'kill $SLEEP_OUTER 2>/dev/null', - 'echo "OUTER_SAW=$OUTER_SAW"', - ].join('\n'), - needsNetworkRestriction: false, - writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, - allowAllUnixSockets: true, // we invoke apply-seccomp ourselves here - }) - - const r = spawnSync('bash', ['-c', wrapped], { - stdio: 'pipe', - timeout: 15000, +describe.if(isLinux)( + 'Full bwrap integration — outer processes are unreachable', + () => { + beforeAll(() => { + applySeccomp = getApplySeccompBinaryPath() + bpfFilter = getPreGeneratedBpfPath() + expect(applySeccomp).toBeTruthy() + expect(bpfFilter).toBeTruthy() + // CI apt-installs bwrap and socat; if missing the suite would no-op. + expect(checkLinuxDependencies().errors).toEqual([]) }) - const out = r.stdout?.toString() ?? '' - // Outer namespace sees the sleep (grep exit 0), inner does not (grep exit 1). - expect(out).toContain('OUTER_SAW=0') - expect(out).toContain('INNER_SAW=1') - }) - it('cannot ptrace the real bwrap init from inside the sandbox', async () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter || !canRunBwrap()) - return - - // With the normal wrapper (seccomp on), PID 1 from the user command's - // view is apply-seccomp's non-dumpable init, not bwrap. - const wrapped = await wrapCommandWithSandboxLinux({ - command: [ - 'python3 -c "', - 'import ctypes, os', - 'libc = ctypes.CDLL(None, use_errno=True)', - 'r = libc.ptrace(16, 1, 0, 0)', - 'err = ctypes.get_errno()', - 'comm = open(\\"/proc/1/comm\\").read().strip()', - 'print(f\\"ptrace={r} errno={err} pid1={comm}\\")', - '"', - ].join('\n'), - needsNetworkRestriction: false, - writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + it('hides outer-namespace helpers (socat analogue) from the inner command', async () => { + // Spawn a background `sleep` in the outer bwrap namespace (stand-in for + // socat), then run apply-seccomp. The inner command must not see `sleep` + // in /proc. + const wrapped = await wrapCommandWithSandboxLinux({ + command: [ + 'sleep 30 &', + 'SLEEP_OUTER=$!', + 'for p in /proc/[0-9]*; do cat "$p/comm" 2>/dev/null; done | grep -qx sleep', + 'OUTER_SAW=$?', + `${applySeccomp} ${bpfFilter} sh -c '` + + 'for p in /proc/[0-9]*; do cat "$p/comm" 2>/dev/null; done | grep -qx sleep; ' + + 'echo "INNER_SAW=$?"' + + `'`, + 'kill $SLEEP_OUTER 2>/dev/null', + 'echo "OUTER_SAW=$OUTER_SAW"', + ].join('\n'), + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + allowAllUnixSockets: true, // we invoke apply-seccomp ourselves here + }) + + const r = spawnSync('bash', ['-c', wrapped], { + stdio: 'pipe', + timeout: 15000, + }) + const out = r.stdout?.toString() ?? '' + // Outer namespace sees the sleep (grep exit 0), inner does not (grep exit 1). + expect(out).toContain('OUTER_SAW=0') + expect(out).toContain('INNER_SAW=1') }) - const r = spawnSync('bash', ['-c', wrapped], { - stdio: 'pipe', - timeout: 15000, + it('cannot ptrace the real bwrap init from inside the sandbox', async () => { + // With the normal wrapper (seccomp on), PID 1 from the user command's + // view is apply-seccomp's non-dumpable init, not bwrap. + const wrapped = await wrapCommandWithSandboxLinux({ + command: [ + 'python3 -c "', + 'import ctypes, os', + 'libc = ctypes.CDLL(None, use_errno=True)', + 'r = libc.ptrace(16, 1, 0, 0)', + 'err = ctypes.get_errno()', + 'comm = open(\\"/proc/1/comm\\").read().strip()', + 'print(f\\"ptrace={r} errno={err} pid1={comm}\\")', + '"', + ].join('\n'), + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) + + const r = spawnSync('bash', ['-c', wrapped], { + stdio: 'pipe', + timeout: 15000, + }) + const out = r.stdout?.toString() ?? '' + expect(out).toMatch(/ptrace=-1/) + expect(out).toMatch(/pid1=apply-seccomp/) }) - const out = r.stdout?.toString() ?? '' - expect(out).toMatch(/ptrace=-1/) - expect(out).toMatch(/pid1=apply-seccomp/) - }) - it('cannot open bwrap /proc/1/mem from inside the sandbox', async () => { - if (skipIfNotLinux() || !applySeccomp || !bpfFilter || !canRunBwrap()) - return + it('cannot open bwrap /proc/1/mem from inside the sandbox', async () => { + const wrapped = await wrapCommandWithSandboxLinux({ + command: + 'python3 -c "open(\\"/proc/1/mem\\", \\"r+b\\")" 2>&1 || echo BLOCKED', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) - const wrapped = await wrapCommandWithSandboxLinux({ - command: - 'python3 -c "open(\\"/proc/1/mem\\", \\"r+b\\")" 2>&1 || echo BLOCKED', - needsNetworkRestriction: false, - writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, - }) - - const r = spawnSync('bash', ['-c', wrapped], { - stdio: 'pipe', - timeout: 15000, + const r = spawnSync('bash', ['-c', wrapped], { + stdio: 'pipe', + timeout: 15000, + }) + const out = r.stdout?.toString() ?? '' + expect(out).toContain('BLOCKED') + expect(out.toLowerCase()).toMatch(/permission denied/) }) - const out = r.stdout?.toString() ?? '' - expect(out).toContain('BLOCKED') - expect(out.toLowerCase()).toMatch(/permission denied/) - }) -}) + }, +) diff --git a/test/sandbox/seccomp-filter.test.ts b/test/sandbox/seccomp-filter.test.ts index 1e84cab7..78d40dbb 100644 --- a/test/sandbox/seccomp-filter.test.ts +++ b/test/sandbox/seccomp-filter.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeAll } from 'bun:test' +import { describe, it, expect } from 'bun:test' import { existsSync, statSync } from 'node:fs' -import { getPlatform } from '../../src/utils/platform.js' import { whichSync } from '../../src/utils/which.js' +import { isLinux } from '../helpers/platform.js' import { generateSeccompFilter, cleanupSeccompFilter, @@ -13,20 +13,8 @@ import { checkLinuxDependencies, } from '../../src/sandbox/linux-sandbox-utils.js' -function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' -} - -function skipIfNotAnt(): boolean { - return process.env.USER_TYPE !== 'ant' -} - -describe('Linux Sandbox Dependencies', () => { +describe.if(isLinux)('Linux Sandbox Dependencies', () => { it('should check for Linux sandbox dependencies', () => { - if (skipIfNotLinux()) { - return - } - const depCheck = checkLinuxDependencies() expect(depCheck).toHaveProperty('errors') expect(depCheck).toHaveProperty('warnings') @@ -39,12 +27,8 @@ describe('Linux Sandbox Dependencies', () => { }) }) -describe('Pre-generated BPF Support', () => { +describe.if(isLinux)('Pre-generated BPF Support', () => { it('should detect pre-generated BPF files on x64/arm64', () => { - if (skipIfNotLinux()) { - return - } - // Check if current architecture supports pre-generated BPF const arch = process.arch const preGeneratedBpf = getPreGeneratedBpfPath() @@ -64,10 +48,6 @@ describe('Pre-generated BPF Support', () => { }) it('should have sandbox dependencies on x64/arm64 with bwrap and socat', () => { - if (skipIfNotLinux()) { - return - } - const preGeneratedBpf = getPreGeneratedBpfPath() // Only test on architectures with pre-generated BPF @@ -96,10 +76,6 @@ describe('Pre-generated BPF Support', () => { }) it('should not allow seccomp on unsupported architectures', () => { - if (skipIfNotLinux()) { - return - } - const preGeneratedBpf = getPreGeneratedBpfPath() // Only test on architectures WITHOUT pre-generated BPF @@ -124,12 +100,8 @@ describe('Pre-generated BPF Support', () => { }) }) -describe('Seccomp Filter (Pre-generated)', () => { +describe.if(isLinux)('Seccomp Filter (Pre-generated)', () => { it('should return pre-generated BPF filter on x64/arm64', () => { - if (skipIfNotLinux()) { - return - } - const arch = process.arch if (arch !== 'x64' && arch !== 'arm64') { // Not a supported architecture @@ -154,10 +126,6 @@ describe('Seccomp Filter (Pre-generated)', () => { }) it('should return same path on repeated calls (pre-generated)', () => { - if (skipIfNotLinux()) { - return - } - const arch = process.arch if (arch !== 'x64' && arch !== 'arm64') { return @@ -174,10 +142,6 @@ describe('Seccomp Filter (Pre-generated)', () => { }) it('should return null on unsupported architectures', () => { - if (skipIfNotLinux()) { - return - } - const arch = process.arch if (arch === 'x64' || arch === 'arm64') { // This test is for unsupported architectures only @@ -189,10 +153,6 @@ describe('Seccomp Filter (Pre-generated)', () => { }) it('should handle cleanup gracefully (no-op for pre-generated files)', () => { - if (skipIfNotLinux()) { - return - } - // Cleanup should not throw for any path (it's a no-op) expect(() => cleanupSeccompFilter('/tmp/test.bpf')).not.toThrow() expect(() => @@ -202,12 +162,8 @@ describe('Seccomp Filter (Pre-generated)', () => { }) }) -describe('Apply Seccomp Binary', () => { +describe.if(isLinux)('Apply Seccomp Binary', () => { it('should find pre-built apply-seccomp binary on x64/arm64', () => { - if (skipIfNotLinux()) { - return - } - const arch = process.arch if (arch !== 'x64' && arch !== 'arm64') { return @@ -224,10 +180,6 @@ describe('Apply Seccomp Binary', () => { }) it('should return null on unsupported architectures', () => { - if (skipIfNotLinux()) { - return - } - const arch = process.arch if (arch === 'x64' || arch === 'arm64') { return @@ -238,297 +190,8 @@ describe('Apply Seccomp Binary', () => { }) }) -describe('Architecture Support', () => { - it('should fail fast when architecture is unsupported and seccomp is needed', async () => { - if (skipIfNotLinux() || skipIfNotAnt()) { - return - } - - // This test documents the expected behavior: - // When the architecture is not x64/arm64, the sandbox should fail the dependency - // check instead of silently running without seccomp protection - - // The actual check happens in: - // 1. checkLinuxDependencies() checks for apply-seccomp binary availability - // 2. Returns warnings if binary not available for the current architecture - // 3. Caller decides policy (e.g. allowAllUnixSockets bypasses seccomp requirement) - expect(true).toBe(true) // Placeholder - actual behavior verified by integration tests - }) - - it('should include architecture information in error messages', () => { - if (skipIfNotLinux() || skipIfNotAnt()) { - return - } - - // Verify error messages mention architecture support and alternatives - // This is a documentation test to ensure error messages are helpful - const expectedInErrorMessage = [ - 'x64', - 'arm64', - 'architecture', - 'allowAllUnixSockets', - ] - - // Error messages should guide users to either: - // 1. Use a supported architecture (x64/arm64), OR - // 2. Set allowAllUnixSockets: true to opt out - expect(expectedInErrorMessage.length).toBeGreaterThan(0) - }) - - it('should allow bypassing architecture requirement with allowAllUnixSockets', async () => { - if (skipIfNotLinux()) { - return - } - - // When allowAllUnixSockets is true, architecture check should not matter - const testCommand = 'echo "test"' - - // This should NOT throw even on unsupported architecture (when allowAllUnixSockets=true) - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - allowAllUnixSockets: true, // Bypass seccomp - }) - - // Command should not contain apply-seccomp binary - expect(wrappedCommand).not.toContain('apply-seccomp') - expect(wrappedCommand).toContain('echo "test"') - }) -}) - -describe('USER_TYPE Gating', () => { - it('should only apply seccomp in sandbox for ANT users', async () => { - if (skipIfNotLinux()) { - return - } - - if (checkLinuxDependencies().errors.length > 0) { - return - } - - const testCommand = 'echo "test"' - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - if (process.env.USER_TYPE === 'ant') { - // ANT users should have apply-seccomp binary in command - expect(wrappedCommand).toContain('apply-seccomp') - } else { - // Non-ANT users should not have seccomp - expect(wrappedCommand).not.toContain('apply-seccomp') - } - }) -}) - -describe('Socket Filtering Behavior', () => { - let filterPath: string | null = null - - beforeAll(() => { - if (skipIfNotLinux() || skipIfNotAnt()) { - return - } - - filterPath = generateSeccompFilter() - }) - - it('should block Unix socket creation (SOCK_STREAM)', async () => { - if (skipIfNotLinux() || skipIfNotAnt() || !filterPath) { - return - } - - const testCommand = `python3 -c "import socket; s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); print('Unix socket created')"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - expect(result.status).not.toBe(0) - const stderr = result.stderr?.toString() || '' - expect(stderr.toLowerCase()).toMatch( - /permission denied|operation not permitted/, - ) - }) - - it('should block Unix socket creation (SOCK_DGRAM)', async () => { - if (skipIfNotLinux() || skipIfNotAnt() || !filterPath) { - return - } - - const testCommand = `python3 -c "import socket; s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM); print('Unix datagram created')"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - expect(result.status).not.toBe(0) - const stderr = result.stderr?.toString() || '' - expect(stderr.toLowerCase()).toMatch( - /permission denied|operation not permitted/, - ) - }) - - it('should allow TCP socket creation (IPv4)', async () => { - if (skipIfNotLinux() || skipIfNotAnt() || !filterPath) { - return - } - - const testCommand = `python3 -c "import socket; s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); print('TCP socket created')"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout?.toString()).toContain('TCP socket created') - }) - - it('should allow UDP socket creation (IPv4)', async () => { - if (skipIfNotLinux() || skipIfNotAnt() || !filterPath) { - return - } - - const testCommand = `python3 -c "import socket; s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); print('UDP socket created')"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout?.toString()).toContain('UDP socket created') - }) - - it('should allow IPv6 socket creation', async () => { - if (skipIfNotLinux() || skipIfNotAnt() || !filterPath) { - return - } - - const testCommand = `python3 -c "import socket; s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM); print('IPv6 socket created')"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stdout?.toString()).toContain('IPv6 socket created') - }) -}) - -describe('Two-Stage Seccomp Application', () => { - it('should allow network infrastructure to run before filter', async () => { - if (skipIfNotLinux() || skipIfNotAnt()) { - return - } - - if (checkLinuxDependencies().errors.length > 0) { - return - } - - // This test verifies that the socat processes can start successfully - // even though they use Unix sockets, because they run before the filter - const testCommand = 'echo "test"' - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - // Command should include both socat and the apply-seccomp binary - expect(wrappedCommand).toContain('socat') - expect(wrappedCommand).toContain('apply-seccomp') - - // The socat should come before the apply-seccomp - const socatIndex = wrappedCommand.indexOf('socat') - const seccompIndex = wrappedCommand.indexOf('apply-seccomp') - expect(socatIndex).toBeGreaterThan(-1) - expect(seccompIndex).toBeGreaterThan(-1) - expect(socatIndex).toBeLessThan(seccompIndex) - }) - - it('should execute user command with filter applied', async () => { - if (skipIfNotLinux() || skipIfNotAnt()) { - return - } - - if (checkLinuxDependencies().errors.length > 0) { - return - } - - // User command tries to create Unix socket - should fail - const testCommand = `python3 -c "import socket; socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)"` - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const result = spawnSync('bash', ['-c', wrappedCommand], { - stdio: 'pipe', - timeout: 5000, - }) - - // Should fail due to seccomp filter - expect(result.status).not.toBe(0) - }) -}) - -describe('Sandbox Integration', () => { - it('should handle commands without network or filesystem restrictions', async () => { - if (skipIfNotLinux()) { - return - } - - if (checkLinuxDependencies().errors.length > 0) { - return - } - - const testCommand = 'echo "hello world"' - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - // Should still wrap the command even without restrictions - expect(wrappedCommand).toBeTruthy() - expect(typeof wrappedCommand).toBe('string') - }) - +describe.if(isLinux)('Sandbox Integration', () => { it('should wrap commands with filesystem restrictions', async () => { - if (skipIfNotLinux()) { - return - } - if (checkLinuxDependencies().errors.length > 0) { return } @@ -546,38 +209,10 @@ describe('Sandbox Integration', () => { expect(wrappedCommand).toBeTruthy() expect(wrappedCommand).toContain('bwrap') }) - - it('should include seccomp for ANT users with dependencies', async () => { - if (skipIfNotLinux()) { - return - } - - if (checkLinuxDependencies().errors.length > 0) { - return - } - - const testCommand = 'echo "test"' - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: testCommand, - needsNetworkRestriction: false, - }) - - const isAnt = process.env.USER_TYPE === 'ant' - - if (isAnt) { - expect(wrappedCommand).toContain('apply-seccomp') - } else { - expect(wrappedCommand).not.toContain('apply-seccomp') - } - }) }) -describe('Error Handling', () => { +describe.if(isLinux)('Error Handling', () => { it('should handle cleanup calls gracefully (no-op)', () => { - if (skipIfNotLinux()) { - return - } - // Cleanup is a no-op for pre-generated files, should never throw expect(() => cleanupSeccompFilter('')).not.toThrow() expect(() => cleanupSeccompFilter('/invalid/path/filter.bpf')).not.toThrow() @@ -588,12 +223,8 @@ describe('Error Handling', () => { }) }) -describe('Custom Seccomp Paths (expectedPath parameter)', () => { +describe.if(isLinux)('Custom Seccomp Paths (expectedPath parameter)', () => { it('should use expectedPath for BPF when provided and file exists', () => { - if (skipIfNotLinux()) { - return - } - const realPath = getPreGeneratedBpfPath() if (!realPath) { // Skip if no real BPF available on this architecture @@ -606,10 +237,6 @@ describe('Custom Seccomp Paths (expectedPath parameter)', () => { }) it('should use expectedPath for apply-seccomp when provided and file exists', () => { - if (skipIfNotLinux()) { - return - } - const realPath = getApplySeccompBinaryPath() if (!realPath) { // Skip if no real binary available on this architecture @@ -622,10 +249,6 @@ describe('Custom Seccomp Paths (expectedPath parameter)', () => { }) it('should fall back to default paths when expectedPath for BPF does not exist', () => { - if (skipIfNotLinux()) { - return - } - const nonExistentPath = '/tmp/nonexistent-seccomp.bpf' const result = getPreGeneratedBpfPath(nonExistentPath) @@ -640,10 +263,6 @@ describe('Custom Seccomp Paths (expectedPath parameter)', () => { }) it('should fall back to default paths when expectedPath for apply-seccomp does not exist', () => { - if (skipIfNotLinux()) { - return - } - const nonExistentPath = '/tmp/nonexistent-apply-seccomp' const result = getApplySeccompBinaryPath(nonExistentPath) @@ -658,10 +277,6 @@ describe('Custom Seccomp Paths (expectedPath parameter)', () => { }) it('should pass seccompConfig through wrapCommandWithSandboxLinux', async () => { - if (skipIfNotLinux()) { - return - } - if (checkLinuxDependencies().errors.length > 0) { return } @@ -687,10 +302,6 @@ describe('Custom Seccomp Paths (expectedPath parameter)', () => { }) it('should use custom seccompConfig paths when they exist', async () => { - if (skipIfNotLinux()) { - return - } - if (checkLinuxDependencies().errors.length > 0) { return } diff --git a/test/sandbox/symlink-boundary.test.ts b/test/sandbox/symlink-boundary.test.ts index 8065993c..69292616 100644 --- a/test/sandbox/symlink-boundary.test.ts +++ b/test/sandbox/symlink-boundary.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import { spawnSync } from 'node:child_process' import { existsSync, mkdirSync, rmSync, unlinkSync, lstatSync } from 'node:fs' import { join } from 'node:path' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import { isSymlinkOutsideBoundary, @@ -19,9 +18,7 @@ import type { FsWriteRestrictionConfig } from '../../src/sandbox/sandbox-schemas * preserved rather than the resolved path. */ -function skipIfNotMacOS(): boolean { - return getPlatform() !== 'macos' -} +import { isMacOS } from '../helpers/platform.js' /** * Safely remove /tmp/claude if it exists (file, directory, or symlink) @@ -44,7 +41,7 @@ function cleanupTmpClaude(): void { } } -describe('macOS Seatbelt Symlink Boundary Validation', () => { +describe.if(isMacOS)('macOS Seatbelt Symlink Boundary Validation', () => { // Use unique test directories per run // Use /private/tmp (not os.tmpdir()) so test paths are outside any // default-allowed write location @@ -56,10 +53,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { const TEST_CONTENT = 'TEST_CONTENT' beforeEach(() => { - if (skipIfNotMacOS()) { - return - } - // Clean up any existing /tmp/claude symlink from previous runs cleanupTmpClaude() @@ -71,10 +64,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { }) afterEach(() => { - if (skipIfNotMacOS()) { - return - } - // Clean up /tmp/claude cleanupTmpClaude() @@ -89,10 +78,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { describe('Symlink Boundary Enforcement', () => { it('should preserve original path when symlink points to root', () => { - if (skipIfNotMacOS()) { - return - } - // Step 1: Verify sandbox correctly blocks writes outside workspace console.log('\n=== Step 1: Initial write attempt (should be blocked) ===') @@ -172,10 +157,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { }) it('should block writes outside workspace when /tmp/claude does not exist', () => { - if (skipIfNotMacOS()) { - return - } - // Ensure /tmp/claude doesn't exist cleanupTmpClaude() expect(existsSync('/tmp/claude')).toBe(false) @@ -204,10 +185,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { }) it('should block writes outside workspace when /tmp/claude is a regular directory', () => { - if (skipIfNotMacOS()) { - return - } - // Create /tmp/claude as a regular directory cleanupTmpClaude() mkdirSync('/tmp/claude', { recursive: true }) @@ -240,10 +217,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { }) it('should block writes via symlink traversal path', () => { - if (skipIfNotMacOS()) { - return - } - // Create symlink /tmp/claude -> / cleanupTmpClaude() spawnSync('ln', ['-s', '/', '/tmp/claude'], { encoding: 'utf8' }) @@ -285,10 +258,6 @@ describe('macOS Seatbelt Symlink Boundary Validation', () => { describe('isSymlinkOutsideBoundary Integration', () => { it('should reject symlink resolution that broadens scope', () => { - if (skipIfNotMacOS()) { - return - } - // Create symlink pointing to root cleanupTmpClaude() spawnSync('ln', ['-s', '/', '/tmp/claude'], { encoding: 'utf8' }) @@ -424,7 +393,7 @@ describe('isSymlinkOutsideBoundary Unit Tests', () => { /** * Tests for glob pattern symlink boundary validation */ -describe('Glob Pattern Symlink Boundary', () => { +describe.if(isMacOS)('Glob Pattern Symlink Boundary', () => { function cleanupTmpClaude(): void { const paths = ['/tmp/claude', '/private/tmp/claude'] for (const p of paths) { @@ -442,10 +411,6 @@ describe('Glob Pattern Symlink Boundary', () => { } it('should preserve original glob pattern when base directory symlink points to root', () => { - if (getPlatform() !== 'macos') { - return - } - // Clean up and create symlink cleanupTmpClaude() spawnSync('ln', ['-s', '/', '/tmp/claude'], { encoding: 'utf8' }) @@ -462,10 +427,6 @@ describe('Glob Pattern Symlink Boundary', () => { }) it('should preserve original glob pattern when base directory symlink points to parent', () => { - if (getPlatform() !== 'macos') { - return - } - // Clean up and create symlink pointing to /tmp (parent) cleanupTmpClaude() spawnSync('ln', ['-s', '/tmp', '/tmp/claude'], { encoding: 'utf8' }) diff --git a/test/sandbox/symlink-write-path.test.ts b/test/sandbox/symlink-write-path.test.ts index c8e6cd1d..073efdcb 100644 --- a/test/sandbox/symlink-write-path.test.ts +++ b/test/sandbox/symlink-write-path.test.ts @@ -10,12 +10,8 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { getPlatform } from '../../src/utils/platform.js' import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' - -function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' -} +import { isLinux } from '../helpers/platform.js' /** * Unit tests for symlink write path detection in generateFilesystemArgs. @@ -24,27 +20,23 @@ function skipIfNotLinux(): boolean { * bwrap would follow the symlink and make the target writable. The fix detects * this and skips the path with a warning. */ -describe('Symlink write path detection (unit)', () => { +describe.if(isLinux)('Symlink write path detection (unit)', () => { const TEST_ID = `symlink-write-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const TEST_BASE = join(tmpdir(), TEST_ID) const USER_AREA = join(TEST_BASE, 'user_area') const PROTECTED = join(TEST_BASE, 'protected') beforeEach(() => { - if (skipIfNotLinux()) return mkdirSync(USER_AREA, { recursive: true }) mkdirSync(PROTECTED, { recursive: true }) writeFileSync(join(PROTECTED, 'secret.txt'), 'secret data') }) afterEach(() => { - if (skipIfNotLinux()) return rmSync(TEST_BASE, { recursive: true, force: true }) }) it('should include normal (non-symlink) write paths in bwrap args', async () => { - if (skipIfNotLinux()) return - const result = await wrapCommandWithSandboxLinux({ command: 'echo hello', needsNetworkRestriction: false, @@ -61,8 +53,6 @@ describe('Symlink write path detection (unit)', () => { }) it('should skip symlink write paths pointing outside expected boundaries', async () => { - if (skipIfNotLinux()) return - // Create symlink: user_area/evil -> protected/ const evilLink = join(USER_AREA, 'evil') symlinkSync(PROTECTED, evilLink) @@ -86,8 +76,6 @@ describe('Symlink write path detection (unit)', () => { }) it('should keep legitimate write paths while skipping symlink paths', async () => { - if (skipIfNotLinux()) return - // Create symlink: user_area/evil -> protected/ const evilLink = join(USER_AREA, 'evil') symlinkSync(PROTECTED, evilLink) @@ -109,8 +97,6 @@ describe('Symlink write path detection (unit)', () => { }) it('should skip write paths that cannot be resolved', async () => { - if (skipIfNotLinux()) return - // Create a broken symlink const brokenLink = join(USER_AREA, 'broken') symlinkSync('/nonexistent/path/that/does/not/exist', brokenLink) @@ -131,8 +117,6 @@ describe('Symlink write path detection (unit)', () => { }) it('should allow symlinks that resolve within the same directory', async () => { - if (skipIfNotLinux()) return - // Create a subdirectory and a symlink within user_area pointing to it const subdir = join(USER_AREA, 'actual_data') mkdirSync(subdir, { recursive: true }) @@ -154,8 +138,6 @@ describe('Symlink write path detection (unit)', () => { }) it('should include write paths with trailing slashes (not treat them as symlinks)', async () => { - if (skipIfNotLinux()) return - // When normalizedPath has a trailing slash, realpathSync returns it without one. // The comparison `resolvedPath !== normalizedPath` would incorrectly be true, // potentially causing the path to be skipped as if it were a symlink. @@ -183,27 +165,23 @@ describe('Symlink write path detection (unit)', () => { * These tests create actual symlinks and verify that the sandbox correctly * prevents writes through symlinks pointing outside allowed boundaries. */ -describe('Symlink write path detection (integration)', () => { +describe.if(isLinux)('Symlink write path detection (integration)', () => { const TEST_ID = `symlink-integ-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const TEST_BASE = join(tmpdir(), TEST_ID) const USER_AREA = join(TEST_BASE, 'user_area') const PROTECTED = join(TEST_BASE, 'protected') beforeEach(() => { - if (skipIfNotLinux()) return mkdirSync(USER_AREA, { recursive: true }) mkdirSync(PROTECTED, { recursive: true }) writeFileSync(join(PROTECTED, 'secret.txt'), 'secret data') }) afterEach(() => { - if (skipIfNotLinux()) return rmSync(TEST_BASE, { recursive: true, force: true }) }) it('should block writes through symlink pointing to protected directory', async () => { - if (skipIfNotLinux()) return - // Attack scenario: user_area/evil_symlink -> protected/ const evilLink = join(USER_AREA, 'evil_symlink') symlinkSync(PROTECTED, evilLink) @@ -232,8 +210,6 @@ describe('Symlink write path detection (integration)', () => { }) it('should allow normal writes to non-symlink paths', async () => { - if (skipIfNotLinux()) return - const testFile = join(USER_AREA, 'normal-write.txt') const command = await wrapCommandWithSandboxLinux({ @@ -258,8 +234,6 @@ describe('Symlink write path detection (integration)', () => { }) it('should block writes through symlink pointing to /etc', async () => { - if (skipIfNotLinux()) return - // Classic attack: src -> /etc const srcLink = join(USER_AREA, 'src') symlinkSync('/etc', srcLink) @@ -285,8 +259,6 @@ describe('Symlink write path detection (integration)', () => { }) it('should block writes through symlink pointing to parent directory', async () => { - if (skipIfNotLinux()) return - // Symlink pointing to parent (broadens scope) const parentLink = join(USER_AREA, 'parent') symlinkSync(TEST_BASE, parentLink) diff --git a/test/sandbox/update-config.test.ts b/test/sandbox/update-config.test.ts index 38127687..95c123b7 100644 --- a/test/sandbox/update-config.test.ts +++ b/test/sandbox/update-config.test.ts @@ -3,6 +3,7 @@ import { SandboxManager } from '../../src/index.js' import { connect } from 'net' import { spawnSync } from 'child_process' import { getPlatform } from '../../src/utils/platform.js' +import { isLinux } from '../helpers/platform.js' /** * Helper to make a CONNECT request through the proxy using raw TCP @@ -286,131 +287,124 @@ describe('SandboxManager.updateConfig proxy filtering', () => { * and actual network behavior with sandboxed curl commands. */ describe('SandboxManager.updateConfig integration (wrapWithSandbox)', () => { - function skipIfNotLinux(): boolean { - return getPlatform() !== 'linux' - } - afterEach(async () => { await SandboxManager.reset() }) - it('should block then allow domain after updateConfig with sandboxed curl', async () => { - if (skipIfNotLinux()) { - return - } - - // Initialize with empty allowlist (blocks all) - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) - - // First request should be blocked - const cmd1 = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 3 http://example.com 2>&1', - ) - const result1 = spawnSync(cmd1, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - const output1 = (result1.stdout + result1.stderr).toLowerCase() - // With empty allowlist, network is completely blocked (no proxy) - expect(output1).not.toContain('example domain') - - // Update config to allow example.com - SandboxManager.updateConfig({ - network: { allowedDomains: ['example.com'], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) - - // Second request should succeed - // Note: wrapWithSandbox() generates new command with updated config - const cmd2 = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 5 http://example.com 2>&1', - ) - const result2 = spawnSync(cmd2, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + it.if(isLinux)( + 'should block then allow domain after updateConfig with sandboxed curl', + async () => { + // Initialize with empty allowlist (blocks all) + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - expect(result2.status).toBe(0) - expect(result2.stdout).toContain('Example Domain') - }) + // First request should be blocked + const cmd1 = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 3 http://example.com 2>&1', + ) + const result1 = spawnSync(cmd1, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + const output1 = (result1.stdout + result1.stderr).toLowerCase() + // With empty allowlist, network is completely blocked (no proxy) + expect(output1).not.toContain('example domain') - it('should allow then block domain after updateConfig with sandboxed curl', async () => { - if (skipIfNotLinux()) { - return - } + // Update config to allow example.com + SandboxManager.updateConfig({ + network: { allowedDomains: ['example.com'], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - // Initialize with example.com allowed - await SandboxManager.initialize({ - network: { allowedDomains: ['example.com'], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) + // Second request should succeed + // Note: wrapWithSandbox() generates new command with updated config + const cmd2 = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com 2>&1', + ) + const result2 = spawnSync(cmd2, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) - // First request should succeed - const cmd1 = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 5 http://example.com 2>&1', - ) - const result1 = spawnSync(cmd1, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - expect(result1.status).toBe(0) - expect(result1.stdout).toContain('Example Domain') + expect(result2.status).toBe(0) + expect(result2.stdout).toContain('Example Domain') + }, + ) - // Update config to block all - SandboxManager.updateConfig({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) + it.if(isLinux)( + 'should allow then block domain after updateConfig with sandboxed curl', + async () => { + // Initialize with example.com allowed + await SandboxManager.initialize({ + network: { allowedDomains: ['example.com'], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - // Second request should be blocked - const cmd2 = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 3 http://example.com 2>&1', - ) - const result2 = spawnSync(cmd2, { - shell: true, - encoding: 'utf8', - timeout: 5000, - }) - const output2 = (result2.stdout + result2.stderr).toLowerCase() - expect(output2).not.toContain('example domain') - }) + // First request should succeed + const cmd1 = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com 2>&1', + ) + const result1 = spawnSync(cmd1, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + expect(result1.status).toBe(0) + expect(result1.stdout).toContain('Example Domain') - it('should allow network via curl after updateConfig when started with empty allowlist', async () => { - if (skipIfNotLinux()) { - return - } + // Update config to block all + SandboxManager.updateConfig({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - // Initialize with EMPTY allowlist - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) + // Second request should be blocked + const cmd2 = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 3 http://example.com 2>&1', + ) + const result2 = spawnSync(cmd2, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + const output2 = (result2.stdout + result2.stderr).toLowerCase() + expect(output2).not.toContain('example domain') + }, + ) + + it.if(isLinux)( + 'should allow network via curl after updateConfig when started with empty allowlist', + async () => { + // Initialize with EMPTY allowlist + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - // Update config to allow example.com - SandboxManager.updateConfig({ - network: { allowedDomains: ['example.com'], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, - }) + // Update config to allow example.com + SandboxManager.updateConfig({ + network: { allowedDomains: ['example.com'], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) - // Full integration: sandboxed curl should work - const cmd = await SandboxManager.wrapWithSandbox( - 'curl -s --max-time 5 http://example.com 2>&1', - ) - const result = spawnSync(cmd, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + // Full integration: sandboxed curl should work + const cmd = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com 2>&1', + ) + const result = spawnSync(cmd, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) - expect(result.status).toBe(0) - expect(result.stdout).toContain('Example Domain') - }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('Example Domain') + }, + ) /** * This test verifies the exact user scenario: diff --git a/test/sandbox/wrap-with-sandbox.test.ts b/test/sandbox/wrap-with-sandbox.test.ts index 663d9a3c..1dcdaf97 100644 --- a/test/sandbox/wrap-with-sandbox.test.ts +++ b/test/sandbox/wrap-with-sandbox.test.ts @@ -1,12 +1,12 @@ 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' +import { isLinux, isMacOS, isSupportedPlatform } from '../helpers/platform.js' /** * Create a test configuration with network access @@ -25,32 +25,17 @@ function createTestConfig(): SandboxRuntimeConfig { } } -function skipIfUnsupportedPlatform(): boolean { - const platform = getPlatform() - return platform !== 'linux' && platform !== 'macos' -} - -describe('wrapWithSandbox customConfig', () => { +describe.if(isSupportedPlatform)('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) @@ -62,10 +47,6 @@ describe('wrapWithSandbox customConfig', () => { 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: { @@ -81,10 +62,6 @@ describe('wrapWithSandbox customConfig', () => { }) it('uses custom denyRead when provided', async () => { - if (skipIfUnsupportedPlatform()) { - return - } - const command = 'cat /etc/passwd' const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, { filesystem: { @@ -100,10 +77,6 @@ describe('wrapWithSandbox customConfig', () => { 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: { @@ -121,10 +94,6 @@ describe('wrapWithSandbox customConfig', () => { }) 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: { @@ -141,10 +110,6 @@ describe('wrapWithSandbox customConfig', () => { 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 @@ -174,10 +139,6 @@ describe('wrapWithSandbox customConfig', () => { 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 @@ -193,10 +154,6 @@ describe('wrapWithSandbox customConfig', () => { }) it('only overrides specified network fields', async () => { - if (skipIfUnsupportedPlatform()) { - return - } - const command = 'echo test' // Only override allowedDomains @@ -224,146 +181,134 @@ 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, - }) + it.if(isLinux)( + 'returns command unchanged when no restrictions on Linux', + async () => { + // 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) - }) + expect(result).toBe(command) + }, + ) - it('returns command unchanged with undefined readConfig on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } + it.if(isMacOS)( + 'returns command unchanged when no restrictions on macOS', + () => { + // No network, empty read deny, no write config = no sandboxing + const result = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: undefined, + }) - const result = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: undefined, - }) + expect(result).toBe(command) + }, + ) - expect(result).toBe(command) - }) + it.if(isLinux)( + 'returns command unchanged with undefined readConfig on Linux', + async () => { + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: undefined, + }) - it('returns command unchanged with undefined readConfig on macOS', () => { - if (getPlatform() !== 'macos') { - return - } + expect(result).toBe(command) + }, + ) - const result = wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: undefined, - }) + it.if(isMacOS)( + 'returns command unchanged with undefined readConfig on macOS', + () => { + const result = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: undefined, + }) - expect(result).toBe(command) - }) + 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.if(isLinux)( + 'empty denyOnly means no read restrictions on Linux', + async () => { + // Only write restrictions, empty read = should sandbox but no read rules + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) - it('non-empty denyOnly means has read restrictions on Linux', async () => { - if (getPlatform() !== 'linux') { - return - } + // Should wrap because of write restrictions + expect(result).not.toBe(command) + expect(result).toContain('bwrap') + }, + ) - const result = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: ['/secret'] }, - writeConfig: undefined, - }) + it.if(isLinux)( + 'non-empty denyOnly means has read restrictions on Linux', + async () => { + 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') - }) + // 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 - } + it.if(isLinux)( + 'undefined writeConfig means no write restrictions on Linux', + async () => { + // Has read restrictions but no write = should sandbox + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/secret'] }, + writeConfig: undefined, + }) - // Empty allowOnly = no writes allowed = has restrictions - const result = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [], denyWithinAllow: [] }, - }) + expect(result).not.toBe(command) + }, + ) - // Should wrap because empty allowOnly is still a restriction - expect(result).not.toBe(command) - expect(result).toContain('bwrap') - }) + it.if(isLinux)( + 'empty allowOnly means maximally restrictive (has restrictions) on Linux', + async () => { + // Empty allowOnly = no writes allowed = has restrictions + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) - it('any writeConfig means has restrictions on macOS', () => { - if (getPlatform() !== 'macos') { - return - } + // Should wrap because empty allowOnly is still a restriction + expect(result).not.toBe(command) + expect(result).toContain('bwrap') + }, + ) + it.if(isMacOS)('any writeConfig means has restrictions on macOS', () => { const result = wrapCommandWithSandboxMacOS({ command, needsNetworkRestriction: false, @@ -378,151 +323,145 @@ describe('restriction pattern semantics', () => { }) 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 - } + it.if(isLinux)( + 'needsNetworkRestriction false skips network sandbox on Linux', + async () => { + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/secret'] }, + writeConfig: undefined, + }) - const result = wrapCommandWithSandboxMacOS({ - 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.if(isMacOS)( + 'needsNetworkRestriction false skips network sandbox on macOS', + () => { + 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') - }) + // 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 { + it.if(isLinux)( + 'needsNetworkRestriction true without proxy sockets blocks all network on Linux', + async () => { + // Network restriction enabled but no proxy sockets = block all network const result = await wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: true, - httpSocketPath: httpSocket, - socksSocketPath: socksSocket, - httpProxyPort: 3128, - socksProxyPort: 1080, + httpSocketPath: undefined, // No proxy available + socksSocketPath: undefined, // No proxy available readConfig: { denyOnly: [] }, writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, }) - // Should wrap with network namespace isolation + // Should wrap with --unshare-net to block all network 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 - } + // Should NOT contain proxy-related environment variables since no proxy + expect(result).not.toContain('HTTP_PROXY') + }, + ) + + it.if(isMacOS)( + 'needsNetworkRestriction true without proxy ports blocks all network on macOS', + () => { + // 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: [] }, + }) - const result = wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: true, - httpProxyPort: 3128, - socksProxyPort: 1080, - 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.if(isLinux)( + 'needsNetworkRestriction true with proxy allows filtered network on Linux', + async () => { + // 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.if(isMacOS)( + 'needsNetworkRestriction true with proxy allows filtered network on macOS', + () => { + 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') - }) + // 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') + }, + ) }) }) @@ -541,9 +480,6 @@ describe('empty allowedDomains network blocking (CVE fix)', () => { 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: { @@ -559,62 +495,61 @@ describe('empty allowedDomains network blocking (CVE fix)', () => { }) 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: [], - }, - }) + it.if(isLinux)( + 'empty allowedDomains in customConfig triggers network restriction on Linux', + async () => { + 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') + }, + ) - // With the fix, empty allowedDomains should trigger sandbox - expect(result).not.toBe(command) - expect(result).toContain('sandbox-exec') - }) + it.if(isMacOS)( + 'empty allowedDomains in customConfig triggers network restriction on macOS', + async () => { + 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 @@ -634,10 +569,6 @@ describe('empty allowedDomains network blocking (CVE fix)', () => { }) 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: { @@ -659,10 +590,6 @@ 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 }) @@ -688,10 +615,6 @@ describe('allowWrite glob suffix handling', () => { }) 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 }) @@ -722,147 +645,143 @@ describe('allowWrite glob suffix handling', () => { // normalizePathForSandbox() produced a duplicate --ro-bind /dev/null . // Second bind finds a char device at ; 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, '') + it.if(isLinux)( + 'dedups denyWrite entries that normalize to the same path (Linux)', + async () => { + 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 + '/'], - }, - }) + 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) + const result = await SandboxManager.wrapWithSandbox(command) - // One --ro-bind 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 }) - } - }) + // One --ro-bind 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 }) + } + }, + ) // Regression: #190 reordered denyWrite after denyRead so .git/hooks ro-binds // survive a tmpfs over an ancestor. But denyWrite's --ro-bind // now lands after denyRead's --ro-bind /dev/null , undoing the mask // when the same file is in both lists. - it('does not let denyWrite unmask a denyRead /dev/null bind (Linux)', async () => { - if (getPlatform() !== 'linux') { - return - } - - const parentDir = join(tmpdir(), `srt-test-unmask-${Date.now()}`) - const secret = join(parentDir, 'secret.txt') - mkdirSync(parentDir, { recursive: true }) - writeFileSync(secret, '') + it.if(isLinux)( + 'does not let denyWrite unmask a denyRead /dev/null bind (Linux)', + async () => { + const parentDir = join(tmpdir(), `srt-test-unmask-${Date.now()}`) + const secret = join(parentDir, 'secret.txt') + mkdirSync(parentDir, { recursive: true }) + writeFileSync(secret, '') - try { - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [secret], - allowWrite: [parentDir], - denyWrite: [secret], - }, - }) + try { + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [secret], + allowWrite: [parentDir], + denyWrite: [secret], + }, + }) - const result = await SandboxManager.wrapWithSandbox(command) + const result = await SandboxManager.wrapWithSandbox(command) - // The /dev/null mask is what we want; the host-file bind is what we don't. - expect(result).toContain(`--ro-bind /dev/null ${secret}`) - expect(result).not.toContain(`--ro-bind ${secret} ${secret}`) - } finally { - await SandboxManager.reset() - rmSync(parentDir, { recursive: true, force: true }) - } - }) + // The /dev/null mask is what we want; the host-file bind is what we don't. + expect(result).toContain(`--ro-bind /dev/null ${secret}`) + expect(result).not.toContain(`--ro-bind ${secret} ${secret}`) + } finally { + await SandboxManager.reset() + rmSync(parentDir, { recursive: true, force: true }) + } + }, + ) // A file listed in denyRead should stay denied even if allowRead covers its // parent directory. Before this change, startsWith(allowPath + '/') matched // and the file-deny was silently skipped. - it('file-level denyRead survives a parent-directory allowRead (Linux)', async () => { - if (getPlatform() !== 'linux') { - return - } + it.if(isLinux)( + 'file-level denyRead survives a parent-directory allowRead (Linux)', + async () => { + const parentDir = join(tmpdir(), `srt-test-file-deny-${Date.now()}`) + const secret = join(parentDir, '.env') + mkdirSync(parentDir, { recursive: true }) + writeFileSync(secret, '') - const parentDir = join(tmpdir(), `srt-test-file-deny-${Date.now()}`) - const secret = join(parentDir, '.env') - mkdirSync(parentDir, { recursive: true }) - writeFileSync(secret, '') - - try { - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [secret], - allowRead: [parentDir], - allowWrite: [parentDir], - denyWrite: [], - }, - }) + try { + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [secret], + allowRead: [parentDir], + allowWrite: [parentDir], + denyWrite: [], + }, + }) - const result = await SandboxManager.wrapWithSandbox(command) + const result = await SandboxManager.wrapWithSandbox(command) - expect(result).toContain(`--ro-bind /dev/null ${secret}`) - } finally { - await SandboxManager.reset() - rmSync(parentDir, { recursive: true, force: true }) - } - }) + expect(result).toContain(`--ro-bind /dev/null ${secret}`) + } finally { + await SandboxManager.reset() + rmSync(parentDir, { recursive: true, force: true }) + } + }, + ) // denyRead entries are sorted shallow-first before mounting, so a file-deny // listed before its ancestor dir-deny still lands on top of the ancestor's // tmpfs + re-allow binds. - it('file-deny survives ancestor dir-deny listed after it in denyRead (Linux)', async () => { - if (getPlatform() !== 'linux') { - return - } + it.if(isLinux)( + 'file-deny survives ancestor dir-deny listed after it in denyRead (Linux)', + async () => { + const parentDir = join(tmpdir(), `srt-test-order-${Date.now()}`) + const projectDir = join(parentDir, 'project') + const envFile = join(projectDir, '.env') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(envFile, '') - const parentDir = join(tmpdir(), `srt-test-order-${Date.now()}`) - const projectDir = join(parentDir, 'project') - const envFile = join(projectDir, '.env') - mkdirSync(projectDir, { recursive: true }) - writeFileSync(envFile, '') - - try { - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - // File deliberately listed before the dir that contains it - denyRead: [envFile, parentDir], - allowRead: [projectDir], - allowWrite: [projectDir], - denyWrite: [], - }, - }) + try { + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + // File deliberately listed before the dir that contains it + denyRead: [envFile, parentDir], + allowRead: [projectDir], + allowWrite: [projectDir], + denyWrite: [], + }, + }) - const result = await SandboxManager.wrapWithSandbox(command) + const result = await SandboxManager.wrapWithSandbox(command) - // The /dev/null mask must come after the tmpfs + ro-bind in arg order. - const tmpfsAt = result.indexOf(`--tmpfs ${parentDir}`) - const maskAt = result.indexOf(`--ro-bind /dev/null ${envFile}`) - expect(tmpfsAt).toBeGreaterThan(-1) - expect(maskAt).toBeGreaterThan(tmpfsAt) - } finally { - await SandboxManager.reset() - rmSync(parentDir, { recursive: true, force: true }) - } - }) + // The /dev/null mask must come after the tmpfs + ro-bind in arg order. + const tmpfsAt = result.indexOf(`--tmpfs ${parentDir}`) + const maskAt = result.indexOf(`--ro-bind /dev/null ${envFile}`) + expect(tmpfsAt).toBeGreaterThan(-1) + expect(maskAt).toBeGreaterThan(tmpfsAt) + } finally { + await SandboxManager.reset() + rmSync(parentDir, { recursive: true, force: true }) + } + }, + ) }) From a42d9f8763788df0d0723e598fdcc15d9c58daa5 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 13:56:12 -0700 Subject: [PATCH 2/6] Replace mock.module with spyOn in linux-dependency-error tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mock.module patches bun's module cache globally and never unmocks. With npm test running all files in one process (instead of the old test:unit + test:integration split), the mock leaked: every file that imported getApplySeccompBinaryPath after this one got () => null, so pid-namespace-isolation.test.ts and integration.test.ts failed in beforeAll. spyOn swaps one export binding; mockRestore in afterEach puts it back. The callee's own import binding routes through the same slot in bun, so checkLinuxDependencies sees the spy without any module-level surgery. Also spies on whichSync directly rather than overwriting Bun.which on globalThis — same fix, closer to what's actually being tested. Drop stale README reference to the deleted test:integration script. --- README.md | 11 +- test/sandbox/linux-dependency-error.test.ts | 139 ++++++++------------ 2 files changed, 60 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index c2b38b11..c82a3cbe 100644 --- a/README.md +++ b/README.md @@ -288,10 +288,10 @@ Uses an **allow-only pattern** - all network access is denied by default. **Unix Socket Settings** (platform-specific behavior): -| Setting | macOS | Linux | -|---------|-------|-------| -| `allowUnixSockets: string[]` | Allowlist of socket paths | *Ignored* (seccomp can't filter by path) | -| `allowAllUnixSockets: boolean` | Allow all sockets | Disable seccomp blocking | +| Setting | macOS | Linux | +| ------------------------------ | ------------------------- | ---------------------------------------- | +| `allowUnixSockets: string[]` | Allowlist of socket paths | _Ignored_ (seccomp can't filter by path) | +| `allowAllUnixSockets: boolean` | Allow all sockets | Disable seccomp blocking | Unix sockets are **blocked by default** on both platforms. @@ -478,9 +478,6 @@ npm run build:seccomp # Run tests npm test -# Run integration tests -npm run test:integration - # Type checking npm run typecheck diff --git a/test/sandbox/linux-dependency-error.test.ts b/test/sandbox/linux-dependency-error.test.ts index 7bb60753..656a706b 100644 --- a/test/sandbox/linux-dependency-error.test.ts +++ b/test/sandbox/linux-dependency-error.test.ts @@ -1,53 +1,37 @@ -import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test' - -// Mock state - these control what the mocked functions return -let mockBwrapInstalled = true -let mockSocatInstalled = true -let mockBpfPath: string | null = null -let mockApplyPath: string | null = null - -// Store original Bun.which to restore later -const originalBunWhich = globalThis.Bun.which - -// Mock Bun.which directly - this avoids mock.module which affects other test files -globalThis.Bun.which = ((bin: string): string | null => { - if (bin === 'bwrap') { - return mockBwrapInstalled ? '/usr/bin/bwrap' : null - } - if (bin === 'socat') { - return mockSocatInstalled ? '/usr/bin/socat' : null - } - // For other binaries, use the original implementation - return originalBunWhich(bin) -}) as typeof globalThis.Bun.which - -// Mock seccomp path functions - controls whether seccomp binaries are "found" -void mock.module('../../src/sandbox/generate-seccomp-filter.js', () => ({ - getPreGeneratedBpfPath: () => mockBpfPath, - getApplySeccompBinaryPath: () => mockApplyPath, - generateSeccompFilter: () => null, - cleanupSeccompFilter: () => {}, -})) - -// Dynamic import AFTER mocking - this is required for mocks to take effect -const { checkLinuxDependencies, getLinuxDependencyStatus } = await import( - '../../src/sandbox/linux-sandbox-utils.js' -) - -// Restore original Bun.which after all tests in this file -afterAll(() => { - globalThis.Bun.which = originalBunWhich +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as which from '../../src/utils/which.js' +import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import { + checkLinuxDependencies, + getLinuxDependencyStatus, +} from '../../src/sandbox/linux-sandbox-utils.js' + +// Spies set up in beforeEach, torn down in afterEach. Each test overrides +// just the piece it's exercising. spyOn patches the export binding, so +// linux-sandbox-utils' own imports see the replacement. +let whichSpy: ReturnType +let bpfSpy: ReturnType +let applySpy: ReturnType + +beforeEach(() => { + whichSpy = spyOn(which, 'whichSync').mockImplementation( + (bin: string) => `/usr/bin/${bin}`, + ) + bpfSpy = spyOn(seccomp, 'getPreGeneratedBpfPath').mockReturnValue( + '/path/to/filter.bpf', + ) + applySpy = spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue( + '/path/to/apply-seccomp', + ) }) -describe('checkLinuxDependencies', () => { - // Reset all mocks to "everything installed" state before each test - beforeEach(() => { - mockBwrapInstalled = true - mockSocatInstalled = true - mockBpfPath = '/path/to/filter.bpf' - mockApplyPath = '/path/to/apply-seccomp' - }) +afterEach(() => { + whichSpy.mockRestore() + bpfSpy.mockRestore() + applySpy.mockRestore() +}) +describe('checkLinuxDependencies', () => { test('returns no errors or warnings when all dependencies present', () => { const result = checkLinuxDependencies() @@ -56,7 +40,9 @@ describe('checkLinuxDependencies', () => { }) test('returns error when bwrap missing', () => { - mockBwrapInstalled = false + whichSpy.mockImplementation((bin: string) => + bin === 'bwrap' ? null : `/usr/bin/${bin}`, + ) const result = checkLinuxDependencies() @@ -65,7 +51,9 @@ describe('checkLinuxDependencies', () => { }) test('returns error when socat missing', () => { - mockSocatInstalled = false + whichSpy.mockImplementation((bin: string) => + bin === 'socat' ? null : `/usr/bin/${bin}`, + ) const result = checkLinuxDependencies() @@ -74,8 +62,7 @@ describe('checkLinuxDependencies', () => { }) test('returns multiple errors when both bwrap and socat missing', () => { - mockBwrapInstalled = false - mockSocatInstalled = false + whichSpy.mockReturnValue(null) const result = checkLinuxDependencies() @@ -85,8 +72,8 @@ describe('checkLinuxDependencies', () => { }) test('returns warning (not error) when seccomp missing', () => { - mockBpfPath = null - mockApplyPath = null + bpfSpy.mockReturnValue(null) + applySpy.mockReturnValue(null) const result = checkLinuxDependencies() @@ -96,8 +83,7 @@ describe('checkLinuxDependencies', () => { }) test('returns warning when only bpf file present (no apply binary)', () => { - mockBpfPath = '/path/to/filter.bpf' - mockApplyPath = null + applySpy.mockReturnValue(null) const result = checkLinuxDependencies() @@ -105,34 +91,18 @@ describe('checkLinuxDependencies', () => { expect(result.warnings.length).toBe(1) }) - // This verifies the config parameter is actually passed through - test('uses custom seccomp paths when provided', () => { - // Default paths return null (not found) - mockBpfPath = null - mockApplyPath = null - - // But we're passing custom paths - the mock ignores them, - // so this still returns warnings. The point is it doesn't crash - // and the structure is correct. Real path validation happens in the mock. - const result = checkLinuxDependencies({ + test('passes custom seccomp paths through to the resolvers', () => { + checkLinuxDependencies({ bpfPath: '/custom/path.bpf', applyPath: '/custom/apply', }) - expect(Array.isArray(result.errors)).toBe(true) - expect(Array.isArray(result.warnings)).toBe(true) + expect(bpfSpy).toHaveBeenCalledWith('/custom/path.bpf') + expect(applySpy).toHaveBeenCalledWith('/custom/apply') }) }) describe('getLinuxDependencyStatus', () => { - beforeEach(() => { - mockBwrapInstalled = true - mockSocatInstalled = true - mockBpfPath = '/path/to/filter.bpf' - mockApplyPath = '/path/to/apply-seccomp' - }) - - // All deps installed = all flags true test('reports all available when everything installed', () => { const status = getLinuxDependencyStatus() @@ -142,34 +112,37 @@ describe('getLinuxDependencyStatus', () => { expect(status.hasSeccompApply).toBe(true) }) - // Each missing dep should show as false independently test('reports bwrap unavailable when not installed', () => { - mockBwrapInstalled = false + whichSpy.mockImplementation((bin: string) => + bin === 'bwrap' ? null : `/usr/bin/${bin}`, + ) const status = getLinuxDependencyStatus() expect(status.hasBwrap).toBe(false) - expect(status.hasSocat).toBe(true) // others unaffected + expect(status.hasSocat).toBe(true) }) test('reports socat unavailable when not installed', () => { - mockSocatInstalled = false + whichSpy.mockImplementation((bin: string) => + bin === 'socat' ? null : `/usr/bin/${bin}`, + ) const status = getLinuxDependencyStatus() expect(status.hasSocat).toBe(false) - expect(status.hasBwrap).toBe(true) // others unaffected + expect(status.hasBwrap).toBe(true) }) test('reports seccomp unavailable when files missing', () => { - mockBpfPath = null - mockApplyPath = null + bpfSpy.mockReturnValue(null) + applySpy.mockReturnValue(null) const status = getLinuxDependencyStatus() expect(status.hasSeccompBpf).toBe(false) expect(status.hasSeccompApply).toBe(false) - expect(status.hasBwrap).toBe(true) // others unaffected + expect(status.hasBwrap).toBe(true) expect(status.hasSocat).toBe(true) }) }) From d07b8ba6ade650eed79910c1542192e98602b0b7 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 14:07:49 -0700 Subject: [PATCH 3/6] Replace docker test-suite job with srt end-to-end test The full suite assumes bwrap --proc /proc works; an unprivileged container doesn't have CAP_SYS_ADMIN for that. Only tests that set enableWeakerNestedSandbox can pass there. Instead of filtering which unit tests to run, test the thing the job is for: build srt, run it with enableWeakerNestedSandbox, check that allowed writes land, denied writes don't, and the seccomp filter blocks AF_UNIX. Gated on SRT_E2E_DOCKER so host jobs skip it. --- .github/workflows/integration-tests.yml | 21 +++--- test/e2e/docker.test.ts | 87 +++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 test/e2e/docker.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e163d573..573b1569 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -89,11 +89,8 @@ jobs: *.log if-no-files-found: ignore - docker-tests: - # Run the suite inside an unprivileged container — the environment - # enableWeakerNestedSandbox targets. seccomp unconfined so bwrap can - # unshare(CLONE_NEWUSER); no CAP_SYS_ADMIN so --proc /proc fails. - name: Tests (docker / ${{ matrix.arch }}) + docker-e2e: + name: E2E (docker / ${{ matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -111,28 +108,26 @@ jobs: - name: Enable unprivileged user namespaces on host run: | - # The container shares the host kernel; this sysctl must be set on - # the host for bwrap/apply-seccomp to nest namespaces inside. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true sudo sysctl -w kernel.unprivileged_userns_clone=1 || true - - name: Run tests in unprivileged container + - name: Run srt end-to-end in unprivileged container run: | docker run --rm \ --security-opt seccomp=unconfined \ --security-opt apparmor=unconfined \ -v "${{ github.workspace }}:/work" \ -w /work \ + -e SRT_E2E_DOCKER=1 \ ubuntu:24.04 \ - bash -c ' - set -euo pipefail + bash -euo pipefail -c ' apt-get update -qq - apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip git zsh + apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip curl -fsSL https://bun.sh/install | bash export PATH="$HOME/.bun/bin:$PATH" curl -fsSL https://deb.nodesource.com/setup_18.x | bash - apt-get install -y -qq nodejs - npm install + npm ci npm run build - npm test + bun test test/e2e/docker.test.ts ' diff --git a/test/e2e/docker.test.ts b/test/e2e/docker.test.ts new file mode 100644 index 00000000..6feabb45 --- /dev/null +++ b/test/e2e/docker.test.ts @@ -0,0 +1,87 @@ +/** + * End-to-end: run srt in an unprivileged container with + * enableWeakerNestedSandbox and verify the sandbox enforces. + * + * Gated on SRT_E2E_DOCKER so `npm test` on the host jobs skips it — + * it's designed for a container that lacks CAP_SYS_ADMIN. + * + * Invoked by CI via: + * docker run --rm \ + * --security-opt seccomp=unconfined --security-opt apparmor=unconfined \ + * -v "$PWD:/work" -w /work -e SRT_E2E_DOCKER=1 \ + * ubuntu:24.04 bash -c ' && bun test test/e2e/docker.test.ts' + */ + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const inDocker = process.env.SRT_E2E_DOCKER === '1' + +describe.if(inDocker)('srt end-to-end in unprivileged container', () => { + const WORK = join(tmpdir(), `srt-e2e-${Date.now()}`) + const ALLOWED = join(WORK, 'allowed') + const DENIED = join(WORK, 'denied') + const CONFIG = join(WORK, 'srt.json') + + const srt = (cmd: string) => + spawnSync('node', ['dist/cli.js', '-s', CONFIG, '-c', cmd], { + encoding: 'utf8', + timeout: 15000, + }) + + beforeAll(() => { + mkdirSync(ALLOWED, { recursive: true }) + mkdirSync(DENIED, { recursive: true }) + writeFileSync( + CONFIG, + JSON.stringify({ + filesystem: { + denyRead: [], + allowWrite: [ALLOWED], + denyWrite: [], + }, + enableWeakerNestedSandbox: true, + }), + ) + }) + + afterAll(() => { + rmSync(WORK, { recursive: true, force: true }) + }) + + it('writes to allowWrite dir', () => { + const out = join(ALLOWED, 'out') + const r = srt(`echo ok > ${out}`) + expect(r.status).toBe(0) + expect(readFileSync(out, 'utf8').trim()).toBe('ok') + }) + + it('blocks write outside allowWrite', () => { + const out = join(DENIED, 'out') + const r = srt(`echo bad > ${out}`) + expect(r.status).not.toBe(0) + expect(existsSync(out)).toBe(false) + }) + + it('seccomp blocks AF_UNIX socket creation', () => { + const r = srt('python3 -c "import socket; socket.socket(socket.AF_UNIX)"') + expect(r.status).not.toBe(0) + expect(r.stderr.toLowerCase()).toMatch( + /permission denied|operation not permitted/, + ) + }) + + it('seccomp allows AF_INET socket creation', () => { + const r = srt('python3 -c "import socket; socket.socket(socket.AF_INET)"') + expect(r.status).toBe(0) + }) +}) From 27c90d480871adb74802ade724aa2f64ed922404 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 14:10:21 -0700 Subject: [PATCH 4/6] Rename docker job to match other Tests jobs --- .github/workflows/integration-tests.yml | 6 +++--- test/{e2e/docker.test.ts => docker-weak-sandbox.test.ts} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename test/{e2e/docker.test.ts => docker-weak-sandbox.test.ts} (100%) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 573b1569..3b548dae 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -89,8 +89,8 @@ jobs: *.log if-no-files-found: ignore - docker-e2e: - name: E2E (docker / ${{ matrix.arch }}) + docker-tests: + name: Tests (docker / ${{ matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -129,5 +129,5 @@ jobs: apt-get install -y -qq nodejs npm ci npm run build - bun test test/e2e/docker.test.ts + bun test test/docker-weak-sandbox.test.ts ' diff --git a/test/e2e/docker.test.ts b/test/docker-weak-sandbox.test.ts similarity index 100% rename from test/e2e/docker.test.ts rename to test/docker-weak-sandbox.test.ts From a58d8f0e9476cabee9ae60637870717877314940 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 14:18:10 -0700 Subject: [PATCH 5/6] Add required network key to docker test config SandboxRuntimeConfigSchema requires network (no .optional()). Without it loadConfig returns null, srt falls through to getDefaultConfig, and the sandbox enforces a different allowWrite than the test expects. --- test/docker-weak-sandbox.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/docker-weak-sandbox.test.ts b/test/docker-weak-sandbox.test.ts index 6feabb45..d6fb91dd 100644 --- a/test/docker-weak-sandbox.test.ts +++ b/test/docker-weak-sandbox.test.ts @@ -44,6 +44,7 @@ describe.if(inDocker)('srt end-to-end in unprivileged container', () => { writeFileSync( CONFIG, JSON.stringify({ + network: { allowedDomains: [], deniedDomains: [] }, filesystem: { denyRead: [], allowWrite: [ALLOWED], From 2d98d7271e6021bfa45b8a9a2727722c84312c08 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 31 Mar 2026 14:49:00 -0700 Subject: [PATCH 6/6] Add explicit timeouts to update-config sandboxed-curl tests The three it.if(isLinux) tests each run two spawnSync calls with curl --max-time 3 then --max-time 5. When example.com responds slowly both curls run to their limits and the body takes ~8s, but bun's default test timeout is 5000ms. bun aborts mid-body; afterEach runs reset() against an in-flight spawn and the next test sees stale state. These were never in test:integration so they never ran on CI before this branch. On fast responses they complete in under 200ms. --- test/sandbox/update-config.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/sandbox/update-config.test.ts b/test/sandbox/update-config.test.ts index 95c123b7..81302f00 100644 --- a/test/sandbox/update-config.test.ts +++ b/test/sandbox/update-config.test.ts @@ -333,6 +333,7 @@ describe('SandboxManager.updateConfig integration (wrapWithSandbox)', () => { expect(result2.status).toBe(0) expect(result2.stdout).toContain('Example Domain') }, + 20000, ) it.if(isLinux)( @@ -374,6 +375,7 @@ describe('SandboxManager.updateConfig integration (wrapWithSandbox)', () => { const output2 = (result2.stdout + result2.stderr).toLowerCase() expect(output2).not.toContain('example domain') }, + 20000, ) it.if(isLinux)( @@ -404,6 +406,7 @@ describe('SandboxManager.updateConfig integration (wrapWithSandbox)', () => { expect(result.status).toBe(0) expect(result.stdout).toContain('Example Domain') }, + 20000, ) /**