Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
108 changes: 105 additions & 3 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
207 changes: 206 additions & 1 deletion test/sandbox/mandatory-deny-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down