Skip to content

A deny keeps the symbolic links on the way to it in place - #693

Open
ronleizrowice-ant wants to merge 12 commits into
mainfrom
fix/a-write-deny-on-a-link-holds-the-name-too
Open

ronleizrowice-ant wants to merge 12 commits into
mainfrom
fix/a-write-deny-on-a-link-holds-the-name-too

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Human: @ronleizrowice-ant

Requested by Ron · project thread

A denyWrite entry or mandatory deny path may have a symbolic link on the way to it, its last name included. Such a deny covers:

  • the path the links lead to, whether or not anything is there yet;
  • each link: removing it, renaming it and putting something else at its name are refused;
  • the directories above them, and those a link's target goes into and out of again by ..; a name that is not there and that the target goes up from cannot be made.

On Linux the same links are kept on the way to a denyRead entry, to a link a denyRead pattern matches, and to a credentials.files path.

Size

+1,262 −97 in 15 files: 353 lines of TypeScript, 161 of C, 13 of README, 735 of tests. It was +1,704 when it was reviewed.

How

  • macOS: the deny is emitted as written and where the links lead. Each link is kept by a literal filter.
  • Nothing among the devices is denied, for reading or writing: a name blanked with a link to /dev/null is kept, and /dev/null stays as it was.
  • Linux: bubblewrap cannot mount on a link, since mount(2) follows one at its target. So the seccomp helper gains --hold-link <path> <target>: it mounts the link on itself, read-only, through a descriptor that is not followed. unlink and rename refuse a name that is a mount point (EBUSY). The link reads and leads as before, and the directory that holds it stays as writable as it was.
    • It fails closed: if the path is no link when the helper looks, if the link says something else than at the wrap, or if the mount is refused, nothing runs. The one line on stderr starts with apply-seccomp: --hold-link <path>: and names the setting that turns holding off.
    • A second user namespace is entered after the last hold, so a hold cannot be unmounted, also by uid 0 inside.
    • Nothing is made on the host.
  • One walker. followLinks(p) is wayTo() of src/utils/real-path.ts, which now tells a watcher each link it follows and each directory it goes up from. Take host-side helpers only from where the sandboxed command cannot write #594 uses the same watcher, and carries the same change to that file.
  • "Could not look" is never "no link". A directory on the way that cannot be looked in, after two tries, is denied whole in place of the path, its mode included. A name nothing can be at (too long, a NUL byte) counts as looked at.

Where a link is not held (Linux)

SandboxManager.getLinuxUnheldLinks() gives { path, reason } for each, by the latest wrap that met the link, and a warn-level line is logged. reset() and updateConfig() empty the list. Where nothing holds the links, the directories above them are not pinned either.

reason
no_helper allowAllUnixSockets, or no binary for the architecture
told_no seccomp.holdsLinks is false
not_told the helper is named by seccomp.applyPath, and seccomp.holdsLinks is not set
older_helper the helper the library found for itself is built from older sources
no_answer that helper's file could not be read

Option seccomp.holdsLinks: the caller's word on its helper. Not set, the file of a helper the library found for itself is READ for the name of the option --holds-links. No helper is run outside a sandbox to find out.

What changed on 2026-10-20, after a review of the whole diff against main (20 findings)

  • The probe ran the helper on the host. In a checkout or a project's own node_modules that file is whatever an earlier command left there. It is read now.
  • A link's target that goes into directories and out again (a -> d1/d2/../../real/secret) left those directories free to be moved aside; on macOS a missing name on such a way (missing/../x) could be created.
  • A denyWrite on a name too long to exist made its whole directory read-only. A target that holds U+FFFD itself stopped the walk.
  • Pins were made where nothing held the links. getFsReadConfig() left out the links its patterns match.
  • Three findings are written down as limits in the README instead: a writer outside the sandbox that flips a link during the wrap, the violation monitor not reporting a refused attempt on a link on the way, and a wrap in flight across reset().
  • Then a simplification pass over source and tests.

The second review, of the whole diff at 08d1132

Nothing had to be fixed. One thing should have been, a step back from main, and is in 4ac7e7e: where a directory on the way cannot be looked in it stands in for the denied path, and the missing names the way had gone up from before it were dropped with the path (l -> m1/../locked/x; chmod 000 locked; the next command makes m1 a link). Run with real bubblewrap, the two commands succeed at 08d1132 and are refused now.

