Skip to content

Mandatory denies: nested repo hooks at scan depth, submodule git dirs, .git pointer files, gitignored paths - #515

Open
ronleizrowice-ant wants to merge 6 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-nested-repo-hooks-depth
Open

Mandatory denies: nested repo hooks at scan depth, submodule git dirs, .git pointer files, gitignored paths#515
ronleizrowice-ant wants to merge 6 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-nested-repo-hooks-depth

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The mandatory write-denies exist so a sandboxed command cannot leave behind something the host's own tooling later executes: a git hook, a core.fsmonitor in a git config, an IDE task. For git that covered the working directory's .git/hooks and .git/config, and nested repositories found by the Linux ripgrep scan or matched by pattern on macOS. Six shapes slipped through with the default configuration; each was confirmed under bwrap / seatbelt before the change.

  1. Linux, nested repository directly under cwd. The scan's default depth (3) reaches pkg/.git/config but the hook files sit one segment deeper, so a nested repository's config was read-only while its hooks directory stayed writable. The scan now looks for any regular file directly inside a .git/ directory (one **/.git/* glob), and any such file marks a repository whose deny paths follow. Recognising the repository by whatever its .git holds — rather than by HEAD alone — matters because creating and moving files inside a .git is otherwise allowed: one command could move HEAD aside and the next command's scan would miss the repository entirely. Detection no longer depends on allowGitConfig either, which governs what is denied and not what is found.
  2. Linux, ignore files. rg honours .gitignore / .ignore / .rgignore, which the sandboxed command may write, so one command could hide pkg/.git from the next command's scan. The scan passes --no-ignore (still --hidden, depth-bounded, node_modules excluded). Cost: gitignored trees (dist/, target/, .venv/) are no longer pruned within the scan depth — about +100 ms per command on a tree with 150k ignored files three levels down, and in the tens of milliseconds on ordinary repositories. The scan's output is --null-delimited, since a path may contain a newline and a run cut short by the timeout can end mid-path.
  3. Both platforms, submodules. A submodule's git directory lives under .git/modules/<name>/; its hooks/ and config matched nothing. Linux walks .git/modules of cwd's repository and of each nested repository the scan finds (nested submodules included, under the walk's own generous depth bound rather than the scan's). macOS enumerates the working directory's own submodule git directories as literal paths — exact whatever the submodule is named, and each one pins its directories against being renamed out from under the deny — and matches a nested repository's as **/.git/modules/<name>/…, a single name segment. The earlier **/.git/modules/**/… shape is gone: its middle ** also matched any component named config or hooks, so a branch named feature/config, a ref named config, or a submodule named config failed operations that worked before the PR.
  4. Both platforms, .git files. A linked worktree or submodule checkout has a .git file holding gitdir: <path>; rewriting it hands the host's git a directory the command prepared. An existing .git file is now read-only, and cannot be removed or renamed over either (on macOS a trailing deny file-write-unlink for it, which the read section's unlink re-allow for write roots would otherwise win over). On Linux that is cwd's own and any the scan finds, and the deny paths of each git directory it leads to follow: the named one for a submodule, plus the commondir's (the main repository's .git) for a linked worktree. Pointer targets are validated: a target that exists but is not a git directory is not followed (a gitdir: .. in app/tools/.git would otherwise make an ordinary app/config/ read-only), and a target that does not exist yet is still denied, so the command cannot create it and fill it with hooks before the host's git first uses it. The pointer is read as git reads it — a bounded prefix of a regular file, prefix check, no backtracking regex — so a FIFO or a very large file left at that path cannot block or slow the host. Creating a .git file where none exists stays possible (git worktree add), on macOS only inside the allowed write paths.
  5. Both platforms, commondir and config.worktree. commondir names the git directory whose hooks/ and config git reads, in any git directory and not just a linked worktree's: writing one redirects every deny below it in a single command (verified with git 2.55 — a commondir planted in an ordinary repository makes git status run the other directory's core.fsmonitor and git commit its pre-commit). config.worktree is read instead of config wherever extensions.worktreeConfig is on, which git sparse-checkout init turns on. Both are now denied for every git directory — cwd's, nested repositories', submodule git directories', worktree git directories' — with config.worktree following allowGitConfig and commondir denied regardless.
  6. Linux, two-segment directory names. The match-to-directory mapping compared single path segments against .claude/commands / .claude/agents, so a nested one within scan depth got one bind per existing file and new files stayed creatable. It rides in this change because it is the same match loop: names are matched as segment runs on the path relative to cwd (so a dangerous name in cwd's own location, e.g. a cwd under ~/.vscode/, never counts) and the directory itself is denied.

Failing closed. The three discovery paths used to continue with whatever they had. Now: a directory rg could not read is denied whole (its paths come from rg's own diagnostics); a directory the .git/modules walk could not list is denied whole; and a pointer target that cannot be inspected is denied at the deepest ancestor still reachable. A scan that does not finish inside its timeout aborts the command instead of sandboxing it with a deny list of unknown completeness — the command that runs next is the one that could have made the tree slow to walk, and an unreached nested repository is one with writable hooks. A scan that cannot run at all (no ripgrep) is still logged and not fatal; ripgrep is already a checked Linux dependency.

No change to what an ordinary repository's .git directory allows (index, objects, refs, git commit), to the scan depth, or to Windows. Not behind a flag.

Cost on Linux (bwrap argv). Every git directory contributes up to four --ro-bind pairs (hooks, commondir, config, config.worktree) to the single sh -c string, and every submodule of the working directory's repository is one. Measured, with ~66-character paths: a repository with no submodules wraps to 1,367 bytes / 17 binds, and one with 300 submodule git directories to 162,147 bytes / 1,217 binds — about 536 bytes and 4 binds per submodule, and 40 ms more wrap time. Linux caps a single execve argument at 128 KiB (MAX_ARG_STRLEN), so at these path lengths the ceiling is around 240 submodules, past which execve returns E2BIG and every sandboxed command in that repository fails. Adding commondir and config.worktree halves the headroom the previous head had (two binds per git directory), and a Boost-scale superproject (~170 submodules) is now inside it but not by much. #504's --args <fd> transport removes the ceiling entirely; until it lands this is the one number worth a maintainer's eye.

Git operations these denies break (documented in the README): removing a tree that holds a submodule checkout or a linked worktree (rm -rf lib, git clean -ffdx), git worktree remove / move / repair, git submodule deinit, git submodule update --init for a not-yet-cloned submodule (it copies template hooks into .git/modules/<name>/hooks/), git push -u and git checkout -b x origin/y from a linked-worktree cwd, and git init / git clone into a subdirectory. Enumerating cwd's submodule git directories literally does not bring first-time submodule clone back on macOS: the pattern that covers a not-yet-created git directory is what blocks it, and dropping it would let a command pre-fill .git/modules/<name>/hooks for a submodule the user has not initialised yet, which git reuses when they do.

Known limit, both platforms. A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory holding it aside and create a fresh one in its place (mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git), and the user's next git -C lib status uses the git directory the sandbox chose. On Linux a path the scan reached has its ancestor directories pinned within the scan depth (#514), which blocks this for what the scan reached; on macOS it is blocked for the literal denies (cwd's own repository and its submodule git directories) and not for the pattern ones.

Test plan

test/sandbox/mandatory-deny-paths.test.ts runs each shape through the real wrapper under bwrap on Linux (bubblewrap 0.11, unprivileged user namespaces) and seatbelt on macOS, with a positive control wherever a failure alone would prove nothing: removing and renaming over a pointer file (with a read config present, which is what re-opened them), commondir and config.worktree in cwd's repository, in a submodule git directory and in a nested repository, a repository and a submodule git directory whose HEAD was moved aside, a pointer naming an ordinary directory, a dangling pointer's target, a directory the scan could not read, and a scan that does not finish. The helpers themselves (gitDirDenyPaths, gitFileDenyPaths, submoduleGitDirs) have unit tests for the pointer parse, the size cap, FIFOs at both the pointer and the commondir, multi-segment and nested submodule names, symlinked modules entries, an unlistable directory and the walk's depth bound. test/utils/ripgrep.test.ts covers the NUL split, the dropped truncated tail, and the stderr the caller denies from.

Full npm test matches main's failure set on Linux exactly: 1,100 pass, the same 8 environment-dependent failures as main, none new. Sixteen of the new cases were re-run against the previous head to confirm they fail there (and the FIFO case hangs it outright, which is the bug). Driven by hand through the srt CLI on Linux at the default depth and on macOS with sandbox-exec.

… dirs, .git pointer files, and gitignored paths

The mandatory write-denies keep a sandboxed command from leaving behind
something the host's git later executes. Four shapes slipped through:

- Linux: the ripgrep scan matched a nested repository's hook FILES
  (`**/.git/hooks/**`), one segment deeper than its config, so at the
  default depth a repository directly under cwd had .git/config denied
  but .git/hooks writable. The scan now also matches `**/.git/HEAD`, and
  any file inside a `.git/` marks a repository whose hooks/ and config are
  denied, as for cwd's own .git.
- Linux: rg honoured .gitignore/.ignore/.rgignore, which the command can
  write, so one command could hide a nested repository from the next
  command's scan. The scan passes --no-ignore.
- Both: a submodule's git directory under .git/modules/<name>/ (its
  hooks/ and config) matched nothing. Linux walks .git/modules for git
  directories; macOS adds **/.git/modules/**/{hooks/**,config}.
- Both: a `.git` FILE (linked worktree or submodule checkout) could be
  repointed at a directory the command prepared. An existing one is now
  read-only — on macOS by vnode type, so .git directories are untouched
  and creating a new pointer is still allowed — and the hooks/config it
  leads to are denied: the named git directory's, or for a worktree the
  commondir's (the main repository's) plus an existing config.worktree.

Also: the Linux match-to-directory mapping compared single segments
against the two-segment names .claude/commands and .claude/agents, so a
nested one got per-file binds and new files stayed creatable; names now
match as segment runs on the cwd-relative path.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One linked-worktree gap remains: gitFileDenyPaths() only denies config.worktree when that file already exists. If the common config already has extensions.worktreeConfig=true, Git will consult a subsequently created .git/worktrees/<id>/config.worktree; a writable repo can then create it after wrapping and set executable config such as core.hooksPath. Could creation of config.worktree be blocked whenever worktree config is enabled, rather than only protecting a pre-existing file?

…ative to cwd

macOS: the create re-allow for `.git` pointer files followed the write
denies unconditionally, so a command could create `sub/.git` under a cwd
that allowOnly never covered. It is now intersected with the allowed
write paths and dropped when there are none. The filter helper is no
longer exported.

Linux: rg prefixes each match with its target, so the segment scan also
looked at cwd's own ancestors. With cwd under a directory named like a
dangerous one (~/.vscode/ext/foo), a dangerous file below cwd mapped to a
deny of that ancestor and was itself left writable. Matches are now taken
relative to cwd.

Also:
- An unreadable directory under cwd made rg exit 2 and the scan discard
  every match it had printed. ripGrep now throws RipgrepError carrying
  the partial matches, and the scan keeps them and warns.
- A nested repository's .git/modules is walked like cwd's own, matching
  what the macOS pattern already covers.
- config.worktree is denied whether or not it exists yet, like hooks/
  and config.
- gitFileDenyPaths and submoduleGitDirs treat only ENOENT/ENOTDIR as
  "absent" and log anything else.
- The git deny-path helpers move out of sandbox-utils.ts into
  mandatory-deny-paths.ts.
- Tests: a regression case for each of the above and for the HEAD-only
  nested repository (allowGitConfig), multi-behaviour cases split,
  positive controls for the worktree cases, the unused `append` option
  dropped.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Merged main (no conflicts; picks up the --control-fd fix that made the linux x86-64 leg flaky) and pushed 1dbfe26. Two of the fixes are security-relevant:

  • macOS: the (allow file-write-create <.git pointer filter>) rule was emitted whether or not cwd was inside allowOnly, so it granted a write the allowlist never granted. Reproduced with sandbox-exec: with cwd outside allowOnly, echo 'gitdir: /evil' > sub/.git succeeded. The allow is now intersected with the write roots and is not emitted when allowOnly is empty. After the fix that write gets EPERM; creating a pointer inside the write roots still works, rewriting an existing one is refused, and a denyWithinAllow still blocks creation. Regression test with a control write.
  • Linux: rg is given cwd as its target and prints absolute paths, so the segment scan counted cwd's own ancestor segments (cwd under ~/.vscode/... mapped a match to a deny of ~/.vscode, which was then skipped as outside the allowed paths). Matches are now taken with path.relative(cwd, match); a match that lands outside cwd gets the file itself denied. Test with cwd at .vscode/ext/foo.

Also: rg exit code 2 (an unreadable directory) no longer discards everything the scan listed (the partial matches are kept, with a warning); a nested repository's .git/modules is walked like cwd's; config.worktree is denied whether or not it exists yet; the catch-alls treat only ENOENT/ENOTDIR as absence and log anything else; the git helpers this PR adds live in src/sandbox/mandatory-deny-paths.ts.

Left as is, stated in the README and the description: on macOS a directory holding a pointer file can still be moved aside and replaced (same limit as the other pattern-based denies) and only cwd's own pointer file is followed; a scan that fails with no output at all still fails open, as on main.

Local run (Linux x86-64): eslint and tsc clean; full npm test shows only the failures main has on the same box; with the source reverted and the tests kept, the Linux regression tests fail. The macOS cases run only on CI.

commondir names the git directory whose hooks and config git reads, in
any git directory and not only a linked worktree's, so one write to it
redirects every deny below it. config.worktree is read instead of config
wherever extensions.worktreeConfig is on. Both now come from
gitDirDenyPaths, so every git directory gets them on both platforms.

A repository is recognised by any regular file directly inside its .git
(one `**/.git/*` glob replaces three) and a submodule git directory by
any of HEAD/config/hooks/objects, so moving HEAD aside no longer hides
either from the next command's scan. Detection no longer depends on
allowGitConfig, which governs what is denied and not what is found.

The three discovery paths fail closed. A directory ripgrep could not
read is denied whole, from the paths it names in its own diagnostics; a
directory the .git/modules walk could not list is denied whole; an
unstattable pointer target is denied at the deepest ancestor still
reachable; and a scan that does not finish inside its timeout aborts the
wrap rather than sandboxing with a deny list of unknown completeness.

A pointer target is followed only when it looks like a git directory, so
`gitdir: ..` cannot point the deny list at an ordinary config/ tree, and
an absent target is denied so the command cannot create it and fill it
with hooks before the host's git first uses it. Pointer files are read
as git reads them: a bounded prefix of a regular file, opened
non-blocking, a prefix check and no backtracking regex, so a FIFO or a
very large file left at that path cannot block or slow the host.

On macOS the two mid-`**` module globs are gone, replaced by literal
enumeration of the working directory's own submodule git directories
plus a single-segment nested pattern - exact paths with ancestor pins,
and no more denying refs/heads/config or a submodule named config. An
existing .git pointer also gets a trailing file-write-unlink deny, which
the read section's unlink re-allow for write roots was winning over
whenever a read config was present.

On Linux, placeholder destinations are deduped through seenDenyWrite, so
one destination cannot take two different placeholder mounts and abort
bwrap for every later command in that directory. ripgrep runs --null, so
a path containing a newline is not split in two and a killed run's
truncated tail is dropped rather than denied. The .git/modules walk has
its own depth bound instead of borrowing the scan-depth knob, logs when
the bound cuts it, and follows a symlinked modules entry.
Integration cases for each gap: removing and renaming over a pointer
file with a read config present, commondir and config.worktree in the
working directory's repository, in a submodule git directory and in a
nested repository, a repository and a submodule git directory whose HEAD
was moved aside, a pointer naming an ordinary directory, a dangling
pointer's target, a directory the scan could not read, and a scan that
does not finish.

Unit cases for the helpers: the pointer parse, the size cap, a FIFO at
the pointer and at the commondir, multi-segment and nested submodule
names, a symlinked modules entry, an unlistable directory and the walk's
depth bound. ripGrep gains cases for the NUL split, the dropped
truncated tail and the stderr its caller denies from.

Also drops a duplicated cleanupBwrapMountPoints() call and a comment
describing behaviour this branch had already replaced, and gates the
unreadable-directory case on the platform rather than on getuid, which
is undefined (and so not 0) on Windows.
Spells out commondir and config.worktree and why they are denied, the
difference between the enumerated working-directory repository and the
pattern-matched nested ones, and the fail-closed scan. Lists the git
operations that no longer work inside the sandbox, and states the
residual: a command can still rename aside the directory holding a
pointer and create a fresh one in its place, which the ancestor pins
block on Linux within the scan depth and for the macOS literal denies.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Second round on top of 1dbfe26 (e6ea51c, af1e241, af8a067), from a deeper review; the macOS items were executed against the real Seatbelt profile, the Linux ones confirmed under bubblewrap 0.11.2 and 0.12.0. Several were gaps in what this PR itself sets out to protect, so please review this head rather than the earlier one.

  • macOS: a .git pointer could still be removed or renamed over whenever a read config was present — the pointer filter was only in the wildcard deny file-write* set, and the read section's (allow file-write-unlink … <write roots>) won over it. Added a trailing deny file-write-unlink for the pointer; creating a new one still works.
  • commondir was read but never denied, in any git directory, so one write redirected every hook/config deny this PR adds. Same for config.worktree outside the linked-worktree branch. Both now come from gitDirDenyPaths, so every git directory gets them on both platforms.
  • macOS **/.git/modules/**/… replaced by literal enumeration of cwd's submodule git directories plus a single-segment nested pattern: exact paths with ancestor pins, and no more denying refs/heads/config or a submodule named config.
  • Detection hardened: a nested repository is recognised by any file directly inside its .git (one glob replaces three), a submodule git directory by any of HEAD/config/hooks/objects, and neither depends on allowGitConfig. Moving HEAD aside no longer hides either from the next command's scan.
  • The three discovery paths fail closed (unreadable directories denied whole, unstattable targets denied at the deepest reachable ancestor) and a timed-out scan aborts the wrap instead of proceeding with a partial deny list.
  • Pointer reads are bounded and typed: a FIFO at .git or at <gitdir>/commondir used to block the host on every later wrap, and an 80 KB .git file cost ~1.3 s per command in a backtracking regex. Now: lstat-equivalent type check on the open fd, an 8 KiB cap, and git's own prefix-and-trim parse.
  • A pointer target is validated (HEAD or objects present) before it steers the deny list, and absent placeholder destinations are deduped through seenDenyWrite, so one destination can no longer get two different placeholder mounts and abort bwrap for every later command in that cwd.
  • rg runs --null so a directory name containing a newline is not split into two paths and a killed run's truncated tail is dropped rather than denied.
  • submoduleGitDirs has its own depth bound (with a debug line when it cuts) instead of borrowing the ripgrep depth knob, and follows a symlinked modules entry.
  • Cleanup: duplicate cleanupBwrapMountPoints() and stale comments removed, macGetMandatoryDenyPatterns's "(no filesystem scanning)" doc corrected, the unreachable .. branch and the resolve-then-relative round trip dropped, the per-match closures hoisted into one indexOfSegmentRun, cwd's pointer no longer processed twice, the nested try/catch replaced by one bounded reader, the hand-rolled work list replaced by recursion, gitPointerFilter reusing pathFilter, and the test/utils/ripgrep.test.ts gate that was true on Windows fixed.

Two behaviour notes: a mandatory-deny scan that times out now fails the wrap (it used to proceed with a partial list); and first-time git submodule update --init still does not work inside the macOS sandbox (the not-yet-created git directory must stay denied, or a command could pre-fill .git/modules/<name>/hooks for a submodule git later reuses); the README lists this with the other git operations that no longer work. Each submodule git directory adds four binds (~536 bytes) to the single sh -c string: 300 submodules is ~162 KB, so E2BIG arrives near 240 submodules until the --args transport in #504 lands.

16 new cases fail on 1dbfe26. eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the config.worktree creation gap. One new bypass remains on af8a067 in readGitMetadataFile(): reaching its 8 KiB read limit causes gitFileDenyPaths() to protect only the pointer, although Git still accepts that file.

Reproduced on macOS with real Git and Seatbelt: create a valid gitdir: <target> pointer, then append 9,000 newline bytes. git rev-parse --absolute-git-dir succeeds both before and after padding. With the ordinary pointer, a sandboxed harmless write to the target gitdir/config gets EPERM; with the padded pointer, the same write succeeds. The target hooks/config denies disappeared.

Please read metadata within Git's accepted bound, or fail closed when the pointer cannot be fully validated, and add this padded-pointer regression. The existing mandatory-deny suite passes locally (65 passed, 21 Linux-only skips), so it does not catch this case.

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