Mandatory denies: nested repo hooks at scan depth, submodule git dirs, .git pointer files, gitignored paths - #515
Open
ronleizrowice-ant wants to merge 1 commit into
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?
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.
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 currently covers the working directory's.git/hooksand.git/config, and nested repositories found by the Linux ripgrep scan (**/.git/hooks/**,**/.git/config) or matched by pattern on macOS. Four shapes slip through; all of them are reachable with the default configuration and each was confirmed under bwrap / seatbelt before the change:pkg/.git/configbut the hook files sit one segment deeper (pkg/.git/hooks/pre-commit), so the nested repository's config was read-only while its hooks directory stayed writable. The scan now also looks for**/.git/HEAD, and any file found inside a.git/marks a repository whosehooks/and (unlessallowGitConfig)configare denied — the rule already applied to cwd's own.git.rghonours.gitignore/.ignore/.rgignore, which the sandboxed command may write, so one command could addpkg/to.gitignoreand the next command's scan no longer sawpkg/.git. The scan passes--no-ignore(still--hidden, still depth-bounded,node_modulesstill excluded). Cost: gitignored trees (dist/,target/,.venv/) are no longer pruned, so the walk reads every directory withinmandatoryDenySearchDepth - 1levels and lists the entries one level further; with the default depth that is directories at most two below cwd, which stayed in the tens of milliseconds on the trees I tried, but it is the one behavioural cost here and worth a maintainer's eye..git/modules/<name>/; itshooks/andconfig(whatgit commitinside the submodule consults) matched nothing. Linux walks.git/modulesfor git directories (nested submodules included, bounded by the scan depth); macOS adds**/.git/modules/**/hooks/**and**/.git/modules/**/config..gitfiles. A linked worktree or submodule checkout has a.gitfile holdinggitdir: <path>. Rewriting it to point at a directory the command prepared hands the host's git that directory's config and hooks. An existing.gitfile is now read-only — cwd's own and, on Linux, any the scan finds (**/.git); on macOS any regular file named.gitunder cwd, by vnode type so a.gitdirectory is untouched — and the hooks/config it leads to are denied too: the named git directory's for a submodule, thecommondir's (the main repository's.git) for a linked worktree, plus an existingconfig.worktree. Creating a.gitfile where none exists is still allowed (git worktree add,git submodule update --init).Along the way, the Linux match-to-directory mapping compared single path segments against the two-segment names
.claude/commands/.claude/agents, so a nested one within scan depth got one bind per existing file and new files stayed creatable; names are now matched as segment runs on the cwd-relative path and the directory itself is denied.No change to what an ordinary repository's
.gitdirectory allows (index, objects, refs,git commit,git init), to the scan depth, or to Windows.Test plan
test/sandbox/mandatory-deny-paths.test.tsgains a nested repository (gitignored), a submodule (git directory under.git/modules+.gitfile checkout), a linked worktree of the test repository checked out inside it, and a nested.claude/commands; new cases (run under bwrap on Linux and seatbelt on macOS): nested.git/config, existing and new nested hooks at the default depth, the rest of the nested repository writable, submodule config/hooks under.git/modules, repointing the submodule's.gitfile, creating a fresh.gitfile (allowed), the main repository's hook from inside the worktree checkout, nested.claude/commandsdenied as a directory at depth 4; the existing worktree-as-cwd test also asserts the pointer is read-only. 36 pass on macOS, 50 on Linux (bubblewrap 0.9, unprivileged userns container); fullnpm teston both matchesmain.Driven through the
srtCLI on Linux at the default depth withnested/gitignored: writes tonested/.git/hooks/pre-commit, a newnested/.git/hooks/post-merge,nested/.git/config,.git/modules/lib/{config,hooks/*},lib/.git, and (from the worktree) its.gitall failEROFS;nested/file.txt, a newsub2/.git, worktree files, andgit statusin each checkout work. On macOS the same set failsEPERM,mv evil lib/.gitandrm lib/.gitincluded, while.git/index,.git/objects/*, a new repository's.git/, and a new.gitfile are writable.