Skip to content

fix(hooks): recognize git -C <path> in guard-git.sh#1004

Open
carlos-alm wants to merge 7 commits intomainfrom
fix/guard-git-detect-work-dir
Open

fix(hooks): recognize git -C <path> in guard-git.sh#1004
carlos-alm wants to merge 7 commits intomainfrom
fix/guard-git-detect-work-dir

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • guard-git.sh subcommand patterns (git\s+push, git\s+commit, git\s+reset, etc.) did not match git -C <path> <subcmd> — the -C global option between git and the subcommand prevented the regex from firing.
  • Consequence: git -C <worktree> push bypassed both branch-name validation and the destructive-command blocklist (add ., reset, clean, stash, restore, checkout --). Parallel-session agents hit this when a plain git push from a non-conforming cwd was denied and they switched to git -C <pr-worktree> push as a workaround — the workaround succeeded only because the hook couldn't recognize it as a push.
  • Fix normalizes the command by stripping git -C <path> before subcommand matching, and extends detect_work_dir to extract the -C target so validate_branch_name and the commit edit-log check read from the correct worktree.

Changes

  • NCOMMAND derived from $COMMAND with git -C "<path>" / git -C <path> stripped — all subcommand greps now run against $NCOMMAND.
  • detect_work_dir recognizes both cd "<dir>" && (existing) and git -C "<dir>" (new) forms and returns the target worktree path.
  • Commit validation reads session-edits.log and git diff --cached from the target worktree when -C is present.

Test plan

Local harness covering 18 cases (all pass):

  • Push branch validation: plain / cd <dir> && / git -C <dir> forms, both conforming and non-conforming target branches.
  • Destructive-block coverage: git reset, git clean -fd, git add ., git stash — all blocked whether or not -C is used (bypass closed).
  • Safe forms unchanged: git restore --staged, git log, gh pr list, including -C variants.

The subcommand grep patterns (`git\s+push`, `git\s+commit`, `git\s+reset`,
etc.) did not match when a global option like `-C <path>` appeared between
`git` and the subcommand, so `git -C <worktree> push` bypassed both branch
validation and the destructive-command blocklist. Parallel-session agents
hit this when pushing from a cwd on a non-conforming branch to a PR
worktree — the workaround `git -C <pr-worktree> push` succeeded only
because the hook failed to recognize it as a push.

Fix:
- Normalize `$COMMAND` into `$NCOMMAND` with `git -C <path>` stripped, and
  run all subcommand greps against the normalized form.
- Extend `detect_work_dir` to extract the `-C` target (in addition to the
  existing `cd "<dir>" &&` form) so `validate_branch_name` reads the
  target worktree's branch, not the cwd's.
- Apply the same detection to the commit edit-log check so
  `git -C <worktree> commit` reads session-edits.log and `diff --cached`
  from the target repo.

Verified with a local test harness covering 18 cases: plain / `cd ... &&`
/ `git -C ...` forms of push, destructive blocks (add ., reset, clean,
stash), and safe forms (restore --staged, log, gh pr list).
@claude
Copy link
Copy Markdown

claude Bot commented Apr 21, 2026

Claude finished @carlos-alm's task —— View job


Reviewing PR: fix(hooks): recognize git -C <path> in guard-git.sh

Todo List:

  • Read and analyze the changed file (guard-git.sh)
  • Phase 1: Root cause analysis - determine if this fixes a real security issue
  • Phase 2: Configuration change detection (skip - shell script fix)
  • Phase 3: Test integrity assessment
  • Phase 4: Code quality review
  • Phase 5: Backlog compliance evaluation
  • Provide final assessment and rating

Phase 1: Root Cause Analysis ✅

Problem Being Solved: Legitimate security vulnerability in guard-git.sh where git -C <path> commands completely bypassed both branch validation AND destructive command blocking. The regex patterns like git\s+push failed to match when -C <path> appeared between git and the subcommand.

Why This Approach: This is a proper fix, not a workaround. The solution:

  1. Normalizes commands by stripping git -C "<path>" patterns before regex matching
  2. Extends working directory detection to handle -C flag
  3. Ensures validation logic operates on the correct target repository

