Hook repo resolution: git operations and root-resolving hooks must scan the repo they actually operate on
Three reliability findings share one principle: several hooks resolve "the project root" using a rule that can point at the wrong repository when git operations are indirected or the hook's cwd is not the project dir.
reliability-007 (anchor): Unify session-start.py and taskwrite.py on _hooklib.project_root — their local copies ignore CLAUDE_PROJECT_DIR and can resolve the wrong repo
Severity: medium | Confidence: 0.75 | Effort: S
Where:
plugins/ca/hooks/session-start.py:48-58
plugins/ca/hooks/taskwrite.py:33-42
Evidence: session-start.py:48-58 and taskwrite.py:33-42 each define a private project_root() that runs git rev-parse --show-toplevel from the hook's cwd and falls back to os.getcwd(). _hooklib.project_root (hooks/_hooklib.py:272-293) exists precisely because a hook subprocess is not guaranteed to start with the project directory as its cwd, and reads CLAUDE_PROJECT_DIR first. The two local copies skip that env-first read (session-start's also omits encoding="utf-8" on the subprocess). session-start is the linchpin hook: with a wrong root it writes standup/dev markers, appends to overrides.log, and installs git-enforce hooks (_githooks.install(root)) into whatever repo the cwd happens to resolve to; taskwrite mutates that repo's open-tasks.md.
Impact: When the hook subprocess's cwd is not the project dir, SessionStart state, audit-log appends, git-hook installation, and board mutations target a different repository, silently, since every failure path degrades without a breadcrumb.
Recommendation: Delete the local project_root() copies and import _hooklib.project_root in both files (session-start already imports from _hooklib), so the CLAUDE_PROJECT_DIR-first contract holds for every hook uniformly.
Acceptance criteria:
- session-start.py and taskwrite.py resolve root via _hooklib.project_root
- A test with CLAUDE_PROJECT_DIR pointed at a fixture repo and cwd elsewhere shows both hooks operate on the fixture repo
reliability-004 (member): Align git_cwd's -C extraction with the GIT global-options grammar — options before -C make every scan target the wrong repo
Severity: medium | Confidence: 0.8 | Effort: S
Where:
plugins/ca/hooks/pre-bash.py:29-44, 141-146, 411
Evidence: COMMIT_RE/PUSH_RE/ADD_RE build on GIT = \bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?|-\w+))* (line 32), which deliberately tolerates any run of global options before the subcommand. But GIT_C_DIR_RE = \bgit\s+-C\s+(...) (line 44) only matches -C when it is the first token after git. git --no-pager -C ../other commit -m x or git -c k=v -C ../other commit therefore matches COMMIT_RE (guards fire) while git_cwd() falls back to root (line 145), so current_branch, added_lines, staged_paths all run against the project root instead of the repo the command actually commits to. A relative -C dir is also passed as subprocess cwd, resolving against the hook process's cwd, which _hooklib.py:275-277 notes is not guaranteed to be the project dir.
Impact: H-01 checks the wrong repo's branch (feature branch here, main there → allowed commit onto the other repo's main), and the H-09b/H-14 scans read the wrong repo's diff — a clean root diff launders a sensitive commit in the -C target; conversely a dirty root diff false-blocks a clean -C commit.
Recommendation: Extract -C using the same global-options prefix grammar as GIT (scan the matched option run for the last -C value), resolve a relative -C against project_root, and fail closed for commit/push when the -C target cannot be resolved.
Acceptance criteria:
git --no-pager -C <dir> commit is scanned against , not root (test in test_hook_guards.py)
- A relative -C path resolves against project root regardless of hook cwd
reliability-005 (member): git-enforce.py must derive its root from the repo the git hook fires in, not CLAUDE_PROJECT_DIR
Severity: medium | Confidence: 0.7 | Effort: S
Where:
plugins/ca/hooks/git-enforce.py:198-206, 115-127
plugins/ca/hooks/_hooklib.py:272-293
Evidence: git-enforce.py main() calls _hooklib.project_root(), which trusts CLAUDE_PROJECT_DIR first. But git-enforce runs as a .git/hooks/pre-commit|pre-push script of whatever repo the git operation targets, and a git -C ../otherRepo commit issued from Claude's Bash tool inherits CLAUDE_PROJECT_DIR=. pre_commit then does cwd = root (line 116) and scans this project's branch/staged diff/markers while the commit lands in the other repo.
Impact: The spelling-proof backstop (#161) inspects the wrong repository whenever a git hook fires in a sibling arbiter-enabled repo under a Claude session: the other repo's staged secrets/migrations pass if this repo's index is clean (fail-open), and a dirty index here false-blocks a clean commit there. arbiter_active() is likewise evaluated against the wrong repo, so the backstop may be silently dormant where it was installed.
Recommendation: In git-enforce.py, resolve the root from the hook's own execution context (git sets cwd to the target repo's toplevel for pre-commit/pre-push, or use GIT_DIR / git rev-parse --show-toplevel from cwd), and ignore CLAUDE_PROJECT_DIR entirely in this entrypoint.
Acceptance criteria:
- With CLAUDE_PROJECT_DIR pointing at repo A, repo B's installed pre-commit hook scans repo B's staged diff (fixture test)
- test_hook_guards.py or a githooks test pins root resolution for git-enforce
Hook repo resolution: git operations and root-resolving hooks must scan the repo they actually operate on
Three reliability findings share one principle: several hooks resolve "the project root" using a rule that can point at the wrong repository when git operations are indirected or the hook's cwd is not the project dir.
reliability-007 (anchor): Unify session-start.py and taskwrite.py on _hooklib.project_root — their local copies ignore CLAUDE_PROJECT_DIR and can resolve the wrong repo
Severity: medium | Confidence: 0.75 | Effort: S
Where:
plugins/ca/hooks/session-start.py:48-58
plugins/ca/hooks/taskwrite.py:33-42
Evidence: session-start.py:48-58 and taskwrite.py:33-42 each define a private project_root() that runs
git rev-parse --show-toplevelfrom the hook's cwd and falls back to os.getcwd(). _hooklib.project_root (hooks/_hooklib.py:272-293) exists precisely because a hook subprocess is not guaranteed to start with the project directory as its cwd, and reads CLAUDE_PROJECT_DIR first. The two local copies skip that env-first read (session-start's also omits encoding="utf-8" on the subprocess). session-start is the linchpin hook: with a wrong root it writes standup/dev markers, appends to overrides.log, and installs git-enforce hooks (_githooks.install(root)) into whatever repo the cwd happens to resolve to; taskwrite mutates that repo's open-tasks.md.Impact: When the hook subprocess's cwd is not the project dir, SessionStart state, audit-log appends, git-hook installation, and board mutations target a different repository, silently, since every failure path degrades without a breadcrumb.
Recommendation: Delete the local project_root() copies and import _hooklib.project_root in both files (session-start already imports from _hooklib), so the CLAUDE_PROJECT_DIR-first contract holds for every hook uniformly.
Acceptance criteria:
reliability-004 (member): Align git_cwd's -C extraction with the GIT global-options grammar — options before -C make every scan target the wrong repo
Severity: medium | Confidence: 0.8 | Effort: S
Where:
plugins/ca/hooks/pre-bash.py:29-44, 141-146, 411
Evidence: COMMIT_RE/PUSH_RE/ADD_RE build on GIT =
\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?|-\w+))*(line 32), which deliberately tolerates any run of global options before the subcommand. But GIT_C_DIR_RE =\bgit\s+-C\s+(...)(line 44) only matches -C when it is the first token after git.git --no-pager -C ../other commit -m xorgit -c k=v -C ../other committherefore matches COMMIT_RE (guards fire) while git_cwd() falls back toroot(line 145), so current_branch, added_lines, staged_paths all run against the project root instead of the repo the command actually commits to. A relative -C dir is also passed as subprocess cwd, resolving against the hook process's cwd, which _hooklib.py:275-277 notes is not guaranteed to be the project dir.Impact: H-01 checks the wrong repo's branch (feature branch here, main there → allowed commit onto the other repo's main), and the H-09b/H-14 scans read the wrong repo's diff — a clean root diff launders a sensitive commit in the -C target; conversely a dirty root diff false-blocks a clean -C commit.
Recommendation: Extract -C using the same global-options prefix grammar as GIT (scan the matched option run for the last -C value), resolve a relative -C against project_root, and fail closed for commit/push when the -C target cannot be resolved.
Acceptance criteria:
git --no-pager -C <dir> commitis scanned against , not root (test in test_hook_guards.py)reliability-005 (member): git-enforce.py must derive its root from the repo the git hook fires in, not CLAUDE_PROJECT_DIR
Severity: medium | Confidence: 0.7 | Effort: S
Where:
plugins/ca/hooks/git-enforce.py:198-206, 115-127
plugins/ca/hooks/_hooklib.py:272-293
Evidence: git-enforce.py main() calls _hooklib.project_root(), which trusts CLAUDE_PROJECT_DIR first. But git-enforce runs as a .git/hooks/pre-commit|pre-push script of whatever repo the git operation targets, and a
git -C ../otherRepo commitissued from Claude's Bash tool inherits CLAUDE_PROJECT_DIR=. pre_commit then doescwd = root(line 116) and scans this project's branch/staged diff/markers while the commit lands in the other repo.Impact: The spelling-proof backstop (#161) inspects the wrong repository whenever a git hook fires in a sibling arbiter-enabled repo under a Claude session: the other repo's staged secrets/migrations pass if this repo's index is clean (fail-open), and a dirty index here false-blocks a clean commit there. arbiter_active() is likewise evaluated against the wrong repo, so the backstop may be silently dormant where it was installed.
Recommendation: In git-enforce.py, resolve the root from the hook's own execution context (git sets cwd to the target repo's toplevel for pre-commit/pre-push, or use GIT_DIR /
git rev-parse --show-toplevelfrom cwd), and ignore CLAUDE_PROJECT_DIR entirely in this entrypoint.Acceptance criteria: