Repository navigation
cow: gate reads on ownership, serve O_PATH opens, and fix legacy readlink - #342
Conversation
| p.starts_with(&self.workdir_str) | ||
| && !p.starts_with(&self.storage_dir) | ||
| && !p.starts_with("/proc/self") | ||
| && !p.starts_with("/proc/thread-self") |
There was a problem hiding this comment.
A prefix check misses symlinks into /proc/self: readlink("/dev/fd/3"), stat("/dev/stdin"), stat("/proc/net/tcp") still resolve in the supervisor via RESOLVE_IN_ROOT. The root cause is that stat/readlink serve untouched lower paths once has_changes(). Gate them like open does (upper has the entry via lstat, or deleted covers it) and Continue otherwise.
| p.starts_with(&self.workdir_str) | ||
| && !p.starts_with(&self.storage_dir) | ||
| && !p.starts_with("/proc/self") | ||
| && !p.starts_with("/proc/thread-self") |
There was a problem hiding this comment.
This also makes writes inconsistent: /proc/self/oom_score_adj now goes to the kernel, while /proc/<pid>/oom_score_adj is still staged.
There was a problem hiding this comment.
Fixed by removing the exclusion in 2c93838. But this has me thinking about the writes: handle_cow_open's write path only checks matches(), which is true for /proc/self too. So a child write to /proc/self/oom_score_adj (or /proc/self/comm) gets staged by COW, and since that open runs in the supervisor's process, /proc/self resolves to the supervisor, not the child.
Should we have a set of critical paths excluded from COW interception entirely, so the supervisor can never shadow them?
| // ADDFD refuses an O_PATH source fd, so hand over a readable handle to the | ||
| // same entry. Under COW the workdir governs access to upper-layer entries, | ||
| // so this grants nothing a plain O_RDONLY would not. | ||
| let fd = if open_flags & libc::O_PATH != 0 { |
There was a problem hiding this comment.
The child gets an O_RDONLY fd, not an O_PATH one: F_GETFL changes and read() succeeds. Entries without read permission still return EOPNOTSUPP. Please state both in the commit message.
There was a problem hiding this comment.
Amended the initial commit, added test cases for both facts: the reopened fd's F_GETFL/read() behavior, and the no-read-permission case. bbbe47e.
| let got = result.stdout_str().unwrap_or("").trim().to_string(); | ||
| assert_eq!( | ||
| got, | ||
| format!("dir=ok file=ok link=errno={}", libc::EOPNOTSUPP), |
There was a problem hiding this comment.
If a kernel accepts O_PATH in ADDFD (the 54971c806194 fix you cite), link=ok and this assertion fails. Drop the symlink case or accept both results.
| /// over a read-only handle to the same entry instead, which grants nothing a | ||
| /// plain O_RDONLY would not under COW. | ||
| /// | ||
| /// Symlinks keep EOPNOTSUPP: reopening one as O_RDONLY|O_NOFOLLOW fails with |
There was a problem hiding this comment.
Stale comment: the code never reopens symlinks. It rejects them by type before any reopen.
272bb58 to
2c93838
Compare
congwang-mk
left a comment
There was a problem hiding this comment.
Commit 4 reverts commit 1's /proc/self exclusion. Please fold the gating into commit 1 instead.
The PR description still says the fix is an exclusion in SeccompCowBranch::matches. Please update it.
| "os.mkdir('{staged}')\n", // staged in upper, this one must be intercepted | ||
| "dfd = os.open('/etc', os.O_RDONLY | os.O_DIRECTORY)\n", | ||
| "exe = os.readlink('/proc/self/exe', dir_fd=dfd)\n", | ||
| "own_stdin = os.stat('/dev/stdin').st_ino == os.fstat(0).st_ino\n", |
There was a problem hiding this comment.
This can't fail: run inherits stdin, so the child's fd 0 is the supervisor's fd 0. Use /dev/stdout (piped) or /proc/self/fd/{r} of an os.pipe() opened in the child.
readlink(path, buf, size) and readlinkat(dirfd, path, buf, size) do not share an argument layout, but the handler read both as readlinkat. For a legacy readlink that took the output buffer as the path, so a symlink created during the run could not be read back. Check the syscall number first: legacy readlink takes path, buf and size from the first three arguments with AT_FDCWD as the directory, readlinkat keeps its four. Signed-off-by: Vahagn <wanghang25@mails.tsinghua.edu.cn>
…anges() With the workdir at "/", matches() is true for every path. stat, statx, exec's upper-redirect, and readlink only gated on has_changes() || !matches(), so once anything changed anywhere in the branch, they'd serve any matching path from the supervisor, even one the branch never touched. For a self-referential path (/proc/self, /dev/stdin), the supervisor answered for itself instead of the caller: /proc/self/exe came back as the sandlock binary, /proc/self/fd/N as the supervisor's fd. Gate these the same way handle_cow_open already gates reads: also require needs_read_intercept(), which checks whether the upper actually owns the path (staged, or covered by a whiteout). It had a latent bug of its own: Path::exists() follows symlinks and misses a dangling one, so a symlink staged in upper could read back as ENOENT. Fixed by also checking is_symlink(), matching resolve_read()'s existing fallback. Signed-off-by: Vahagn <wanghang25@mails.tsinghua.edu.cn>
ADDFD resolves the source descriptor with fget(), which refuses an O_PATH one (fixed in Linux by 54971c806194), so an O_PATH open of an entry that exists only in the upper layer could not be answered and the child got EOPNOTSUPP. fstat the descriptor and reopen a regular file or a directory as O_RDONLY. The child's fd then reports O_RDONLY via F_GETFL, not O_PATH, and read() on it succeeds, which grants nothing a plain O_RDONLY would not under COW. Anything else (symlink, FIFO, socket, device) is rejected by type before any reopen is attempted, so it keeps the original O_PATH descriptor and ADDFD refuses it as before. An entry the reopen cannot read for lack of permission ends up the same way, since open() itself fails. Signed-off-by: Vahagn <wanghang25@mails.tsinghua.edu.cn>
2c93838 to
836b7e5
Compare
Three independent COW bugs found while running agent workloads under learn.
stat, readlink and exec gate reads on ownership, not just has_changes()
With the workdir at
/,matches()is true for every path.stat,statx,exec's upper-redirect, andreadlinkonly gated onhas_changes() || !matches(), so once anything changed anywhere in the branch, they'd serve any matching path from the supervisor, even one the branch never touched. For a self-referential path (/proc/self,/dev/stdin), the supervisor answered for itself instead of the caller:/proc/self/execame back as the sandlock binary,/proc/self/fd/Nas the supervisor's fd.Gated the same way
handle_cow_openalready gates reads: also requireneeds_read_intercept(), which checks whether the upper actually owns the path (staged, or covered by a whiteout). It had a latent bug of its own:Path::exists()follows symlinks and misses a dangling one, so a symlink staged in upper could read back asENOENT. Fixed by also checkingis_symlink(), matchingresolve_read()'s existing fallback.O_PATH opens of branch entries are served
Fixes #325.
ADDFD resolves the source descriptor with
fget(), which refuses anO_PATHone (fixed in Linux by 54971c806194), so anO_PATHopen of an entry that exists only in the upper layer could not be answered and the child gotEOPNOTSUPP.The descriptor is
fstated and a regular file or directory is reopened asO_RDONLY, which grants nothing a plainO_RDONLYwould not under COW. A symlink would fail the reopen withELOOPand a FIFO would block, so both keep theO_PATHdescriptor and ADDFD refuses it as before.On the review comment asking to gate this on
O_PATH|O_DIRECTORY: callers often open a directory without settingO_DIRECTORY, and alsoO_PATHregular files, so that gate would miss most opens in practice. Gating on the entry type covers both and still keeps symlinks atEOPNOTSUPP, which was the reason for the request.Legacy readlink reads its own arguments
readlink(path, buf, size)andreadlinkat(dirfd, path, buf, size)do not share an argument layout, but the handler read both asreadlinkat. For a legacyreadlinkthat took the output buffer as the path, so a symlink created during the run could not be read back.Check the syscall number first: legacy readlink takes path, buf and size from the first three arguments with AT_FDCWD as the directory, readlinkat keeps its four.
Tests
Each commit adds a test that fails without it:
test_seccomp_cow_proc_self_answers_for_the_callertest_seccomp_cow_o_path_open_of_created_entriestest_seccomp_cow_legacy_readlink_reads_its_own_argumentsVerified
With these fixes, the following Claude Code 2.1.263 tools are confirmed working under
sandlock learnand thensandlock runwith the learned profile: Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch.Write and Edit were also checked for containment: the change is staged and discarded by
learn, and only applied byrun.The commands used, for reference.