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..56ede1c 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -58,6 +58,70 @@ 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, + * 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,14 +582,52 @@ async function generateFilesystemArgs( continue } - // Skip non-existent paths - if (!fs.existsSync(normalizedPath)) { + // 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] Skipping non-existent deny path: ${normalizedPath}`, + `[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 + 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 + } + // Only add deny binding if this path is within an allowed write path // Otherwise it's already read-only from the initial --ro-bind / / const isWithinAllowedPath = allowedWritePaths.some( diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 8f164bb..f43c5fa 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' @@ -478,6 +485,204 @@ 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, + enableWeakerNestedSandbox: true, + }) + + 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 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 () => { + 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) + // 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 () => { + 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) + // 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('') + }) + }) + + 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', () => {