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:
+59
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user