Repository navigation
linux: deny every dangerous name of a subdirectory, not only those three levels down - #629
Merged
Merged
Conversation
…ree levels down Below the working directory the mandatory denies come from one ripgrep scan. ripgrep lists files, and `mandatoryDenySearchDepth` (3 by default) was handed to it as the depth of the file. So `sub/.bashrc` and `sub/.git/config` were denied while `sub/.git/hooks/pre-commit`, one level further down, was not: the hooks of a repository in a subdirectory could be written from inside the sandbox. The depth now counts the directory that holds a dangerous name. ripgrep looks two levels deeper, as far as a file in `.git/hooks` or `.claude/commands` lies below that directory, and a match counts only if the directory is within the depth. Every dangerous name of one directory is denied or none is, and no dangerous file is denied deeper than before. The loop that turns a match into a path had two more faults. It looked for one path segment equal to `.claude/commands`, which no segment can be, so only the file it had found was denied and a new one could be made beside it. And it searched the absolute path, so under a directory named `.idea` or `.vscode` every match was taken for that ancestor, and nothing below the working directory was denied. Names are now taken by component, on the path relative to the working directory. A repository is known by its HEAD, so its hooks are denied before there are any.
…ds in for missing hooks
…lone Two things beside the names decided what the scan below the working directory found. How ripgrep ended. It exits 2 as soon as one directory cannot be read, and is killed after ten seconds, with everything it had found by then on its output. Any exit but 0 or 1 was taken for "nothing found", so one such directory anywhere within the depth left every name below the working directory undenied. Looking two levels deeper made that likelier. The helper's error now carries the whole lines ripgrep had listed, and the scan uses them. Ignore files and ripgrep's configuration file. Nothing under a directory named in .gitignore, .ignore, .rgignore or .git/info/exclude was listed, and a configuration file can add any flag. The scan now runs with --no-ignore and --no-config. On two large trees that made it faster. A scan that fails before it lists anything, or cannot be started, still denies nothing.
Denying every dangerous name two levels down has a price that one level down does not. Each repository in reach costs four mounts, two denies and two that keep what holds them from being renamed, and bwrap's start grows quadratically with mounts. One level down three of the four are there already. Two levels down there were none: with 300 repositories a command went from 0.4 s to 3.1 s, and at about 750 the limit on bwrap's arguments is reached and no command runs. A mount also stops `a/b` from being removed or renamed from inside, and a checkout in the outer repository from replacing `apps/web/.vscode/settings.json`. So ripgrep looks one level deeper than the depth, not two. A dangerous file counts down to the depth, as it always has. A dangerous directory, and the hooks and config of a repository, count one level higher up, which is as far as `.vscode` and `.git/config` were seen before: what is new is that `.git/hooks`, `.claude/commands` and `.claude/agents` are treated like them. Over 119 paths at the default depth nothing that was refused before this branch is written, and 18 more are refused, all one level down.
…a killed scan had listed Two things said two commits ago were not so. That what ripgrep listed counts when it is killed after its ten seconds. Into a pipe it writes by the block, and killed it drops the block: of 24 runs killed in mid scan, none left a line. The scan now asks for line buffering. What ripgrep had not come to is still not denied. That a directory which cannot be read no longer costs the names their denies. It no longer cost those of other directories theirs, but nothing in the directory itself was denied, and its mode is the user's own to change. Such a directory, down to the search depth, is now denied whole, which also keeps its mode from being changed from inside. ripgrep names the directory on its standard error, and that is taken for a hint only, because the paths in it are the tree's own text: a name can hold a newline and a whole message of its own, and a bind of a path that runs through a link would bring what the link leads to into the sandbox. So a directory counts only if it lies below the working directory with no link on the way to it and cannot be read from this process either. One that cannot be looked at, because what holds it cannot be searched, is stood in for by the nearest directory above it that can.
… it, if it is the user's own The last commit took the directory's path out of ripgrep's message with a pattern. ripgrep has two walkers, and they word the message differently: with one thread, as on one CPU, it says "<path>: IO error for operation on <path>: Permission denied", the pattern took something that is no path, and the directory was not denied. Versions before 14.1 also leave out the "rg: ". There is no pattern now. What stands before each ": " of a line is tried, and each goes through the same checks as before, so a wrong cut adds nothing. The directory above stands in only for what cannot be looked at, not for what is not there, and a directory is looked at once. One check is new: the directory must be the user's own. It is denied because its mode can be given back from inside, and only an owner can do that. Other users' directories are left as they were: below `/home` on a shared host they would be thousands of mounts, and past bwrap's limit on arguments no command runs. The cases with a directory that cannot be read now run twice, the second time with -j1, which words the message as one CPU does.
ronleizrowice-ant
added a commit
that referenced
this pull request
Sep 30, 2026
The one conflict was the import of ripgrep's helper beside the mount-point manifests' imports.
antdres
previously approved these changes
Sep 30, 2026
One conflict, in the catch after the scan for dangerous names. Both sides stand: the signal is looked at first, so that an aborted scan rejects the wrap, and then what ripgrep had listed is used and the directories it could not read are denied.
antdres
approved these changes
Sep 30, 2026
This was referenced Oct 1, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On Linux the mandatory write denies below the working directory missed some names one level down, and could be lost altogether. Now every dangerous name of a subdirectory is denied, whatever else the tree holds.
What was wrong
The names come from one
rg --files --max-depth N(3 by default).sub/.git/configis within it;sub/.git/hooks/pre-commitandsub/.claude/commands/x.mdare one level too deep..claude/commands..ideaor.vscodenothing below the working directory was denied..gitignorewas seen.What changes
a/b/.bashrc). A dangerous directory, and the hooks and config of a repository, count one level higher up, all alike (a/.vscode,a/.claude/commands,a/.git/hooks).HEAD: its hooks are denied whether full, empty or missing.--no-ignore --no-config --line-buffered, and what it listed counts however it ended. Without line buffering a killed ripgrep drops its output.:is tried, and counts only if it is a directory below the working directory, with no link on the way, that the host process cannot read either.Who sees a difference
git add,commitandcheckout -binside a nested repository work as before.Known limits
a/b/.git/hooks,a/b/.git/config,a/b/.vscode,a/b/.claude/*. Denying them costs four mounts a repository where there were none, and stopsa/bfrom being removed or renamed from inside.HEADstays writable, so hooks that are empty or missing are covered only while it is there. A repository with nohooksdirectory has an empty file in its place while a command runs, and after a hard kill until it is removed;git initrun again and installing a hook fail with it there.Testing
main. 34 mutations of the final code each fail a test.main, row by row: four other depths,allowGitConfig, other spellings, a linked worktree, five kinds of ignore file, three configuration lines, directories unreadable in five ways with all CPUs, one CPU and one thread, a real ripgrep killed in mid scan, forged messages spelled by directory names.