Files
claude_mac/hooks/unpushed-guard.sh
T
bastien 9da5d8d52c feat(guardrails): push every commit, static deny for destructive tools, brief carries no user authority
Layer C of the plan written after the 2026-09-21 wipe (BDR-095): a reviewer
sub-agent traced `lftp mirror --delete` against a local file:// tree, the
prose tiers named neither lftp nor a local trace, the brief had authorized
it, and four days of commits had never left the machine.

- gitflow: `start` pushes the branch with its upstream, merge targets are
  pushed after each merge, and `init`/`install-hook` write post-commit and
  post-merge hooks that push every commit as it lands (warn, never block;
  GITFLOW_NO_PUSH=1 for throwaway repos). T18 + T19 (installed == emitted).
- hooks/unpushed-guard.sh on SessionStart and Stop: branch ahead of its
  upstream, no upstream, or no origin. Non-blocking systemMessage.
- settings.json: static deny for transfer and mirror tools, rsync --delete,
  xargs rm, pipe-to-shell, chmod/chown -R, sudo/doas/pkexec, disk tools,
  chattr, docker volume drops/prune/--privileged/socket/-v /:, git history
  destruction, --no-verify and core.hooksPath; new hard_deny "destructive
  tool against a local path, brief carries no user authority"; soft_deny
  reworded + discarding uncommitted work; environment records the incident.
- CLAUDE.global.md "Destructive tools & data loss"; the four report-only
  agents trace by reading, never by running, whatever the brief says.
- lib/tests/guard-bash.test.sh: executable spec of the PreToolUse guard
  (214 cases). The hook itself is not shipped (BLK-022); the spec skips.
2026-09-22 07:43:12 +02:00

53 lines
2.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# hooks/unpushed-guard.sh — SessionStart + Stop: surface work that exists on
# this disk only (BDR-095). The 21/09 wipe cost four days of commits that had
# never left the machine; the post-commit hook now pushes every commit, so a
# branch ahead of its upstream is a real signal (push refused, offline, hook
# not installed), not noise.
#
# Non-blocking by contract: a systemMessage for the user, never a decision.
# SessionStart also reports uncommitted changes (a dead session leaves some
# behind); Stop reports unpushed commits only, since a dirty tree mid-work is
# the normal state at a turn end.
set -u
payload=$(cat 2>/dev/null)
field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; }
event=$(field '.hook_event_name')
cwd=$(field '.cwd'); [ -n "$cwd" ] || cwd=$PWD
cd "$cwd" 2>/dev/null || exit 0
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0
# Commits that no remote holds, as one clause; empty when everything is pushed.
unpushed_clause() {
local up n
if ! git remote get-url origin >/dev/null 2>&1; then
echo "no 'origin' remote, every commit lives on this disk only"
return
fi
if up=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
n=$(git rev-list --count "$up..HEAD" 2>/dev/null || echo 0)
[ "$n" -gt 0 ] && echo "$n commit(s) on '$br' not on $up, push: git push"
else
# commits no remote-tracking ref holds: the ones only this disk has
n=$(git rev-list --count HEAD --not --remotes 2>/dev/null || echo 0)
echo "'$br' has no upstream ($n commit(s) on this disk only), push: git push -u origin $br"
fi
}
msg=$(unpushed_clause)
if [ "$event" = "SessionStart" ]; then
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
[ "$dirty" -gt 0 ] && msg="${msg:+$msg; }$dirty uncommitted change(s) in $cwd"
fi
[ -n "$msg" ] || exit 0
msg="⚠ unpushed work: $msg"
if [ "$event" = "SessionStart" ]; then
jq -cn --arg m "$msg" \
'{systemMessage: $m, hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $m}}'
else
jq -cn --arg m "$msg" '{systemMessage: $m}'
fi