Risk Assessment: HIGH - This was a genuine security bypass. Agents could use git -C to circumvent ALL protections, including pushing from wrong branches and executing dangerous commands like git -C <path> reset --hard.

Phase 3: Test Integrity ✅

Test Coverage: PR mentions "local test harness covering 18 cases" but no automated tests are included. However, this is acceptable for hook scripts where manual verification is standard practice. The described test cases cover:

  • Push validation: plain/cd/git -C forms, conforming and non-conforming branches
  • Destructive command blocking: reset, clean -fd, add ., stash
  • Safe command preservation: restore --staged, log, gh pr list

Test Approach: Sound - tests both the security fix and verifies no regression in existing functionality.

Phase 4: Code Quality Review ✅

Implementation Analysis:

  • Line 32: Regex normalization is robust - handles both quoted and unquoted paths
  • Lines 93-95: detect_work_dir() properly extracts -C targets with correct precedence (cd takes priority)
  • Lines 141-147: Commit validation correctly uses detected work dir for both git -C commands and file staging checks
  • Security: No command injection risks - uses proper shell quoting throughout

Minor Issues:

  • Line 97 whitespace trimming is overly complex but harmless
  • Could benefit from comments explaining the regex patterns, but functionality is correct

Overall: Clean, security-focused implementation with proper error handling.

Phase 5: Backlog Compliance ✅

Root Cause Analysis

Problem Being Solved: Critical security vulnerability where git -C <path> commands bypassed ALL hook protections (branch validation + destructive command blocking)
Why This Approach: Direct regex fix to handle -C flag positioning, plus proper working directory detection
Risk Assessment: HIGH - Complete security bypass affecting parallel session safety

Backlog Compliance

  • Zero-dep: ✓ — Pure shell script fix, no new dependencies
  • Foundation-aligned: ✓ — Improves P6 (parallel sessions) safety without violating any principles
  • Problem-fit: 4/5 — Directly prevents AI agents from bypassing safety measures that protect multi-agent workflows
  • Breaking: No — Internal hook fix, no API/CLI changes
  • Tier: 1

Critical Concerns

None. This is a legitimate security fix with clean implementation.

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: Critical security fix with solid implementation. Fixes genuine vulnerability rather than masking symptoms. Well-tested manually with comprehensive coverage. Only missing the 5th star due to complexity of regex patterns that could benefit from better documentation.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 21, 2026

Greptile Summary

This PR closes a meaningful security gap in guard-git.sh: git -C <path> <subcommand> commands previously bypassed both the destructive-command blocklist and branch-name validation. The fix introduces two-pass NCOMMAND normalization, a subcommand-hint segment extractor in detect_work_dir, and greedy .*-C anchoring so the last (effective) -C path is resolved per segment. All three concerns raised in the prior review round (multi--C chain bypass, cd/-C precedence inversion, greedy anchor picking the wrong worktree) have been addressed.

Confidence Score: 4/5

Safe to merge; all prior P0/P1 bypass vectors are closed. Two P2 portability nits remain.

All previously flagged bypass issues (multi-C chain, cd/C precedence, wrong worktree on chained &&) are correctly resolved. The remaining findings are a BSD awk RS portability concern and an overly-broad -C grep guard — both P2 with no security impact in the current agent-generated-command context.

.claude/hooks/guard-git.sh — awk segment-splitting portability (line 109) and -C detection pattern breadth (line 120).

Important Files Changed

Filename Overview
.claude/hooks/guard-git.sh Core bypass vector closed: NCOMMAND two-pass normalization, detect_work_dir subcommand-hint segmenting, and last-C-wins greedy sed all look correct. Two minor P2 portability/pattern concerns remain (BSD awk RS, -C grep false-positive).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Raw $COMMAND"] --> B["NCOMMAND: strip 'git -C path'
(2-pass sed, quoted + unquoted)"]
    B --> C{Blocklist checks
git add/reset/clean
checkout -- / stash / restore}
    C -->|match| D["deny()"]
    C -->|no match| E{git push
or gh pr create?}
    E -->|yes| F["validate_branch_name(subcmd)"]
    F --> G["detect_work_dir(subcmd)"]
    G --> H{awk: find &&-segment
matching subcmd}
    H -->|segment found| I["search_str = segment"]
    H -->|not found| J["search_str = $COMMAND"]
    I --> K{git -C in
search_str?}
    J --> K
    K -->|yes| L["sed greedy .*-C → LAST -C path"]
    K -->|no| M{cd prefix
in $COMMAND?}
    M -->|yes| N["sed → cd target"]
    M -->|no| O["work_dir = '' (use cwd)"]
    L --> P["git -C work_dir rev-parse HEAD"]
    N --> P
    O --> Q["git rev-parse HEAD"]
    P --> R{branch matches pattern?}
    Q --> R
    R -->|no| D
    R -->|yes| S["exit 0 (allow)"]
    E -->|git commit| T["detect_work_dir(commit)"]
    T --> G
    T --> U["git -C WORK_DIR diff --cached"]
    U --> V{staged files in
session-edits.log?}
    V -->|unexpected files| D
    V -->|ok| S
