Skip to content

Commit 6460160

Browse files
Kenoclaude
authored andcommitted
Fix EACCES error in chmod_recursive during cleanup
The condition `isdir(path) && !islink(path)` calls `isdir` first, which uses `stat()` and follows symlinks. In overlay upper directories, symlinks created inside the sandbox (e.g. Claude Code task output files) point to paths like `/root/.claude/...` that are inaccessible from the host (the host user can't traverse `/root/`). `stat()` follows the symlink and fails with EACCES. Fix by checking `!islink(path)` first. `islink` uses `lstat()` which operates on the symlink itself without following it, so it succeeds. When `islink` returns true, short-circuit evaluation skips the `isdir` call entirely, avoiding the EACCES error. Fixes Keno/ClaudeBox.jl#24 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ee7018 commit 6460160

File tree

2 files changed

+26
-1
lines changed

2 files changed

+26
-1
lines changed

src/UserNamespaces.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function chmod_recursive(root::String, perms, use_sudo::Bool)
2828
rethrow(e)
2929
end
3030
end
31-
if isdir(path) && !islink(path)
31+
if !islink(path) && isdir(path)
3232
chmod_recursive(path, perms, use_sudo)
3333
end
3434
end

test/UserNamespaces.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ if Sys.islinux()
55
@test Sandbox.check_kernel_version()
66
end
77

8+
@testset "chmod_recursive with dangling/inaccessible symlinks" begin
9+
mktempdir() do dir
10+
# Create a directory tree similar to an overlay upper dir
11+
tasks_dir = joinpath(dir, "upper", "rootfs", "tmp", "tasks")
12+
mkpath(tasks_dir)
13+
14+
# Create a symlink pointing to an inaccessible path (simulates a
15+
# symlink created inside a sandbox that points through /root/ which
16+
# the host user cannot traverse after the sandbox exits)
17+
symlink("/root/.claude/nonexistent/agent.jsonl", joinpath(tasks_dir, "abc1234.output"))
18+
19+
# Also create a regular file to ensure it's still processed
20+
touch(joinpath(tasks_dir, "regular.txt"))
21+
chmod(joinpath(tasks_dir, "regular.txt"), 0o000)
22+
23+
# This should not throw — previously it would because `isdir(path)`
24+
# was checked before `islink(path)`, and `isdir` uses `stat()` which
25+
# follows symlinks to the inaccessible target.
26+
@test nothing === Sandbox.chmod_recursive(dir, 0o777, false)
27+
28+
# Verify the regular file got chmod'd
29+
@test filemode(joinpath(tasks_dir, "regular.txt")) & 0o777 == 0o777
30+
end
31+
end
32+
833
if executor_available(UnprivilegedUserNamespacesExecutor)
934
@testset "UnprivilegedUserNamespacesExecutor" begin
1035
with_executor(UnprivilegedUserNamespacesExecutor) do exe

0 commit comments

Comments
 (0)