feat(hooks): guard-main решает по цели записи, без исключения openspec-only (#6)

B1; RT-1, RT-2, RT-7; решение владельца 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andrey.epifantsev 2026-10-16 01:54:33 +04:00
parent df203521ed
commit 87f2c956aa
2 changed files with 82 additions and 43 deletions

View File

@ -1,44 +1,8 @@
#!/usr/bin/env bash
# PreToolUse hook: block file changes on main/master and on detached HEAD.
# Covers Edit/Write/MultiEdit directly and Bash commands that look like writes.
# Single exception: commits/pushes to main that touch only openspec/ (archive step).
set -euo pipefail
input=$(cat)
eval "$(printf '%s' "$input" | python3 -c '
import sys, json, shlex
d = json.load(sys.stdin); ti = d.get("tool_input", {}) or {}
print("cwd=" + shlex.quote(d.get("cwd", "")))
print("tool=" + shlex.quote(d.get("tool_name", "")))
print("cmd=" + shlex.quote(ti.get("command", "") or ""))' 2>/dev/null || echo 'cwd=; tool=; cmd=')"
[ -n "$cwd" ] || exit 0
git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
branch=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
case "$branch" in main|master|"") ;; *) exit 0 ;; esac
label=${branch:-"detached HEAD"}
only_openspec() { # $1 = git diff range/args; true if every changed path is under openspec/
local files
files=$(git -C "$cwd" diff --name-only $1 2>/dev/null || true)
[ -n "$files" ] && ! printf '%s\n' "$files" | grep -vq '^openspec/'
}
if [ "$tool" = "Bash" ]; then
case "$cmd" in
*"git worktree"*|*"git checkout -b"*|*"git switch -c"*|*"openspec archive"*|*"/.claude/harness/adopt-project.sh"*) exit 0 ;;
esac
# archive exception: commit of staged openspec-only changes, push of openspec-only commits
if printf '%s' "$cmd" | grep -Eq '\bgit\s+commit\b'; then
only_openspec "--cached" && exit 0
fi
if printf '%s' "$cmd" | grep -Eq '\bgit\s+push\b'; then
only_openspec "origin/main...HEAD" && exit 0
fi
if printf '%s' "$cmd" | grep -Eq '(^|[^<])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b'; then
echo "BLOCKED: shell write on $label ($(printf '%s' "$cmd" | head -c 80)). Create an isolated worktree first (superpowers:using-git-worktrees). Only 'openspec archive' commits may land on main directly." >&2
exit 2
fi
exit 0
fi
echo "BLOCKED: you are on $label. Create an isolated worktree first (superpowers:using-git-worktrees), never edit main directly." >&2
exit 2
# PreToolUse hook (Edit/Write/MultiEdit/Bash): запрет записи в репозиторий на main/master/detached HEAD.
# Решение — по цели записи, а не по cwd; логика в harness_target.py рядом. Любой сбой — пропуск (exit 0).
py="${0%/*}/harness_target.py"
command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
python3 "$py" guard-main
[ $? -eq 2 ] && exit 2
exit 0

View File

