Mandatory denies: nested repo hooks at scan depth, submodule git dirs, .git pointer files, gitignored paths - #515
Conversation
… 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
left a comment
There was a problem hiding this comment.
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?
No conflicts.
…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.
|
Merged
Also: 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 Local run (Linux x86-64): eslint and |
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.
|
Second round on top of
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 16 new cases fail on |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
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.
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.fsmonitorin a git config, an IDE task. For git that covered the working directory's.git/hooksand.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.pkg/.git/configbut 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.githolds — rather than byHEADalone — matters because creating and moving files inside a.gitis otherwise allowed: one command could moveHEADaside and the next command's scan would miss the repository entirely. Detection no longer depends onallowGitConfigeither, which governs what is denied and not what is found.rghonours.gitignore/.ignore/.rgignore, which the sandboxed command may write, so one command could hidepkg/.gitfrom the next command's scan. The scan passes--no-ignore(still--hidden, depth-bounded,node_modulesexcluded). 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..git/modules/<name>/; itshooks/andconfigmatched nothing. Linux walks.git/modulesof 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 namedconfigorhooks, so a branch namedfeature/config, a ref namedconfig, or a submodule namedconfigfailed operations that worked before the PR..gitfiles. A linked worktree or submodule checkout has a.gitfile holdinggitdir: <path>; rewriting it hands the host's git a directory the command prepared. An existing.gitfile is now read-only, and cannot be removed or renamed over either (on macOS a trailingdeny file-write-unlinkfor 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 thecommondir'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 (agitdir: ..inapp/tools/.gitwould otherwise make an ordinaryapp/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.gitfile where none exists stays possible (git worktree add), on macOS only inside the allowed write paths.commondirandconfig.worktree.commondirnames the git directory whosehooks/andconfiggit 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 — acommondirplanted in an ordinary repository makesgit statusrun the other directory'score.fsmonitorandgit commititspre-commit).config.worktreeis read instead ofconfigwhereverextensions.worktreeConfigis on, whichgit sparse-checkout initturns on. Both are now denied for every git directory — cwd's, nested repositories', submodule git directories', worktree git directories' — withconfig.worktreefollowingallowGitConfigandcommondirdenied regardless..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
rgcould not read is denied whole (its paths come fromrg's own diagnostics); a directory the.git/moduleswalk 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 (noripgrep) is still logged and not fatal;ripgrepis already a checked Linux dependency.No change to what an ordinary repository's
.gitdirectory 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-bindpairs (hooks, commondir, config, config.worktree) to the singlesh -cstring, 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 singleexecveargument at 128 KiB (MAX_ARG_STRLEN), so at these path lengths the ceiling is around 240 submodules, past whichexecvereturnsE2BIGand every sandboxed command in that repository fails. Addingcommondirandconfig.worktreehalves 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 --initfor a not-yet-cloned submodule (it copies template hooks into.git/modules/<name>/hooks/),git push -uandgit checkout -b x origin/yfrom a linked-worktree cwd, andgit init/git cloneinto 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>/hooksfor 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 nextgit -C lib statususes 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.tsruns 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),commondirandconfig.worktreein cwd's repository, in a submodule git directory and in a nested repository, a repository and a submodule git directory whoseHEADwas 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 thecommondir, multi-segment and nested submodule names, symlinkedmodulesentries, an unlistable directory and the walk's depth bound.test/utils/ripgrep.test.tscovers the NUL split, the dropped truncated tail, and the stderr the caller denies from.Full
npm testmatchesmain's failure set on Linux exactly: 1,100 pass, the same 8 environment-dependent failures asmain, 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 thesrtCLI on Linux at the default depth and on macOS withsandbox-exec.