From 85e15153957ce981ebd11e2000bdca2eeb93e441 Mon Sep 17 00:00:00 2001 From: John Joseph Ugalino <90829963+la-j@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:55:47 +0800 Subject: [PATCH] Fix dotfile leak by skipping non-existent deny paths (#91) Mounting /dev/null over non-existent paths creates empty files on host. Skip non-existent deny paths instead of mounting /dev/null over them. Removed: - Non-existent path protection code that created empty files - findFirstNonExistentComponent() function - Non-existent deny path protection tests Co-authored-by: Claude Sonnet 4.5 --- src/sandbox/linux-sandbox-utils.ts | 56 +------------- test/sandbox/mandatory-deny-paths.test.ts | 94 ----------------------- 2 files changed, 4 insertions(+), 146 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index c7d2b59..dde6d20 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -100,30 +100,6 @@ function findSymlinkInPath( 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. @@ -627,36 +603,12 @@ async function generateFilesystemArgs( continue } - // Handle non-existent paths by mounting /dev/null to block creation + // Skip non-existent paths - no protection needed + // Mounting /dev/null over non-existent paths creates empty files on host 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 + '/'), + logForDebugging( + `[Sandbox Linux] Skipping non-existent deny path: ${normalizedPath}`, ) - - 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 f43c5fa..1e44daf 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -486,100 +486,6 @@ 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