@ -0,0 +1,75 @@
# guard-main.sh: решение по цели записи (спека hook-guards) в песочнице из настоящих git-репозиториев.
G="$ROOT/hooks/guard-main.sh"
S="$TMP/sb"; mkdir -p "$S/ws" "$HOME/.claude/projects/p/memory"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/m" # репозиторий на main
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x # основной checkout на main + worktree на ветке
mk "$S/ms" master # на master
mk "$S/d"; git -C "$S/d" checkout -q --detach # detached HEAD
j(){ python3 -c 'import json,sys; k="command" if sys.argv[2]=="Bash" else "file_path"; print(json.dumps({"cwd":sys.argv[1],"tool_name":sys.argv[2],"tool_input":{k:sys.argv[3]}}))' "$@"; }
rc(){ j "$@" | bash "$G" 2>/dev/null; echo $?; }
err(){ j "$@" | bash "$G" 2>&1 >/dev/null; }
# решение по цели, а не по cwd
assert_eq "чтение с 2>/dev/null на main" "$(rc "$S/m" Bash 'grep -rn "npm i\|npm install" . 2>/dev/null | head')" 0
assert_eq "fetch 2>&1 | tail на main" "$(rc "$S/m" Bash 'git fetch origin --prune 2>&1 | tail -3')" 0
assert_eq "Write вне репозитория при cwd на main" "$(rc "$S/m" Write "$HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "Edit в worktree при cwd на main" "$(rc "$S/f" Edit "$S/wt/a.md")" 0
assert_eq "Edit в репозитории на main при cwd в worktree" "$(rc "$S/wt" Edit "$S/m/a.md")" 2
assert_eq "Write на master" "$(rc "$S/ws" Write "$S/ms/a.md")" 2
assert_eq "Write при detached HEAD" "$(rc "$S/ws" Write "$S/d/a.md")" 2
# файловые цели Bash
assert_eq "echo > в main" "$(rc "$S/m" Bash 'echo x > notes.md')" 2
assert_eq "make 2>build.log в main" "$(rc "$S/m" Bash 'make 2>build.log')" 2
assert_eq "sed -i по файлу main" "$(rc "$S/ws" Bash "sed -i 's/a/b/' $S/m/docs/HARNESS.md")" 2
assert_eq "sed -i '' по файлу памяти" "$(rc "$S/m" Bash "sed -i '' 's/a/b/' $HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "docker … sh -c > /tmp" "$(rc "$S/m" Bash "docker compose exec -T db sh -c 'psql -c \"select 1\" > /tmp/x'")" 0
assert_eq "pip install на main" "$(rc "$S/m" Bash 'pip install requests')" 0
assert_eq "npm install на main" "$(rc "$S/m" Bash 'npm install')" 2
assert_eq "цель в переменной" "$(rc "$S/m" Bash 'echo x > "$OUT"')" 0
assert_eq "цель через \$HOME в main" "$(HOME="$S" rc "$S/ws" Bash 'echo x > $HOME/m/f')" 2
# каталог внутри команды
assert_eq "cd в worktree и запись" "$(rc "$S/f" Bash "cd $S/wt && echo x > f")" 0
assert_eq "cd в main и touch" "$(rc "$S/wt" Bash "cd $S/m && touch f")" 2
assert_eq "mkdir -p .wt/ в не-git workspace" "$(rc "$S/ws" Bash 'mkdir -p .wt/6-x')" 0
assert_eq "bash -c с записью в main" "$(rc "$S/m" Bash "bash -c 'echo x > f'")" 2
# git
assert_eq "add && commit в main" "$(rc "$S/m" Bash 'git add -A && git commit -m x')" 2
assert_eq "commit -C worktree при cwd на main" "$(rc "$S/f" Bash "git -C $S/wt commit -m x")" 0
assert_eq "worktree add -C из workspace" "$(rc "$S/ws" Bash "git -C $S/m worktree add ../.wt/x -b feat/x")" 0
assert_eq "pull --ff-only на main" "$(rc "$S/m" Bash 'git pull --ff-only')" 0
assert_eq "stash list на main" "$(rc "$S/m" Bash 'git stash list')" 0
assert_eq "-C в переменной — блок" "$(rc "$S/ws" Bash 'git -C "$REPO" commit -m x')" 2
assert_grep "-C в переменной — подсказка git -C" 'git -C <абсолютный путь>' <(err "$S/ws" Bash 'git -C "$REPO" commit -m x')
assert_eq "switch -c && commit на main" "$(rc "$S/m" Bash 'git switch -q -c chore/archive-x && git add openspec && git commit -m a')" 0
assert_eq "switch -c && touch на main" "$(rc "$S/m" Bash 'git switch -q -c feat/16 && touch .bounded')" 0
assert_eq "switch в переменную && commit — блок" "$(rc "$S/m" Bash 'git switch "$B" && git commit -m x')" 2
assert_eq "switch в переменную && touch — пропуск" "$(rc "$S/m" Bash 'git switch "$B" && touch f')" 0
assert_eq "commit вне репозитория" "$(rc "$S/ws" Bash 'git commit -m x')" 0
# push
assert_eq "push с main" "$(rc "$S/m" Bash 'git push')" 2
assert_eq "push --delete фичи с main" "$(rc "$S/m" Bash 'git push origin --delete feat/6-x')" 0
assert_eq "push фичи с main" "$(rc "$S/m" Bash 'git push origin feat/6-x')" 0
assert_eq "push HEAD:main из worktree" "$(rc "$S/wt" Bash 'git push origin HEAD:main')" 2
assert_eq "push из worktree" "$(rc "$S/wt" Bash 'git push -u origin HEAD')" 0
# без исключения для openspec (решение владельца 1)
mkdir -p "$S/m/openspec"; echo a > "$S/m/openspec/x.md"; git -C "$S/m" add openspec
assert_eq "commit только openspec/ в main" "$(rc "$S/m" Bash 'git commit -m archive')" 2
git -C "$S/m" reset -q
assert_eq "openspec archive в main" "$(rc "$S/m" Bash 'openspec archive x')" 2
# граничные случаи (Review Focus)
ln -s "$S/wt/CLAUDE.md" "$S/ws/CLAUDE.md"; ln -s "$S/m/CLAUDE.md" "$S/ws/MAIN.md"
assert_eq "запись через симлинк в worktree" "$(rc "$S/ws" Write "$S/ws/CLAUDE.md")" 0
assert_eq "запись через симлинк в main" "$(rc "$S/ws" Write "$S/ws/MAIN.md")" 2
assert_eq "пустой cwd и относительный путь — пропуск" "$(rc "" Write "a.md")" 0
assert_eq "запись внутри .git/ — не рабочее дерево" "$(rc "$S/m" Bash 'echo x >> .git/info/exclude')" 0
assert_eq "путь с пробелом в main" "$(rc "$S/m" Bash 'touch "a b.md"')" 2
# сбой разбора и сообщение
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$G" 2>/dev/null; echo $?)" 0
assert_eq "незакрытая кавычка — пропуск" "$(rc "$S/m" Bash 'echo "x > f')" 0
mkdir -p "$TMP/nopy"
assert_eq "нет python3 — пропуск" "$(env PATH="$TMP/nopy" /bin/bash "$G" <<< "$(j "$S/m" Write "$S/m/a.md")" 2>/dev/null; echo $?)" 0
m=$(err "$S/ws" Write "$S/m/docs/HARNESS.md")
assert_grep "сообщение: префикс" '^BLOCKED \[guard-main\]:' <(printf '%s' "$m")
assert_grep "сообщение: путь отн. корня" 'docs/HARNESS.md' <(printf '%s' "$m")
assert_grep "сообщение: корень и worktree" "$(basename "$S")/m на main.*worktree" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует guard-main" 'guard-main\.sh' "$ROOT/hooks/settings.hooks.json"