Loading

Fix All in Claude Code

Reviews (6): Last reviewed commit: "fix(hooks): resolve per-subcommand work ..." | Re-trigger Greptile

Comment thread .claude/hooks/guard-git.sh Outdated
# Normalize: strip `git -C "<path>"` / `git -C <path>` so downstream subcommand
# patterns (git\s+push, git\s+commit, …) match regardless of whether `-C` is
# present. detect_work_dir still inspects the raw $COMMAND to find the target.
NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multiple unquoted -C options leave NCOMMAND partially un-normalized

The two sed substitutions run sequentially on the same line. Each runs with /g, but after the first match replaces git -C /path1 with git, the remainder of the string is -C /path2 push — there is no second git token for the pattern to re-anchor on. So git -C /path1 -C /path2 push (two unquoted -C options) becomes git -C /path2 push in NCOMMAND, which does not match git\s+push, silently bypassing branch validation.

A second pass of the same sed expression on the already-normalized string would collapse the leftover -C option:

Suggested change
NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g')
NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g')
NCOMMAND=$(echo "$NCOMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g')

git -C with multiple flags is rare in agent-generated commands, but closing the gap keeps the bypass surface minimal.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a second sed pass so the -C normalization re-anchors on git after the first replacement. Also tightened the unquoted pattern ([^"[:space:]][^[:space:]]*) so it no longer mis-matches the opening " of a quoted path on the second pass (which would have left a trailing path" in NCOMMAND). Local smoke test covers git -C /a -C /b push (unquoted) and git -C "/p a/b" -C "/p c/d" push (quoted) — both collapse to git push and match git\s+push.

Comment thread .claude/hooks/guard-git.sh Outdated
Comment on lines +93 to +95
if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then
work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
fi
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 cd X && git -C Y resolves to the cd target, not the -C target

detect_work_dir prefers the cd "..." path and skips the -C check when a cd prefix is present (if [ -z "$work_dir" ]). For a command like cd /tmp && git -C /worktree push, the function returns /tmp, but git will operate on /worktree. Branch validation would then read the HEAD from /tmp rather than the actual target worktree.

This combination is unusual in practice, but for completeness the -C path should take precedence when both are present (it is the explicit git-level override):

detect_work_dir() {
  local work_dir=""
  # git -C takes explicit precedence over any ambient cd prefix
  if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then
    work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
  fi
  if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE '^\s*cd\s+'; then
    work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p')
  fi
  work_dir="${work_dir%"${work_dir##*[![:space:]]}"}"
  echo "$work_dir"
}

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — detect_work_dir now checks git -C first and only falls back to the cd prefix when no -C is present. -C is the explicit git-level override, so cd /tmp && git -C /worktree push now correctly returns /worktree. Smoke tested all four combinations (only-cd, only-C, both, neither).

…work-dir

Impact: 4 functions changed, 5 affected
- Normalize NCOMMAND twice so multi-`-C` chains (e.g. `git -C /a -C /b push`)
  collapse fully; with a single pass, the pattern cannot re-anchor on `git`
  after the first replacement, leaving a residual `-C /b` that would bypass
  subcommand matching.
- Unquoted pattern now requires a non-quote first char so it does not
  mis-match the opening `"` of a quoted path; the previous pattern could
  swallow `"/p` and leave a trailing `path"` in NCOMMAND.
- `detect_work_dir` gives `git -C` precedence over `cd`: `-C` is an explicit
  git-level override, so `cd /tmp && git -C /worktree push` now correctly
  reports /worktree (was /tmp).
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread .claude/hooks/guard-git.sh Outdated
Comment on lines +97 to +99
if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then
work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
fi
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 detect_work_dir extracts the first -C path, not the last

The greedy .*git anchor in the sed pattern causes it to capture the first -C path when multiple -C options are chained. For git -C /conforming-wt -C /bad-branch-wt push, detect_work_dir returns /conforming-wt while git actually operates on /bad-branch-wt. validate_branch_name then reads the HEAD of the wrong worktree and approves the push — a branch-validation bypass using a prefix -C that points to any conforming worktree.

NCOMMAND collapses the chain to git push correctly (two-pass sed with /g), so the subcommand is detected and validate_branch_name is called, but the worktree resolution is wrong.

A one-pass strip of NCOMMAND-style normalization removes the first -C and leaves only the last one, which can then be extracted:

  if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then
    local cmd_last_c
    cmd_last_c=$(echo "$COMMAND" | \
      sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^"[:space:]][^[:space:]]*/\1git/g')
    if echo "$cmd_last_c" | grep -qE 'git\s+-C\s+'; then
      work_dir=$(echo "$cmd_last_c" | sed -nE \
        's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
    else
      work_dir=$(echo "$COMMAND" | sed -nE \
        's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
    fi
  fi

This keeps a single--C command working while correctly picking the last path for multi--C chains.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — detect_work_dir now accepts an optional subcommand hint and, within the chosen segment, uses greedy .*-C (anchoring on the LAST -C) rather than .*git\s+-C (anchoring on the first). So git -C /ok -C /bad push now resolves to /bad, matching git's cumulative -C semantics. Verified with a regression harness: single -C, multi -C (last wins), triple -C, and mixed quoted/unquoted paths all resolve correctly. The multi--C bypass is closed — e.g. git -C /conforming-wt -C /bad-branch-wt push is now blocked by validate_branch_name.

Comment thread .claude/hooks/guard-git.sh Outdated
Comment on lines +97 to +98
if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then
work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 detect_work_dir targets the last git -C in a chained command, bypassing push branch validation

The sed pattern uses a greedy .*git anchor, so it always extracts the -C path from the last git token in the string. For a chained command like git -C /wt-bad-branch push && git -C /wt-good-branch commit -m "msg", both the push and commit checks fire on NCOMMAND, but detect_work_dir returns /wt-good-branch (from the trailing commit). validate_branch_name then reads the HEAD of the wrong worktree and approves the push — a branch-validation bypass whenever the last sub-command targets a conforming worktree.

validate_branch_name needs the -C path of the first git token (the one driving the push), while the commit-validation block needs the last. One approach is to pass a hint to detect_work_dir so each caller can anchor to the right sub-command, or strip all sub-commands after the first && before calling detect_work_dir from within validate_branch_name.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — detect_work_dir now takes a subcommand hint (push, commit) and narrows to the &&-separated segment whose git invocation runs that subcommand before extracting -C. For git -C /wt-bad push && git -C /wt-good commit -m "msg", the push block calls validate_branch_name push which resolves to /wt-bad (correctly blocking the non-conforming branch), and the commit block calls detect_work_dir commit which resolves to /wt-good for the edit-log check. Regression test suite covers this exact chained scenario and confirms push/commit each see their own worktree.

Chained commands like 'git -C /a push && git -C /b commit' and multi-C
invocations like 'git -C /ok -C /bad push' escaped branch validation
because detect_work_dir extracted the first -C path via greedy anchor
on '.*git', not the effective -C git would honor.

- detect_work_dir now accepts an optional subcommand hint (push, commit).
  When given, it narrows to the &&-separated segment whose git invocation
  runs that subcommand.
- Within the chosen segment the LAST -C wins — git's -C is cumulative,
  so the final -C is the effective CWD.
- validate_branch_name and the commit-edit-log block pass the appropriate
  hint (push/commit) so each sees its own target worktree.

Regression tests cover: single -C, multi -C (last wins), chained push/commit
in different worktrees, quoted paths, cd-only, mixed precedence, and the
original 18-case harness from the PR.
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