Skip to content
63 changes: 44 additions & 19 deletions .claude/hooks/guard-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ 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.
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.


deny() {
local reason="$1"
node -e "
Expand All @@ -43,46 +48,61 @@ 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:
# - `cd "<dir>" && git ...` → the cd target
# - `git -C "<dir>" ...` → the -C target
# Falls back to empty string (caller uses cwd).
detect_work_dir() {
local work_dir=""
if echo "$COMMAND" | grep -qE '^\s*cd\s+'; then
work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p')
fi
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).

# 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 +122,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 +155,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