Skip to content

linux: deny every dangerous name of a subdirectory, not only those three levels down - #629

Merged
ronleizrowice-ant merged 7 commits into
mainfrom
fix/linux-mandatory-names-below
Oct 1, 2026
Merged

ronleizrowice-ant merged 7 commits into
mainfrom
fix/linux-mandatory-names-below

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • The depth was the file's. sub/.git/config is within it; sub/.git/hooks/pre-commit and sub/.claude/commands/x.md are one level too deep.
  • Two-component names never matched a single path segment, so only the file found was denied, not .claude/commands.
  • The absolute path was searched. Under a directory named .idea or .vscode nothing below the working directory was denied.
  • Any failure counted as "nothing found". ripgrep exits 2 if there was a directory it could not read, having listed the rest. All of it was dropped.
  • Ignore files and ripgrep's configuration file steered the scan. Nothing under a directory named in .gitignore was seen.

What changes

  • ripgrep looks one level deeper. A dangerous file counts down to the depth, as before (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).
  • Names are taken by path component, relative to the working directory.
  • A repository is known by its HEAD: its hooks are denied whether full, empty or missing.
  • The scan runs with --no-ignore --no-config --line-buffered, and what it listed counts however it ended. Without line buffering a killed ripgrep drops its output.
  • A directory of the user's own that ripgrep could not read is denied whole, which also keeps its mode from being changed from inside.
  • ripgrep's message about it is a hint only. Its wording depends on the version and the number of threads, and the paths in it are the tree's own text. What stands before each : 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

  • Over 119 paths at the default depth, nothing refused before is written now. 18 more are refused, all one level down. The same holds in a directory that an ignore file names.
  • git add, commit and checkout -b inside a nested repository work as before.
  • A repository one level down costs four mounts, not three: with 100 of them a whole CLI run took 0.84 s, not 0.66 s; with 300, 3.0 s, not 1.9 s (medians of 7). bwrap's argument limit is reached at about 740 of them, not 985. Two levels down nothing changes.

Known limits

  • Two levels down is as before: 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 stops a/b from being removed or renamed from inside.
  • A dangerous file, or a dangerous directory other than a repository's hooks, that does not exist yet can be made below the working directory.
  • HEAD stays writable, so hooks that are empty or missing are covered only while it is there. A repository with no hooks directory has an empty file in its place while a command runs, and after a hard kill until it is removed; git init run again and installing a hook fail with it there.
  • An unreadable directory of another user's is left alone: its mode cannot be given back from inside, and there can be thousands. One whose name holds a newline or is not UTF-8 is not recognised; a directory that holds denies cannot be renamed from inside.
  • A scan that cannot be started denies nothing. One killed after its ten seconds denies what it had listed, not what it had not come to.
  • Conflicts textually with Mandatory denies: submodule git dirs, .git pointer files, nested hooks, gitignored paths #569.

Testing

  • 52 new cases, nearly all with a real sandbox; nothing tested a subdirectory before. 25 fail on main. 34 mutations of the final code each fail a test.
  • Real bubblewrap, ripgrep and git beside 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.
  • Not run through the CLI: an unreadable directory of another user's in a writable one. The built function was given real ripgrep output about three such directories.

…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.
…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
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.
@ronleizrowice-ant
ronleizrowice-ant merged commit 5d196e0 into main Oct 1, 2026
16 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the fix/linux-mandatory-names-below branch October 1, 2026 07:52
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