diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index aea61c1..498bd3e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -22,7 +22,7 @@ jobs: runner: ubuntu-24.04-arm os: linux - arch: x86-64 - runner: macos-13 + runner: macos-15-large os: macos - arch: arm64 runner: macos-14 diff --git a/package-lock.json b/package-lock.json index 7be2bff..cad23a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.19", + "version": "0.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.19", + "version": "0.0.20", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 057748d..c4864d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.19", + "version": "0.0.20", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 6dbc47e..bfef419 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -100,7 +100,7 @@ async function linuxGetMandatoryDenyPaths( } // Git hooks always blocked in nested repos iglobArgs.push('--iglob', '**/.git/hooks/**') - + // Git config conditionally blocked in nested repos if (!allowGitConfig) { iglobArgs.push('--iglob', '**/.git/config') @@ -660,7 +660,7 @@ export async function wrapCommandWithSandboxLinux( return command } - const bwrapArgs: string[] = [] + const bwrapArgs: string[] = ['--new-session', '--die-with-parent'] let seccompFilterPath: string | undefined = undefined try { diff --git a/test/sandbox/integration.test.ts b/test/sandbox/integration.test.ts index 240ee26..28bd3cd 100644 --- a/test/sandbox/integration.test.ts +++ b/test/sandbox/integration.test.ts @@ -638,6 +638,102 @@ describe('Sandbox Integration Tests', () => { expect(endTime - startTime).toBeLessThan(4000) }) + 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') + + if (existsSync(markerFile)) { + unlinkSync(markerFile) + } + + // Start a long-running process that we'll kill with timeout + // The process writes to a file every 0.2s - if it survives the kill, + // we'll see many more writes than expected + const command = await SandboxManager.wrapWithSandbox( + `for i in $(seq 1 50); do echo "tick $i" >> ${markerFile}; sleep 0.2; done`, + ) + + // Use timeout to kill the sandbox after 1 second + // The inner command would take 10 seconds to complete + const result = spawnSync('timeout', ['1', 'bash', '-c', command], { + encoding: 'utf8', + cwd: TEST_DIR, + timeout: 5000, + }) + + // timeout returns 124 when it kills the process + expect(result.status).toBe(124) + + // Wait a bit to let any orphaned processes continue (if they weren't killed) + await new Promise(resolve => setTimeout(resolve, 1500)) + + // Check how many ticks were written + if (existsSync(markerFile)) { + const content = readFileSync(markerFile, 'utf8') + const lines = content.trim().split('\n').length + + // With 1 second timeout and 0.2s per tick, we expect ~5 ticks + // If child survived, we'd see 5 + 7 (1.5s more) = 12+ ticks + // Allow some margin for timing variance + expect(lines).toBeLessThan(10) + + unlinkSync(markerFile) + } + }) + + 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') + + if (existsSync(markerFile)) { + unlinkSync(markerFile) + } + + // Start a process that will be killed by timeout + // Use a distinctive command that we can grep for + const command = await SandboxManager.wrapWithSandbox( + `export ORPHAN_MARKER="${uniqueMarker}"; while true; do echo "$ORPHAN_MARKER" >> ${markerFile}; sleep 0.5; done`, + ) + + // Kill after 0.5 seconds + spawnSync('timeout', ['0.5', 'bash', '-c', command], { + encoding: 'utf8', + cwd: TEST_DIR, + timeout: 3000, + }) + + // Wait to see if any orphan continues + await new Promise(resolve => setTimeout(resolve, 1500)) + + // Check for orphan processes with our marker + const psResult = spawnSync( + 'bash', + ['-c', `ps aux | grep "${uniqueMarker}" | grep -v grep || true`], + { + encoding: 'utf8', + timeout: 2000, + }, + ) + + // Should not find any orphan processes + expect(psResult.stdout.trim()).toBe('') + + // Cleanup + if (existsSync(markerFile)) { + unlinkSync(markerFile) + } + }) + it('should prevent privilege escalation attempts', async () => { if (skipIfNotLinux()) { return @@ -1029,7 +1125,7 @@ describe('Empty allowedDomains Network Blocking Integration', () => { // Network should fail - either connection error, timeout, or "network_failed" echo const networkBlocked = output.includes('network_failed') || - output.includes('couldn\'t connect') || + output.includes("couldn't connect") || output.includes('connection refused') || output.includes('network is unreachable') || output.includes('name or service not known') || @@ -1064,7 +1160,7 @@ describe('Empty allowedDomains Network Blocking Integration', () => { // Network should fail const networkBlocked = output.includes('network_failed') || - output.includes('couldn\'t connect') || + output.includes("couldn't connect") || output.includes('connection refused') || output.includes('network is unreachable') || output.includes('name or service not known') || @@ -1258,7 +1354,7 @@ describe('Empty allowedDomains Network Blocking Integration', () => { const output = (result.stdout + result.stderr).toLowerCase() const isBlocked = output.includes('blocked') || - output.includes('couldn\'t connect') || + output.includes("couldn't connect") || output.includes('network is unreachable') || result.status !== 0