worktree-guard.sh 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. #!/bin/bash
  2. # hooks/worktree-guard.sh
  3. # PreToolUse hook (matcher: Bash) — enforce rules/worktree-boundaries.md.
  4. #
  5. # `.claude/worktrees/` is the private state of whichever agent, session, or
  6. # human spawned it. Worktrees that look orphaned often aren't (active sessions,
  7. # uncommitted work). This hook catches the command shapes that have actually
  8. # caused damage (2026-04-19 incident: `git add -A` staged worktree gitlinks,
  9. # then `rm -rf .claude/worktrees/` deleted live agent state):
  10. #
  11. # 1. rm targeting a path containing .claude/worktrees
  12. # 2. git worktree remove <path containing .claude/worktrees>
  13. # 3. git worktree prune — when the cwd's repo has a .claude/worktrees dir
  14. # 4. git rm targeting .claude/worktrees paths
  15. # 5. git add -A / --all / . — when cwd has a .claude/worktrees dir
  16. #
  17. # On (3) and (5) we use directory existence ([ -d cwd/.claude/worktrees ]) as
  18. # the cheap proxy. True gitlink detection would need `git status --porcelain`
  19. # parsing — a subprocess against a possibly-large repo on EVERY Bash call, and
  20. # gitlinks only show once recorded — too slow/unreliable for a hook, so we
  21. # accept slight over-warning (advisory anyway).
  22. #
  23. # Own-worktree exemption: a session whose payload cwd is INSIDE
  24. # .claude/worktrees/<name> is operating in its own worktree and may touch
  25. # itself — it is exempted entirely (tradeoff: such a session is also not
  26. # guarded against touching sibling worktrees; acceptable, the rule targets
  27. # outside sessions doing "cleanup").
  28. #
  29. # Stdin: PreToolUse JSON ({tool_input:{command}, cwd, …}); $1 = command fallback.
  30. #
  31. # Behaviour (silent on clean):
  32. # no violation → no output, exit 0
  33. # violation → ADVISORY warning naming the rule, exit 0
  34. # + WORKTREE_GUARD_BLOCK=1 → HARD DENY: stderr + exit 2 (tool call prevented)
  35. set -uo pipefail
  36. CMD="${1:-}"; CWD=""
  37. if [[ -z "$CMD" && ! -t 0 ]]; then
  38. RAW="$(cat 2>/dev/null)"
  39. if [[ -n "${RAW:-}" ]] && command -v jq >/dev/null 2>&1; then
  40. CMD="$(printf '%s' "$RAW" | jq -r '.tool_input.command // empty' 2>/dev/null)"
  41. CWD="$(printf '%s' "$RAW" | jq -r '.cwd // empty' 2>/dev/null)"
  42. fi
  43. fi
  44. [[ -z "$CMD" ]] && exit 0
  45. [[ -z "$CWD" ]] && CWD="${CLAUDE_PROJECT_DIR:-$PWD}"
  46. # Own-worktree session → exempt (see header).
  47. case "$CWD" in
  48. *.claude/worktrees/*|*.claude\\worktrees\\*) exit 0 ;;
  49. esac
  50. WT='\.claude[/\\]worktrees' # matches forward or back slashes
  51. VIOLATION=""
  52. if printf '%s' "$CMD" | grep -qE "\bgit\b.*\bworktree[[:space:]]+remove\b[^;|&]*$WT"; then
  53. VIOLATION="git worktree remove on .claude/worktrees"
  54. elif printf '%s' "$CMD" | grep -qE "\bgit\b.*\bworktree[[:space:]]+prune\b" \
  55. && [[ -d "$CWD/.claude/worktrees" ]]; then
  56. VIOLATION="git worktree prune in a repo with .claude/worktrees"
  57. elif printf '%s' "$CMD" | grep -qE "\bgit\b[^;|&]*\brm\b[^;|&]*$WT"; then
  58. VIOLATION="git rm on .claude/worktrees paths"
  59. elif printf '%s' "$CMD" | grep -qE "\brm\b[^;|&]*$WT"; then
  60. VIOLATION="rm targeting .claude/worktrees"
  61. elif printf '%s' "$CMD" | grep -qE '\bgit\b.*\badd[[:space:]]+([^;|&]*[[:space:]])?(-A|--all|\.)([[:space:]]|$|;)' \
  62. && [[ -d "$CWD/.claude/worktrees" ]]; then
  63. VIOLATION="git add -A/. in a repo with .claude/worktrees (may stage worktree gitlinks)"
  64. fi
  65. [[ -z "$VIOLATION" ]] && exit 0 # clean → silent
  66. if [[ "${WORKTREE_GUARD_BLOCK:-0}" == "1" ]]; then
  67. {
  68. echo "WORKTREE GUARD: blocked — $VIOLATION."
  69. echo "rules/worktree-boundaries.md: worktrees are another session's private state."
  70. echo "Use explicit file paths with git add; never delete .claude/worktrees without"
  71. echo "asking the user. Unset WORKTREE_GUARD_BLOCK only after they confirm."
  72. } >&2
  73. exit 2
  74. fi
  75. echo "WORKTREE GUARD: $VIOLATION."
  76. echo "rules/worktree-boundaries.md: .claude/worktrees/ is another session's private"
  77. echo "state — it may look orphaned and isn't. Use explicit paths with git add; ask"
  78. echo "the user before removing any worktree. (WORKTREE_GUARD_BLOCK=1 to hard-deny.)"
  79. exit 0