Skip to content

files: follow in-root symlinks via a kernel-faithful confined resolver#153

Merged
Meirtz merged 1 commit into
masterfrom
feat/files-symlink-containment
Jul 4, 2026
Merged

files: follow in-root symlinks via a kernel-faithful confined resolver#153
Meirtz merged 1 commit into
masterfrom
feat/files-symlink-containment

Conversation

@Meirtz

@Meirtz Meirtz commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Why

agentix.files upload/download walked every path component with O_NOFOLLOW and refused any symlink. That is far stronger than the jail's actual invariant (the resolved target stays under $AGENTIX_UPLOAD_ROOT) and breaks legitimate filesystems: merged-usr images make /bin, /sbin, /lib symlinks to usr/*, and real repos contain symlinked sources. With the root widened to / there is nothing to escape, yet the refusal still fired.

What

The resolver now follows symlinks the way openat2(RESOLVE_IN_ROOT) does — escape is structurally impossible, not lexically checked:

  • the root dir is opened once with O_NOFOLLOW and retained for the whole walk (never reopened by path), so a concurrent rename/replace of the root cannot redirect a later step outside the jail — a TOCTOU that an earlier reopen-per-restart draft was vulnerable to;
  • symlink targets are pushed onto the walk queue as raw components, never lexically normalized: .. is applied by popping the open-dir stack (clamped at the root) against actually-resolved directories, so a target like inner/../victim.txt where inner -> dest/sub resolves to dest/victim.txt exactly as the kernel would, not to a lexically-wrong root file;
  • absolute symlink targets re-anchor at the root (chroot semantics): a link to /etc/x reads <root>/etc/x and can never touch the host;
  • a 40-hop cap (the kernel's own limit) turns cycles into PermissionError.

macOS reports ENOTDIR instead of ELOOP for the O_DIRECTORY case, so symlink detection arbitrates via readlink.

Tests

merged-usr relative links, nested link-to-link chains, ..-bearing targets (contained + escaping), absolute-target re-anchoring, a link resolving exactly to the root, and cycle refusal. Tests monkeypatch files.UPLOAD_ROOT rather than reloading the module — reload re-creates UploadResult and breaks class identity for the safepickle allowlist.

Reviewed by a second co-author (Codex, security lens) + adversarial pass. The HIGH findings — root-fd TOCTOU on restart and lexical-.. resolving the wrong file — drove the rewrite from the initial reopen-per-restart version to this retained-fd-stack resolver.

🤖 Generated with Claude Code

upload/download walked every path component with O_NOFOLLOW and refused
ANY symlink — far stronger than the jail's real invariant (the resolved
target stays under UPLOAD_ROOT) and broken for legitimate filesystems:
merged-usr images make /bin, /sbin, /lib symlinks to usr/*, and real
repos contain symlinked sources.

The resolver now follows symlinks the way openat2(RESOLVE_IN_ROOT) does,
so escape is structurally impossible rather than lexically checked:

- the root dir is opened ONCE with O_NOFOLLOW and retained for the whole
  walk — never reopened by path — so a concurrent rename/replace of the
  root cannot redirect a later step outside the jail (a TOCTOU an
  earlier reopen-per-restart draft was vulnerable to);
- symlink targets are pushed onto the walk queue as raw components, never
  lexically normalized: `..` is applied by popping the open-dir stack
  (clamped at the root) against actually-resolved directories, so a
  target like `inner/../victim.txt` where `inner -> dest/sub` resolves to
  dest/victim.txt exactly as the kernel would, not to a lexically-wrong
  root file;
- absolute symlink targets re-anchor at the root (chroot semantics), so a
  link to /etc/x reads root/etc/x and can never touch the host;
- a 40-hop cap (the kernel's own limit) turns cycles into PermissionError.

macOS reports ENOTDIR instead of ELOOP for the O_DIRECTORY case, so
symlink detection arbitrates via readlink. Tests: monkeypatch
files.UPLOAD_ROOT (reloading the module breaks UploadResult identity for
the safepickle allowlist); coverage includes merged-usr relative links,
nested link-to-link chains, `..`-bearing targets, absolute-target
re-anchoring, a link resolving exactly to root, and cycle refusal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Meirtz Meirtz force-pushed the feat/files-symlink-containment branch from 90db1df to f629b98 Compare July 4, 2026 01:11
@Meirtz Meirtz merged commit 55a0282 into master Jul 4, 2026
5 checks passed
@Meirtz Meirtz deleted the feat/files-symlink-containment branch July 4, 2026 01:19
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.

1 participant