What it attacked with real bubblewrap, and held: mv, rm -rf and a renameat2 exchange on every name of 16 fixtures (chains, relative and absolute targets, .. through links, nested write roots, ways out of the root and back); making the missing names of nine; / as the write root; the names denied in every working directory as links; links to /, /proc, /dev, /sys; umount2, a bind and rbind in a nested user namespace. The two simplification commits changed nothing in the Linux wrap, the list of links not held or the macOS profile on 17 fixtures.

Known and left: a link whose target goes up from more than 40 missing names is followed no further, which on macOS leaves what it leads to undenied (Linux refuses to start).

What a command notices

  • A tool that replaces a file instead of writing into it is refused at a linked denied name, as at any denied one (sed -i, mv -f, ln -sfn, rsync, git checkout where the tracked link differs). On Linux the error is EBUSY.
  • Linux: a folder on the way to a write deny that cannot be looked at is denied whole wherever it lies, as it already was below the working directory. One existing test (literal-path-entries) says so now.

Cost

The packaged helper (0.8 MB) is read at every wrap that leaves seccomp.holdsLinks unset: 0.3 ms. At the start of a command, nothing without a link to hold; with one, under 1 ms, and about 35 µs a link.

Testing

  • 157 cases in write-deny-through-links.test.ts (682 lines, were 1,187). One table of ways drives the walk, the Seatbelt profile and the real attempts; rm, mv … aside and ln -sfn are tried on all 13 links of the fixture.
  • 136 mutations of the source: 126 fail a case here (117 before the cut, none lost). The ten that do not are listed in the project thread; two of them are caught by other suites.
  • The macOS rows run only on CI's Macs.

Merge order

Conflicts with #607 in read-deny-glob.ts and sandbox-manager.ts (one options object, { anchor, listings, matchedLinks } here and { anchor, budget, listings } there): whichever lands second takes both. src/utils/real-path.ts changes the same way in #594, and both add a last parameter to generateFilesystemArgs(): whichever lands second takes both.

main merged in on 2026-11-06

main at 27fce0d is merged in at 2a4776d, without a conflict. What this pull request changes is the same as before, +1,262 −97 in 15 files.

main merged in again on 2026-11-09

main at 951e641 is merged in at c3ae746, without a conflict. What this pull request changes is the same as before.

Claude session #01FydxqU


Generated by Claude Code

A denyWrite entry or mandatory deny path may have a symbolic link on the way
to it, its last name included. Such a deny covers the path the links lead to,
whether or not anything is there yet; each link, which cannot be removed,
renamed or replaced; and the directories above them.

macOS: the deny is emitted as written and where the links lead, and each link
is kept by a literal filter. Linux: bubblewrap cannot mount on a link, so the
seccomp helper gains --hold-link <path>, which mounts the link on itself,
read-only. A helper without the option is recognised and used as before, and
SandboxManager.getLinuxUnheldLinks() then names the links.
… to do

Links at denyRead entries, pattern matches, credential files and masks are
held like those on the way to a write deny. Nothing among the devices is
denied through a link. A directory that cannot be looked in is denied whole:
"could not look" is never "no link". The helper compares what the link says
with what it said at the wrap, on the descriptor it mounts.

seccomp.holdsLinks is the caller's word on its helper, and a path given by
applyPath is never run outside the sandbox. getLinuxUnheldLinks() gives a
reason with each link and is emptied by reset() and updateConfig().

The README and the comments say what a hold refuses: unlink and rename of a
mount point in the caller's mount namespace, and unmounting it.
@ronleizrowice-ant ronleizrowice-ant changed the title A write deny keeps the symbolic links on the way to it in place A deny keeps the symbolic links on the way to it in place Oct 10, 2026
…o be compared with

The walk follows 40 links on every platform. macOS gives up sooner, so the
comparison with the kernel at 40 is made on Linux. The row on /dev/stdout
said what a command may do there with no run without the link beside it: it
goes. The rows on /dev/null stay.
Four files with blocks. In the Linux plan main's placeholder functions stay
as they are and both loops go on over the paths as one walk took them. On
macOS the check for the devices is the one in startingFrom(), which main
has since written into inBothSpellings(). In the helper
enter_user_namespace() is main's block: dumpable is raised for the maps and
never put back to what it was. It is cleared at the end of each call, not
only before the fork, because the links are held between two calls.
`made_userns` is set after the first, so the limit on user namespaces is
written in the innermost one, where the command lives.

Joins that no conflict marker shows:

