Skip to content
72 changes: 52 additions & 20 deletions .claude/hooks/guard-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)(git|gh)\s+'; then
exit 0
fi

# 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.
# The unquoted pattern requires a non-quote first char so it does not mis-match
# the opening `"` of a quoted path (which would leave a trailing `path"` in
# NCOMMAND). The pattern re-anchors on `git`, so multi-`-C` chains (e.g.
# `git -C /a -C /b push`) need a second pass to collapse the residual `-C`.
NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^"[: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:]][^[:space:]]*/\1git/g')

deny() {
local reason="$1"
node -e "
Expand All @@ -43,46 +53,63 @@ deny() {
# --- Block dangerous commands ---

# git add . / git add -A / git add --all (broad staging)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+add\s+(\.\s*$|-A|--all)'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+add\s+(\.\s*$|-A|--all)'; then
deny "BLOCKED: 'git add .' / 'git add -A' stages ALL changes including other sessions' work. Stage specific files instead: git add <file1> <file2>"
fi

# git reset (unstaging / hard reset)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+reset'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+reset'; then
deny "BLOCKED: 'git reset' can unstage or destroy other sessions' work. To unstage your own files, use: git restore --staged <file>"
fi

# git checkout -- <file> (reverting files)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+checkout\s+--'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+checkout\s+--'; then
deny "BLOCKED: 'git checkout -- <file>' reverts working tree changes and may destroy other sessions' edits. If you need to discard your own changes, be explicit about which files."
fi

# git restore (reverting) — EXCEPT git restore --staged (safe unstaging)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore'; then
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore\s+--staged'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore'; then
if ! echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore\s+--staged'; then
deny "BLOCKED: 'git restore <file>' reverts working tree changes and may destroy other sessions' edits. To unstage files safely, use: git restore --staged <file>"
fi
fi

# git clean (delete untracked files)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+clean'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+clean'; then
deny "BLOCKED: 'git clean' deletes untracked files that may belong to other sessions."
fi

# git stash (hides all changes)
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+stash'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+stash'; then
deny "BLOCKED: 'git stash' hides all working tree changes including other sessions' work. In worktree mode, commit your changes directly instead."
fi

# --- Branch name validation helper ---
# --- Working directory detection ---

validate_branch_name() {
# Try to get branch from the working directory where the command runs
# Extract cd target if command starts with cd "..." && ...
# Resolve the working directory a git command targets:
# - `git -C "<dir>" ...` → the -C target (takes precedence — explicit git-level override)
# - `cd "<dir>" && git ...` → the cd target
# Falls back to empty string (caller uses cwd).
detect_work_dir() {
local work_dir=""
if echo "$COMMAND" | grep -qE '^\s*cd\s+'; then
# `git -C` is the explicit git-level override and wins over any ambient cd prefix,
# so check it first (e.g. `cd /tmp && git -C /worktree push` targets /worktree).
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.

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.

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
# Trim trailing whitespace
work_dir="${work_dir%"${work_dir##*[![:space:]]}"}"
echo "$work_dir"
}

# --- Branch name validation helper ---

validate_branch_name() {
local work_dir
work_dir=$(detect_work_dir)

local BRANCH=""
if [ -n "$work_dir" ] && [ -d "$work_dir" ]; then
Expand All @@ -102,21 +129,29 @@ validate_branch_name() {

# --- Branch name validation on push ---

if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then
validate_branch_name
fi

# --- Branch name validation on gh pr create ---

if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then
validate_branch_name
fi

# --- Commit validation against edit log ---

if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then
# Use git worktree root so each worktree session has its own edit log
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then
# Resolve the target worktree so the edit log and staged-file listing come
# from the same repo the commit targets (e.g. `git -C <pr-worktree> commit`).
WORK_DIR=$(detect_work_dir)
if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then
PROJECT_DIR=$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
STAGED_FILES=$(git -C "$WORK_DIR" diff --cached --name-only 2>/dev/null) || true
else
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
STAGED_FILES=$(git diff --cached --name-only 2>/dev/null) || true
fi
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"

# If no edit log exists, allow (backward compat for sessions without tracking)
Expand All @@ -127,9 +162,6 @@ if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then
# Get unique edited files from log
EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u)

# Get staged files
STAGED_FILES=$(git diff --cached --name-only 2>/dev/null) || true

if [ -z "$STAGED_FILES" ]; then
exit 0
fi
Expand Down
Loading