Repository navigation
A deny keeps the symbolic links on the way to it in place - #693
ronleizrowice-ant wants to merge 12 commits into
Conversation
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.
…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>
|
Later the same day: the re-run passed, so all eight are green on Generated by Claude Code |
- 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>
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>
Human: @ronleizrowice-ant
Requested by Ron · project thread
A
denyWriteentry or mandatory deny path may have a symbolic link on the way to it, its last name included. Such a deny covers:..; 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
denyReadentry, to a link adenyReadpattern matches, and to acredentials.filespath.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
literalfilter./dev/nullis kept, and/dev/nullstays as it was.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.unlinkandrenamerefuse 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.apply-seccomp: --hold-link <path>:and names the setting that turns holding off.followLinks(p)iswayTo()ofsrc/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.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()andupdateConfig()empty the list. Where nothing holds the links, the directories above them are not pinned either.no_helperallowAllUnixSockets, or no binary for the architecturetold_noseccomp.holdsLinksis falsenot_toldseccomp.applyPath, andseccomp.holdsLinksis not setolder_helperno_answerOption
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)node_modulesthat file is whatever an earlier command left there. It is read now.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.denyWriteon a name too long to exist made its whole directory read-only. A target that holds U+FFFD itself stopped the walk.getFsReadConfig()left out the links its patterns match.reset().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 makesm1a 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 -rfand arenameat2exchange 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 andrbindin 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
sed -i,mv -f,ln -sfn,rsync,git checkoutwhere the tracked link differs). On Linux the error isEBUSY.literal-path-entries) says so now.Cost
The packaged helper (0.8 MB) is read at every wrap that leaves
seccomp.holdsLinksunset: 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
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 … asideandln -sfnare tried on all 13 links of the fixture.Merge order
Conflicts with #607 in
read-deny-glob.tsandsandbox-manager.ts(one options object,{ anchor, listings, matchedLinks }here and{ anchor, budget, listings }there): whichever lands second takes both.src/utils/real-path.tschanges the same way in #594, and both add a last parameter togenerateFilesystemArgs(): whichever lands second takes both.main merged in on 2026-11-06
mainat27fce0dis merged in at2a4776d, 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
mainat951e641is merged in atc3ae746, without a conflict. What this pull request changes is the same as before.Claude session #01FydxqU
Generated by Claude Code