From 43d549eee9bd8752674dc60acb47f1c884144a61 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 6 Jan 2026 14:31:22 -0800 Subject: [PATCH 1/3] Harden sandbox deny path handling for non-existent files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, non-existent paths in the deny list were skipped since bwrap cannot ro-bind a file that doesn't exist. This change adds defense-in-depth by mounting /dev/null at the first non-existent path component, which prevents creation of the denied path. - Add findFirstNonExistentComponent helper to locate mount point - Mount /dev/null at first missing component to block path creation - Add tests for non-existent deny path protection Bump version to 0.0.24 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 4 +- package.json | 2 +- src/sandbox/linux-sandbox-utils.ts | 55 +++++++++++++- test/sandbox/mandatory-deny-paths.test.ts | 88 +++++++++++++++++++++++ 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b50368..8313b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.23", + "version": "0.0.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.23", + "version": "0.0.24", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 9027cf2..2003966 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.23", + "version": "0.0.24", "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 bfef419..9a0dd78 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -58,6 +58,30 @@ export interface LinuxSandboxParams { /** Default max depth for searching dangerous files */ const DEFAULT_MANDATORY_DENY_SEARCH_DEPTH = 3 +/** + * Find the first non-existent path component. + * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists, + * returns "/existing/parent/nonexistent" + * + * This is used to block creation of non-existent deny paths by mounting /dev/null + * at the first missing component, preventing mkdir from creating the parent directories. + */ +function findFirstNonExistentComponent(targetPath: string): string { + const parts = targetPath.split(path.sep) + let currentPath = '' + + for (const part of parts) { + if (!part) continue // Skip empty parts (leading /) + const nextPath = currentPath + path.sep + part + if (!fs.existsSync(nextPath)) { + return nextPath + } + currentPath = nextPath + } + + return targetPath // Shouldn't reach here if called correctly +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. @@ -518,11 +542,36 @@ async function generateFilesystemArgs( continue } - // Skip non-existent paths + // Handle non-existent paths by mounting /dev/null to block creation if (!fs.existsSync(normalizedPath)) { - logForDebugging( - `[Sandbox Linux] Skipping non-existent deny path: ${normalizedPath}`, + // Find the deepest existing ancestor directory + let ancestorPath = path.dirname(normalizedPath) + while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) { + ancestorPath = path.dirname(ancestorPath) + } + + // Only protect if the existing ancestor is within an allowed write path + const ancestorIsWithinAllowedPath = allowedWritePaths.some( + allowedPath => + ancestorPath.startsWith(allowedPath + '/') || + ancestorPath === allowedPath || + normalizedPath.startsWith(allowedPath + '/'), ) + + if (ancestorIsWithinAllowedPath) { + // Mount /dev/null at the first non-existent path component + // This blocks creation of the entire path by making the first + // missing component appear as an empty file (mkdir will fail) + const firstNonExistent = findFirstNonExistentComponent(normalizedPath) + args.push('--ro-bind', '/dev/null', firstNonExistent) + logForDebugging( + `[Sandbox Linux] Mounted /dev/null at ${firstNonExistent} to block creation of ${normalizedPath}`, + ) + } else { + logForDebugging( + `[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`, + ) + } continue } diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 8f164bb..8fd7895 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -478,6 +478,94 @@ describe('Mandatory Deny Paths - Integration Tests', () => { ) }) }) + + describe('Non-existent deny path protection (Linux only)', () => { + // This tests the fix for sandbox escape via creating non-existent deny paths + // Only applicable to Linux since it uses /dev/null mounting + + 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, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + } + } + + it('blocks creation of non-existent file when parent dir exists', async () => { + if (getPlatform() !== 'linux') return + + // .claude directory exists from beforeAll setup + // .claude/settings.json does NOT exist + const nonExistentFile = '.claude/settings.json' + + const result = await runSandboxedWriteWithDenyPaths( + `echo '{"hooks":{}}' > '${nonExistentFile}'`, + [join(TEST_DIR, nonExistentFile)], + ) + + expect(result.success).toBe(false) + // Verify file was NOT created on host + expect(() => readFileSync(nonExistentFile)).toThrow() + }) + + it('blocks creation of non-existent file when parent dir also does not exist', async () => { + if (getPlatform() !== 'linux') return + + // nonexistent-dir does NOT exist + const nonExistentPath = 'nonexistent-dir/settings.json' + + const result = await runSandboxedWriteWithDenyPaths( + `mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + expect(result.success).toBe(false) + // Verify directory was NOT created on host + expect(() => readFileSync('nonexistent-dir')).toThrow() + }) + + it('blocks creation of deeply nested non-existent path', async () => { + if (getPlatform() !== 'linux') return + + // a/b/c/file.txt does NOT exist + 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) + // Verify directory structure was NOT created on host + expect(() => readFileSync('a')).toThrow() + }) + }) }) describe('macGetMandatoryDenyPatterns - Unit Tests', () => { From 7fcfd84ce1ad2f6181b5ff5b7ef2fb6dd66e87d1 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 6 Jan 2026 14:57:41 -0800 Subject: [PATCH 2/3] Add symlink replacement attack protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect symlinks in protected paths and mount /dev/null over them to prevent attackers from deleting the symlink and creating a real directory with malicious content. If any component of a protected path is a symlink within an allowed write path, mount /dev/null there to block deletion. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/sandbox/linux-sandbox-utils.ts | 53 ++++++++++ test/sandbox/mandatory-deny-paths.test.ts | 113 +++++++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 9a0dd78..56ede1c 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -58,6 +58,46 @@ export interface LinuxSandboxParams { /** Default max depth for searching dangerous files */ const DEFAULT_MANDATORY_DENY_SEARCH_DEPTH = 3 +/** + * Find if any component of the path is a symlink within the allowed write paths. + * Returns the symlink path if found, or null if no symlinks. + * + * This is used to detect and block symlink replacement attacks where an attacker + * could delete a symlink and create a real directory with malicious content. + */ +function findSymlinkInPath( + targetPath: string, + allowedWritePaths: string[], +): string | null { + const parts = targetPath.split(path.sep) + let currentPath = '' + + for (const part of parts) { + if (!part) continue // Skip empty parts (leading /) + const nextPath = currentPath + path.sep + part + + try { + const stats = fs.lstatSync(nextPath) + if (stats.isSymbolicLink()) { + // Check if this symlink is within an allowed write path + const isWithinAllowedPath = allowedWritePaths.some( + allowedPath => + nextPath.startsWith(allowedPath + '/') || nextPath === allowedPath, + ) + if (isWithinAllowedPath) { + return nextPath + } + } + } catch { + // Path doesn't exist - no symlink issue here + break + } + currentPath = nextPath + } + + return null +} + /** * Find the first non-existent path component. * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists, @@ -542,6 +582,19 @@ async function generateFilesystemArgs( continue } + // Check for symlinks in the path - if any parent component is a symlink, + // mount /dev/null there to prevent symlink replacement attacks. + // Attack scenario: .claude is a symlink to ./decoy/, attacker deletes + // symlink and creates real .claude/settings.json with malicious hooks. + const symlinkInPath = findSymlinkInPath(normalizedPath, allowedWritePaths) + if (symlinkInPath) { + args.push('--ro-bind', '/dev/null', symlinkInPath) + logForDebugging( + `[Sandbox Linux] Mounted /dev/null at symlink ${symlinkInPath} to prevent symlink replacement attack`, + ) + continue + } + // Handle non-existent paths by mounting /dev/null to block creation if (!fs.existsSync(normalizedPath)) { // Find the deepest existing ancestor directory diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 8fd7895..bb00bd1 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test' import { spawnSync } from 'node:child_process' -import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { + mkdirSync, + rmSync, + writeFileSync, + readFileSync, + symlinkSync, + existsSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' @@ -566,6 +573,110 @@ describe('Mandatory Deny Paths - Integration Tests', () => { expect(() => readFileSync('a')).toThrow() }) }) + + 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', () => { From c6ef01051ecfb6237630a9af424d2fb990a9a3ad Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 6 Jan 2026 23:06:38 +0000 Subject: [PATCH 3/3] Fix tests to run in container environments - Add enableWeakerNestedSandbox: true for non-existent deny path tests - Update assertions to check for empty content instead of non-existence (bwrap creates empty mount point files when setting up /dev/null binds) --- test/sandbox/mandatory-deny-paths.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index bb00bd1..f43c5fa 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -509,6 +509,7 @@ describe('Mandatory Deny Paths - Integration Tests', () => { needsNetworkRestriction: false, readConfig: undefined, writeConfig, + enableWeakerNestedSandbox: true, }) const result = spawnSync(wrappedCommand, { @@ -537,8 +538,9 @@ describe('Mandatory Deny Paths - Integration Tests', () => { ) expect(result.success).toBe(false) - // Verify file was NOT created on host - expect(() => readFileSync(nonExistentFile)).toThrow() + // Verify file content was NOT written (bwrap may create empty mount point file) + const content = readFileSync(nonExistentFile, 'utf8') + expect(content).toBe('') }) it('blocks creation of non-existent file when parent dir also does not exist', async () => { @@ -553,8 +555,10 @@ describe('Mandatory Deny Paths - Integration Tests', () => { ) expect(result.success).toBe(false) - // Verify directory was NOT created on host - expect(() => readFileSync('nonexistent-dir')).toThrow() + // bwrap mounts /dev/null at first non-existent component, blocking mkdir + // The mount point file is created but is empty (from /dev/null) + const content = readFileSync('nonexistent-dir', 'utf8') + expect(content).toBe('') }) it('blocks creation of deeply nested non-existent path', async () => { @@ -569,8 +573,10 @@ describe('Mandatory Deny Paths - Integration Tests', () => { ) expect(result.success).toBe(false) - // Verify directory structure was NOT created on host - expect(() => readFileSync('a')).toThrow() + // bwrap mounts /dev/null at 'a' (first non-existent component), blocking mkdir + // The mount point file is created but is empty (from /dev/null) + const content = readFileSync('a', 'utf8') + expect(content).toBe('') }) })