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 <noreply@anthropic.com>
This commit is contained in:
John Joseph Ugalino
2026-02-02 16:55:47 +08:00
committed by GitHub
parent 54ecea4d47
commit 85e1515395
2 changed files with 4 additions and 146 deletions

View File

@@ -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
}

View File

@@ -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