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 1 commit 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 1 commit into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-nested-repo-hooks-depth

Conversation

@ronleizrowice-ant

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 currently covers the working directory's .git/hooks and .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:

  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 (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 whose hooks/ and (unless allowGitConfig) config are denied — the rule already applied to cwd's own .git.
  2. Linux, ignore files. rg honours .gitignore / .ignore / .rgignore, which the sandboxed command may write, so one command could add pkg/ to .gitignore and the next command's scan no longer saw pkg/.git. The scan passes --no-ignore (still --hidden, still depth-bounded, node_modules still excluded). Cost: gitignored trees (dist/, target/, .venv/) are no longer pruned, so the walk reads every directory within mandatoryDenySearchDepth - 1 levels 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.
  3. Both platforms, submodules. A submodule's git directory lives under the superproject's .git/modules/<name>/; its hooks/ and config (what git commit inside the submodule consults) matched nothing. Linux walks .git/modules for git directories (nested submodules included, bounded by the scan depth); macOS adds **/.git/modules/**/hooks/** and **/.git/modules/**/config.
  4. Both platforms, .git files. A linked worktree or submodule checkout has a .git file holding gitdir: <path>. Rewriting it to point at a directory the command prepared hands the host's git that directory's config and hooks. An existing .git file is now read-only — cwd's own and, on Linux, any the scan finds (**/.git); on macOS any regular file named .git under cwd, by vnode type so a .git directory is untouched — and the hooks/config it leads to are denied too: the named git directory's for a submodule, the commondir's (the main repository's .git) for a linked worktree, plus an existing config.worktree. Creating a .git file 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 .git directory allows (index, objects, refs, git commit, git init), to the scan depth, or to Windows.

Test plan

test/sandbox/mandatory-deny-paths.test.ts gains a nested repository (gitignored), a submodule (git directory under .git/modules + .git file 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 .git file, creating a fresh .git file (allowed), the main repository's hook from inside the worktree checkout, nested .claude/commands denied 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); full npm test on both matches main.

Driven through the srt CLI on Linux at the default depth with nested/ gitignored: writes to nested/.git/hooks/pre-commit, a new nested/.git/hooks/post-merge, nested/.git/config, .git/modules/lib/{config,hooks/*}, lib/.git, and (from the worktree) its .git all fail EROFS; nested/file.txt, a new sub2/.git, worktree files, and git status in each checkout work. On macOS the same set fails EPERM, mv evil lib/.git and rm lib/.git included, while .git/index, .git/objects/*, a new repository's .git/, and a new .git file are writable.

… 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?

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