Skip to content

fix(agent-isolation): make the gitignore safety check hook-safe - #1093

Open
potiuk wants to merge 1 commit into
apache:mainfrom
potiuk:fix/gitignore-check-under-git-hooks
Open

fix(agent-isolation): make the gitignore safety check hook-safe#1093
potiuk wants to merge 1 commit into
apache:mainfrom
potiuk:fix/gitignore-check-under-git-hooks

Conversation

@potiuk

@potiuk potiuk commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

  • sandbox-add-project-root.sh refused to write settings.local.json on every invocation from a git hook, warning is not gitignored about a file that was correctly ignored. Since the post-checkout hook is exactly how the helper reaches a fresh worktree, the per-worktree allowlist entry that setup-isolated-setup-install: "." in allowRead does not cover CWD; need defensive abs-path resolution at install time #197 asks for was never written there — reads under a new worktree kept hitting sandbox denials.
  • Cause: the check ran cd "$(dirname "$file")" && git check-ignore "$file". Git hooks export GIT_DIR, and with GIT_DIR set but no GIT_WORK_TREE git treats the current directory as the work-tree root — so after the cd into .claude/, the root-anchored /.claude/settings.local.json pattern was evaluated against .claude/ as the root, where it cannot match.
  • Fixing that surfaced a second, older bug in the same block, described below.

Type of change

  • Skill change (.claude/skills/<name>/) — eval fixtures updated below
  • Tool / bridge contract (tools/<system>/*.md)
  • Python package (tools/*/ with pyproject.toml) — tests only
  • Groovy reference impl
  • Cross-cutting (RFC, AGENTS.md, sandbox, privacy-LLM)
  • Documentation (docs/, README.md, CONTRIBUTING.md)
  • Project template (projects/_template/)
  • CI / dev loop (prek, workflows, validators)
  • Other: shell helper in tools/agent-isolation/

The second bug

The fallback guard tested [ -d "$(dirname "$file")/.." ], which is false when .claude/ does not exist yet. On a first run the check therefore matched neither branch and fell through to writing — so the safety check protected nothing in precisely the case it was written for. The replacement walks up to the nearest existing ancestor before asking git anything, so it holds whether or not .claude/ is present.

Test plan

Five regression tests in TestGitignoreSafetyCheck, covering both bugs:

Test Guards
test_refuses_when_not_gitignored the missing-.claude/ fall-through (bug 2)
test_writes_when_git_dir_is_exported the GIT_DIR misresolution (bug 1)
test_git_dir_exported_still_records_repo_root the recorded path is the repo root, not .claude/
test_git_dir_exported_still_refuses_when_not_gitignored the fix does not turn the safety check into a no-op
test_works_in_linked_worktree the real-world trigger, in an actual git worktree

All five fail against the previous script — verified by reverting the helper and re-running.

Two things worth knowing about how the tests are written:

  • They pre-create .claude/ for the GIT_DIR cases. Without that they pass against the buggy script too, because the old code's cd failed and it fell through to writing anyway — the GIT_DIR bug only bites when .claude/ already exists, as it does in a real adopter repo. My first draft got this wrong and proved nothing.
  • They neutralise global and system git config via an explicit core.excludesFile override. Pointing GIT_CONFIG_GLOBAL at an empty file is not enough: with core.excludesFile unset git still falls back to $XDG_CONFIG_HOME/git/ignore (~/.config/git/ignore), which commonly already contains **/.claude/settings.local.json and would mask the "refuses" cases — passing on a clean CI box and failing on a developer machine, or vice versa.

Checks run:

  • uv run pytest for tools/agent-isolation — 88 passed
  • ruff check / ruff format / mypy (workspace) — passed via pre-commit hooks on the changed files
  • prek run --all-filesnot run; hooks ran on the changed files at commit time
  • Groovy bridges: n/a
  • Skill eval suite: n/a — no skill changed

RFC-AI-0004 compliance

Linked issues

Refs #197 — the per-worktree allowlist defence that this helper implements, and which was inert inside hooks.

Notes for reviewers (optional)

  • env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE is held in a variable and word-split at the call sites. That is deliberate and safe here (fixed, space-free tokens), but say the word if you would rather see the two calls spelled out in full.
  • I strip GIT_INDEX_FILE as well as the two that matter today. It does not affect check-ignore, but hooks export it and leaving it set invites the same class of surprise later.
  • Found while adopting the framework in apache/airflow, where the hook fired the false warning on every git checkout / rebase in a worktree.

sandbox-add-project-root.sh refused to write settings.local.json on
every invocation from a git hook, reporting "is not gitignored" for a
file that was correctly ignored. Because the post-checkout hook is
exactly how the helper reaches a fresh worktree, the per-worktree
sandbox-allowlist entry that issue apache#197 asks for was never written
there -- reads under a new worktree kept hitting sandbox denials.

The check ran `cd "$(dirname "$file")" && git check-ignore "$file"`.
Git hooks export GIT_DIR, and with GIT_DIR set but no GIT_WORK_TREE
git treats the current directory as the work-tree root -- so after the
cd into .claude/, the root-anchored pattern
`/.claude/settings.local.json` was evaluated against .claude/ as the
root, where it cannot match.

Discovery now runs with GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE
stripped, and check-ignore runs from the resolved toplevel instead of
from a subdirectory.

Fixing that surfaced a second, older bug in the same block: the
fallback guard tested `[ -d "$(dirname "$file")/.." ]`, which is false
when .claude/ does not exist yet. On a first run the check therefore
matched neither branch and fell through to writing, so the safety
check protected nothing in precisely the case it was written for. The
replacement walks up to the nearest existing ancestor before asking
git anything, so it holds whether or not .claude/ is present.

Five regression tests cover both bugs; all five fail against the
previous script. They neutralise global and system git config,
including an explicit core.excludesFile override -- pointing
GIT_CONFIG_GLOBAL at an empty file is not enough, because git still
falls back to ~/.config/git/ignore, which commonly already ignores
settings.local.json and would mask the "refuses" cases.
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.

1 participant