- main's pre-pass adds a name the way goes up from to the list it is going
  through, for both loops to meet. Here both loops go through the list made
  from that one beforehand, so the name was added where nothing read it and
  was not denied: three cases of symlinked-deny-paths failed. It is taken
  like any other deny path and added to the list the loops go through.
- The namespaces filter answers an unmount before the kernel looks at the
  lock, so a command of uid 0 gets EPERM, not EINVAL. The cases say both:
  EPERM as the sandbox runs by default, EINVAL with the filter lifted, which
  is the one place the second user namespace is all that holds.
- A bubblewrap started by the command starts only with the filter lifted.
  The case that the hold holds there lifts it.
- One case of main's said that a directory is mounted nowhere by finding its
  path nowhere in the command. The helper is told what the link it holds
  says, which names it. The case looks among the mounts.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
The container suite is to run on musl as well, where rm says "Resource busy".

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
@ronleizrowice-ant

ronleizrowice-ant commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Tests (macos / arm64) failed on 438380f in test/sandbox/git-over-ssh.test.ts ("nc reaches the proxy on 127.0.0.1": connectx to 127.0.0.1 ... Operation not permitted). This is not from this pull request as far as I can tell: the only change since 6ea4cea, where the leg passed, is one regular expression in test/docker-weak-sandbox.test.ts, which runs in the containers only, and nothing here touches the proxy or the network rules of the profile. The same leg failed in the same suite, in another case ("as injected on macOS: ls-remote": sandbox proxy: ... no answer), on #751 within the same five minutes, and passed on three earlier heads that hold this code. I know of no fix for it. Re-running the leg once.

Later the same day: the re-run passed, so all eight are green on 438380f. The failure is older than this pull request: going by the annotations of the failed runs since the suite landed (#681, 10 October), the same leg failed in the same suite, with the same two messages, at least twelve times on heads that hold none of this code, three of them on main (82ca961, 0fabe2c, 0ded0c8). The Intel macOS leg never failed there.


Generated by Claude Code

claude and others added 4 commits October 20, 2026 12:09
- The helper the library finds for itself is read for the name of
  --holds-links, never run: that was outside any sandbox, and in a checkout or
  a project's own node_modules the file is whatever an earlier command left.
- One walker. followLinks() is wayTo() of real-path.ts with a watcher, so a
  target that holds U+FFFD itself is text, and a name too long to be there is
  a name that is not there and no longer makes its directory read-only.
- A look that fails is tried again before a directory is denied whole.
- A directory that a link's target goes into and out of again by `..` is kept
  in place, on both platforms; on macOS a name that is not there which the
  target goes up from can no longer be made.
- Where nothing holds the links, the directories above them are not pinned.
- getFsReadConfig() names the links its patterns match, as a wrap does.
- The refusals for length and of a hold name seccomp.holdsLinks.
- Tests: a rejection is awaited, EBUSY is read in musl's wording too, and the
  attempts are no longer printed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
No behaviour changes but these: the packaged helper's file is read at every
wrap that leaves seccomp.holdsLinks unset (0.3 ms), not only where there is a
link to hold, and that it could not be read is told by the reason alone.

- Whether links are held is asked before the mounts are planned and handed
  down as a boolean, in place of a function in and a reason out.
- followLinks() is two flat tries, and wayPastMissingNames() says `shut` in
  place of an errno for three callers to cast.
- macOS keeps main's inBothSpellings(), which takes the end of a walk.
- Comments and the README paragraph are shorter.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…,187

One table of ways (name, links, end, directories left, names passed) drives
the walk, the Seatbelt profile and the real attempts, every link of the
fixture is on the way to a deny, so that removing, moving and replacing are
tried on all thirteen, and one table holds the reasons a link is not held.

Of 136 mutations of the source 117 were caught before and 126 are now; none
that was caught is lost. Four cases proved nothing and are replaced, and the
read of a chain of 40 links, which the kernel itself refused now and then
under load, is gone.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…ing names before it

Where the walk to a denied path cannot look in a directory, that directory
stands in for the path. The missing names the way had gone up from before it
were dropped with the path, so a command could make one of them a link and lead
the way elsewhere: l -> m1/../locked/x, chmod 000 locked, ln -s evil/sub m1.
They are denied beside the directory, as they are where the walk goes through.

Tests: that case; the directories a way goes up from are pinned where nothing
holds links and the command can replace none; one refusal is recognised by the
shell's name alone, whatever the words around it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
claude and others added 2 commits November 6, 2026 01:52
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants