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.
This commit is contained in:
bastien
2026-09-22 07:43:12 +02:00
parent 475200bc83
commit 9da5d8d52c
16 changed files with 699 additions and 18 deletions
+59 -4
View File
@@ -67,6 +67,31 @@ gitflow_release_open() {
# ── start ────────────────────────────────────────────────────────────────────
# gitflow_start <type> <name> → checkout -b <type>/<name> from the correct base.
# _gitflow_push_branch <br> → push + set upstream on origin (BDR-095: a remote
# only backs up what it holds, so a branch is pushed the moment it exists).
# Best effort BY CONTRACT: no origin, offline, or refused → loud warning, rc 0.
# A failed push must never block the work, only make the gap visible.
# GITFLOW_NO_PUSH=1 opts out (throwaway test repos).
_gitflow_push_branch() {
local br="$1"
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
git remote get-url origin >/dev/null 2>&1 || return 0
if _gitflow_timeout git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then
return 0
fi
echo "gitflow: push of '$br' FAILED — it exists only on this disk. Push by hand: git push -u origin $br" >&2
return 0
}
# Wrap a network call in a timeout when coreutils' timeout exists (macOS lacks it).
_gitflow_timeout() {
if command -v timeout >/dev/null 2>&1; then
timeout "${GITFLOW_PUSH_TIMEOUT:-30}" "$@"
else
"$@"
fi
}
gitflow_start() {
local type="${1:-}" name="${2:-}" base
base="$(gitflow_base_for "$type")" || return 2
@@ -76,6 +101,7 @@ gitflow_start() {
git checkout -q "$base" || return 1
git pull --ff-only -q 2>/dev/null || true # best-effort sync; offline / no-upstream ok
git checkout -q -b "$type/$name" || return 1
_gitflow_push_branch "$type/$name"
echo "$type/$name"
}
@@ -87,6 +113,7 @@ _gitflow_merge_into() { # _gitflow_merge_into <target> <source>
git pull --ff-only -q 2>/dev/null || true
git merge --no-ff -q -m "Merge $source into $target" "$source" \
|| { echo "gitflow: conflict merging $source → $target — resolve, commit, re-run finish" >&2; return 4; }
_gitflow_push_branch "$target" # git merge fires post-merge, not post-commit; push here too
}
_gitflow_merge_into_open_releases() { # <source>
@@ -296,12 +323,36 @@ exit 1
HOOK
}
# write the versioned hook file — does NOT activate (see gitflow_activate_hook).
# Emit the self-contained push hook, $1 = post-commit | post-merge: push every
# commit as it lands (BDR-095). `git commit` fires post-commit, `git merge` and
# `git pull` fire post-merge, so both carry the same body. Same contract as
# _gitflow_push_branch, inlined because the hook runs in arbitrary project
# repos with no access to this lib.
_gitflow_emit_push_hook() {
printf '#!/bin/sh\n# gitflow %s — generated by gitflow_init. Do not hand-edit.\n' "$1"
cat <<'HOOK'
# Pushes every commit as it lands (BDR-095): a remote only backs up what it
# holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0
git remote get-url origin >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track
if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi
if $t git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then exit 0; fi
echo "gitflow post-commit: push of '$br' FAILED — this commit exists only on this disk." >&2
echo " Push by hand: git push -u origin $br (rejected as non-fast-forward? never force-push; ask first)" >&2
exit 0
HOOK
}
# write the versioned hook files — does NOT activate (see gitflow_activate_hook).
_gitflow_write_hook() {
local hd=".githooks"
mkdir -p "$hd"
_gitflow_emit_pre_commit > "$hd/pre-commit"
chmod +x "$hd/pre-commit"
_gitflow_emit_push_hook post-commit > "$hd/post-commit"
_gitflow_emit_push_hook post-merge > "$hd/post-merge"
chmod +x "$hd/pre-commit" "$hd/post-commit" "$hd/post-merge"
}
# point git at the versioned hook dir. Run LAST in init so the bootstrap commits
@@ -330,7 +381,11 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
reconcile) gitflow_reconcile_gitignore "$@" ;;
purge-transient) _gitflow_purge_transient ;;
install-hook) gitflow_install_hook "$@" ;;
emit-hook) _gitflow_emit_pre_commit ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|init|reconcile|purge-transient|install-hook|emit-hook}" >&2; exit 2 ;;
emit-hook) case "${1:-pre-commit}" in
pre-commit) _gitflow_emit_pre_commit ;;
post-commit|post-merge) _gitflow_emit_push_hook "$1" ;;
*) echo "gitflow.sh emit-hook {pre-commit|post-commit|post-merge}" >&2; exit 2 ;;
esac ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|init|reconcile|purge-transient|install-hook|emit-hook [pre-commit|post-commit|post-merge]}" >&2; exit 2 ;;
esac
fi