Skip to content

cow: gate reads on ownership, serve O_PATH opens, and fix legacy readlink - #342

Merged
congwang-mk merged 3 commits into
multikernel:mainfrom
ghazariann:fix/cow-opath-proc-readlink
Nov 5, 2026
Merged

congwang-mk merged 3 commits into
multikernel:mainfrom
ghazariann:fix/cow-opath-proc-readlink

Conversation

@ghazariann

@ghazariann ghazariann commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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, 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.

Gated 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.

O_PATH opens of branch entries are served

Fixes #325.

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.

The descriptor is fstated and a regular file or directory is reopened as O_RDONLY, which grants nothing a plain O_RDONLY would not under COW. A symlink would fail the reopen with ELOOP and a FIFO would block, so both keep the O_PATH descriptor 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 setting O_DIRECTORY, and also O_PATH regular files, so that gate would miss most opens in practice. Gating on the entry type covers both and still keeps symlinks at EOPNOTSUPP, which was the reason for the request.

Legacy readlink reads its own arguments

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.

Tests

Each commit adds a test that fails without it:

  • test_seccomp_cow_proc_self_answers_for_the_caller
  • test_seccomp_cow_o_path_open_of_created_entries
  • test_seccomp_cow_legacy_readlink_reads_its_own_arguments

Verified

With these fixes, the following Claude Code 2.1.263 tools are confirmed working under sandlock learn and then sandlock run with 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 by run.

The commands used, for reference.

sandlock learn -o /tmp/p-write.toml -- \
  claude --allowedTools Write -p "Use Write to create ./probe.txt containing exactly: hello"

sandlock learn -o /tmp/p-bash.toml -- \
  claude --allowedTools Bash -p "run the command: echo hello-from-bash"

sandlock learn -o /tmp/p-web.toml \
  --http-inject-ca /etc/ssl/certs/ca-certificates.crt \
  --http-ca-out /tmp/sandlock-ca.pem \
  --env NODE_EXTRA_CA_CERTS=/tmp/sandlock-ca.pem \
  -- claude --allowedTools WebFetch -p "fetch https://example.com and tell me the page title"

sandlock run --profile-file /tmp/p-web.toml

Comment thread crates/sandlock-core/src/cow/seccomp.rs Outdated
p.starts_with(&self.workdir_str)
&& !p.starts_with(&self.storage_dir)
&& !p.starts_with("/proc/self")
&& !p.starts_with("/proc/thread-self")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, 2c93838.

Comment thread crates/sandlock-core/src/cow/seccomp.rs Outdated
p.starts_with(&self.workdir_str)
&& !p.starts_with(&self.storage_dir)
&& !p.starts_with("/proc/self")
&& !p.starts_with("/proc/thread-self")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also makes writes inconsistent: /proc/self/oom_score_adj now goes to the kernel, while /proc/<pid>/oom_score_adj is still staged.

@ghazariann ghazariann Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, bbbe47e.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment: the code never reopens symlinks. It rejects them by type before any reopen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, bbbe47e.

@ghazariann
ghazariann force-pushed the fix/cow-opath-proc-readlink branch from 272bb58 to 2c93838 Compare November 5, 2026 03:57

@congwang-mk congwang-mk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ghazariann ghazariann changed the title cow: fix O_PATH, /proc/self and legacy readlink handling cow: gate reads on ownership, serve O_PATH opens, and fix legacy readlink Nov 5, 2026
@ghazariann
ghazariann force-pushed the fix/cow-opath-proc-readlink branch from 2c93838 to 836b7e5 Compare November 5, 2026 05:33
@congwang-mk
congwang-mk merged commit 2eca111 into multikernel:main Nov 5, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

COW cannot serve O_PATH opens of branch entries

2 participants