fix(push-guard): single reader, whole-word dir tokens, payload fallback, bad-value banner

Run D2 of manual-push mode (BDR-114).

- push-guard sources lib/gitflow.sh once (absolute path) and reads each
  candidate dir through gitflow_push_mode; a missing lib denies.
- Dir tokens are extracted as whole shell words: a fully quoted token
  (inner apostrophe allowed) is resolved, a backslash-escaped space is
  unescaped deterministically, a token mixing quoted and unquoted parts
  is refused (fail closed) instead of resolving to its parent.
- A payload jq cannot parse is scanned as raw text with its JSON escapes
  folded; a push-looking one gets the static deny through the trap.
- The 20-token cap runs before any per-token classification (a flood of
  20 000 tokens is refused in 0.13 s; T58 locks it under 5 s).
- `case "$mode"` has a deny default; missing core tools warn and allow.
- T42 compares the deny list against main (the last release) instead of
  HEAD; literal-true, mixed-token, broken-payload, lib-missing and
  banner-on-bad-value cases added (98 checks).
- session-start banner reads the mode through the verb and shows
  `push : manual (autopush bad)` on an unparseable value.
- tour hints quote "<abs project>".
This commit is contained in:
bchanot
2026-10-07 17:11:56 +02:00
parent 472cccbc52
commit 3c59333fcf
4 changed files with 187 additions and 39 deletions
+82 -25
View File
@@ -2,14 +2,22 @@
# push-guard.sh — PreToolUse (Bash|Monitor): refuse `git push` in manual
# push mode (BDR-111). Manual mode = `gitflow.autopush` reads false (or is
# unparseable or unreadable: fail closed) in the payload cwd or in any literal -C / cd dir
# the command names; outside a repo `git config` reads global/system.
# the command names; outside a repo `git config` reads global/system. The
# mode is read by the sourced lib verb gitflow_push_mode (single reader).
#
# Deny form: JSON on stdout, exit 0 (hookSpecificOutput.permissionDecision
# = "deny"). Silent in auto mode and on every non-push command. The guard
# sees the command TEXT only. Once a push is detected an EXIT trap emits a
# static deny (exit 0) unless a decision was recorded: internal error =
# push refused. jq missing: one stderr warning, allow (sibling hooks).
# push refused. jq, cat, grep, sed, sort or head missing: one stderr
# warning, allow (sibling hooks; PATH is not command-controlled).
#
# DENIED beyond a manual-mode push: a cd/-C dir token mixing quoted and
# unquoted parts ("/m"/x"/y", a/'../b'); a cd argument touching a closing
# quote followed by another quote on the line (bash -c 'cd /x' && bash -c
# 'git push' reads as one mixed token, accepted, fail closed); a payload jq
# cannot parse whose raw text (JSON escapes folded) looks like a push: static
# deny, mode-blind, so a description naming a push also denies there.
# OVER-BLOCKS in manual mode: any text carrying a later ` push` word after
# a `git` token (git subtree push, git stash push, git log -S "git push",
# grep -rn "git push" skills/, git config --get push.default, git add
@@ -24,14 +32,29 @@
set -u
unset CDPATH
# Absolute lib path, before anything else; sourced once (functions only).
_src=${BASH_SOURCE[0]}
case "$_src" in */*) _dir=${_src%/*} ;; *) _dir=. ;; esac
LIB="$(cd -P "$_dir/../lib" 2>/dev/null && pwd)/gitflow.sh"
# shellcheck source=/dev/null
if [ -r "$LIB" ]; then . "$LIB"; LIB_OK=1; else LIB_OK=0; fi
if ! command -v jq >/dev/null 2>&1; then
echo "push-guard: jq missing, guard inactive" >&2
exit 0
fi
for t in cat grep sed sort head; do
command -v "$t" >/dev/null 2>&1 || {
echo "push-guard: $t missing, guard inactive" >&2
exit 0
}
done
payload=$(cat 2>/dev/null)
field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; }
cmd=$(field '.tool_input.command')
# jq's rc is field's rc: a payload that does not parse becomes the text to scan.
unparsed=0
cmd=$(field '.tool_input.command') || { cmd=$payload; unparsed=1; }
cwd=$(field '.cwd')
[ -n "$cmd" ] || exit 0
[ -d "$cwd" ] || cwd=$PWD
@@ -39,7 +62,12 @@ cwd=$(field '.cwd')
# Fold line breaks (backslash-newline first), then drop quoted spans.
one=${cmd//$'\\\n'/ }
one=${one//$'\n'/ }
if [ "$unparsed" = 1 ]; then
one=${one//\\n/ }; one=${one//\\r/ }; one=${one//\\t/ }; one=${one//\\\\/ }
fi
bare=$(printf '%s' "$one" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")
# JSON quotes are syntax, not shell quoting: keep them for the loose regexes.
[ "$unparsed" = 1 ] && bare=$one
# is_push: strict (full text), loose (quotes removed), alias definition.
is_push() {
@@ -60,22 +88,35 @@ static_deny() {
}
decided=0
trap '[ "$decided" = 1 ] || static_deny; exit 0' EXIT
# Unparseable payload that looks like a push: the trap answers (mode-blind).
[ "$unparsed" = 1 ] && exit 0
# unquote <tok>: strip one pair of surrounding quotes.
unquote() {
local t=$1
# classify_tok <raw>: prints the literal dir of a raw dir token, rc 1 when it
# mixes quoted and unquoted parts. A token enclosed in one quote pair is
# stripped (the other quote kind inside is fine); backslashes of an unquoted
# token are unescaped (a\ b -> a b), deterministic, never eval'd.
classify_tok() {
local t=$1 q=
case "$t" in
\"*\") t=${t#\"}; t=${t%\"} ;;
\'*\') t=${t#\'}; t=${t%\'} ;;
\"*\") q='"' ;;
\'*\') q="'" ;;
esac
printf '%s' "$t"
if [ -n "$q" ]; then
t=${t#"$q"}; t=${t%"$q"}
case "$t" in *"$q"*) return 1 ;; esac
printf '%s' "$t"
return 0
fi
case "$t" in *\"*|*\'*) return 1 ;; esac
printf '%s' "$t" | sed -E 's/\\(.)/\1/g'
}
# arg_tokens: the directory argument of every `cd`/`pushd`/`-C` in the text.
# arg_tokens: the directory argument of every `cd`/`pushd`/`-C` in the text,
# one shell word each (adjacent quoted and unquoted segments, \x escapes).
# A quote or backtick may precede the command word (bash -c 'cd d && ...').
arg_tokens() {
local pre='[[:space:];&|()"'"'"'`]'
local arg='(--[[:space:]]+)?("[^"]*"|'"'[^']*'"'|[^[:space:];&|()"'"'"'`]+)'
local arg='(--[[:space:]]+)?((\\.|"[^"]*"|'"'[^']*'"'|[^[:space:];&|()"'"'"'`\\]+)+)'
{
printf '%s' "$one" | grep -oE "(^|$pre)(cd|pushd)[[:space:]]+$arg"
printf '%s' "$one" | grep -oE "(^|$pre)-C[[:space:]]+$arg"
@@ -94,30 +135,30 @@ resolve_dir() {
)
}
# candidates: cwd, then each distinct literal dir of $tokens, deduplicated
# candidates: cwd, then each distinct literal dir of $literals, deduplicated
# after resolution (unresolvable ones are skipped, never an allow).
candidates() {
local tok
printf '%s\n' "$cwd"
printf '%s\n' "$tokens" | while IFS= read -r tok; do
tok=$(unquote "$tok")
printf '%s\n' "$literals" | while IFS= read -r tok; do
case "$tok" in ''|-) continue ;; esac
resolve_dir "$tok"
done | sort -u
}
# mode_in <dir>: prints `manual`, `auto` (key unset or true),
# `invalid:<raw>` (not a boolean) or `failed:<what>` (git or cd failed).
# `invalid:<why>` (not a boolean, unreadable) or `failed:<what>`.
mode_in() {
(
cd -- "$1" 2>/dev/null || { echo "failed:cannot enter the directory"; exit 0; }
val=$(git config --bool gitflow.autopush 2>/dev/null); rc=$?
case "$rc" in
0) if [ "$val" = false ]; then echo manual; else echo auto; fi ;;
1) echo auto ;;
*) raw=$(git config gitflow.autopush 2>/dev/null)
if [ -n "$raw" ]; then echo "invalid:$raw"
else echo "failed:git exited $rc"; fi ;;
[ "$LIB_OK" = 1 ] || { echo "failed:gitflow lib missing"; exit 0; }
out=$(gitflow_push_mode 2>&1); m=${out##*$'\n'}
why=$(printf '%s\n' "$out" | grep -m1 '^gitflow.sh push-mode: ' \
| sed 's/^gitflow.sh push-mode: //')
case "$m" in
manual|auto) echo "$m" ;;
invalid) echo "invalid:${why:-unreadable}" ;;
*) echo "$m" ;;
esac
)
}
@@ -131,12 +172,26 @@ deny() {
exit 0
}
# Cap the distinct dir tokens before resolving any (hook timeout is 10 s).
# literal_dirs: fills $literals from $tokens; a mixed token denies, naming
# it. Runs in the main shell (deny must end the hook, not a subshell).
literal_dirs() {
local raw lit
literals=""
while IFS= read -r raw; do
[ -n "$raw" ] || continue
lit=$(classify_tok "$raw") || deny "push-guard: directory token $raw mixes quoted and unquoted parts — this guard refuses to interpolate it (fail closed). Quote the whole path, or run it yourself: ! $cmd"
literals="$literals$lit"$'\n'
done <<<"$tokens"
}
# Cap the distinct dir tokens before resolving or classifying any (hook
# timeout is 10 s).
tokens=$(arg_tokens | sort -u)
ntok=$(printf '%s\n' "$tokens" | grep -c .)
if [ "$ntok" -gt 20 ]; then
deny "push-guard: too many directory tokens in one command ($ntok > 20) — push refused (fail closed). Split the command, or run it yourself: ! $cmd"
fi
literal_dirs
evaluated=0
while IFS= read -r dir; do
@@ -145,10 +200,12 @@ while IFS= read -r dir; do
manual)
deny "push-guard: manual push mode (gitflow.autopush=false in $dir) — Claude never pushes. Run it yourself in the terminal: ! $cmd" ;;
invalid:*)
deny "push-guard: gitflow.autopush='${mode#invalid:}' is not a boolean in $dir — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! $cmd" ;;
deny "push-guard: ${mode#invalid:} in $dir — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! $cmd" ;;
failed:*)
deny "push-guard: could not read gitflow.autopush in $dir (${mode#failed:}) — git failed, push refused (fail closed). Run it yourself in the terminal: ! $cmd" ;;
deny "push-guard: push mode unreadable in $dir (${mode#failed:}) — push refused (fail closed). Run it yourself: ! $cmd" ;;
auto) evaluated=$((evaluated + 1)) ;;
*)
deny "push-guard: unexpected push mode '$mode' in $dir — push refused (fail closed). Run it yourself: ! $cmd" ;;
esac
done < <(candidates)
+6 -4
View File
@@ -53,7 +53,6 @@ _gf_lib="$(dirname "${BASH_SOURCE[0]}")/../lib/gitflow.sh"
if [ -f "$_gf_lib" ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
GF_REFRESHED=$(bash "$_gf_lib" reconcile-hooks 2>/dev/null | sed -n 's/^gitflow hooks refreshed: *//p')
fi
unset _gf_lib
# ── graphify threshold signal (BDR-097) ──
# Informs, never acts: one banner line when the repo holds ≥ 200 tracked code
@@ -232,9 +231,12 @@ if [ -n "$GF_REFRESHED" ]; then
fi
# ── manual-push mode (BDR-111): one lock line when this repo never auto-pushes ──
# %-46s, not 44: bash printf pads by BYTES and "—" is 3 bytes (2 extra).
if [ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ]; then
printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push"
fi
_pm=$( [ -r "$_gf_lib" ] && bash "$_gf_lib" push-mode 2>/dev/null )
case "$_pm" in
manual) printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push" ;;
invalid) printf "│ 🔒 %-46s│\n" "push : manual (autopush bad) — ! git push" ;;
esac
unset _pm _gf_lib
if [ -n "$GRAPHIFY_HINT" ]; then
printf "│ 🕸️ %-44s│\n" "${GRAPHIFY_HINT:0:44}"
printf "│ %-40s│\n" "→ /graphify (AST, seconds) — you decide"