diff --git a/hooks/push-guard.sh b/hooks/push-guard.sh index 12f96ed..0b92954 100644 --- a/hooks/push-guard.sh +++ b/hooks/push-guard.sh @@ -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 : strip one pair of surrounding quotes. -unquote() { - local t=$1 +# classify_tok : 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 : prints `manual`, `auto` (key unset or true), -# `invalid:` (not a boolean) or `failed:` (git or cd failed). +# `invalid:` (not a boolean, unreadable) or `failed:`. 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) diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 5bf9d67..a7c7698 100644 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -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" diff --git a/lib/tests/push-guard.test.sh b/lib/tests/push-guard.test.sh index feef387..11c2054 100644 --- a/lib/tests/push-guard.test.sh +++ b/lib/tests/push-guard.test.sh @@ -150,6 +150,14 @@ cmd48=""; for i in $(seq 1 25); do cmd48="${cmd48}cd /x$i;"; done run "$cmd48 git push" "$WORK/auto" check T48-cap-deny "$(verdict)" deny check T48-cap-reason "$(grep -c 'too many directory tokens' <<<"$(reason)")" 1 +cmd58=""; for i in $(seq 1 2000); do cmd58="${cmd58}cd /x$i;"; done +t58=$SECONDS +run "$cmd58 git push" "$WORK/auto" +t58=$((SECONDS - t58)) +echo "T58 elapsed: ${t58}s" +check T58-flood-deny "$(verdict)" deny +check T58-flood-reason "$(grep -c 'too many directory tokens' <<<"$(reason)")" 1 +check T58-flood-fast "$([ "$t58" -lt 5 ] && echo yes || echo no)" yes cmd48b=""; for i in 1 2 3 4 5; do cmd48b="${cmd48b}cd \"$M\";"; done run "$cmd48b git push" "$WORK/plain" check T48b-dedup-detect "$(verdict)" deny @@ -181,6 +189,69 @@ chmod 755 "$WORK/locked" check T50-bash-c-cd "$(fire "bash -c 'cd \"$M\" && git push'" "$WORK/plain")" deny check T50b-unquoted-arg "$(fire "bash -c 'cd $M && git push'" "$WORK/plain")" deny +# T51: a literal `true` is auto mode. +mkrepo "$WORK/truerepo"; git -C "$WORK/truerepo" config gitflow.autopush true +check T51-literal-true "$(fire 'git push' "$WORK/truerepo")" allow + +# T52: tokens that mix quoted and unquoted parts are refused, named. +run "cd $WORK/auto'/../manual' && git push" "$WORK/plain" +check T52-mixed-deny "$(verdict)" deny +check T52-mixed-reason "$(grep -c 'mixes quoted and unquoted' <<<"$(reason)")" 1 +run "cd \"$WORK/manual/my dir\" && git push" "$WORK/plain" +check T52b-quoted-deny "$(verdict)" deny +check T52b-quoted-reason "$(grep -c 'manual push mode' <<<"$(reason)")" 1 +mkdir -p "$WORK/auto/bob's" +check T52c-apostrophe-nopush "$(fire "cd \"$WORK/auto/bob's\" && git status" "$WORK/plain")" allow +check T52c-apostrophe-auto "$(fire "cd \"$WORK/auto/bob's\" && git push" "$WORK/plain")" allow + +# T53: a backslash-escaped space is one word, unescaped and resolved. +run "cd $WORK/manual/my\\ dir && git push" "$WORK/plain" +check T53-escaped-space "$(verdict)" deny +check T53-escaped-reason "$(grep -c 'manual push mode' <<<"$(reason)")" 1 +run "cd \"$WORK/manual\"/sub\"\" && git push" "$WORK/plain" +check T53b-enclosed-mixed "$(verdict)" deny +check T53b-mixed-reason "$(grep -c 'mixes quoted and unquoted' <<<"$(reason)")" 1 + +# T54: a payload jq cannot parse (lone surrogate escape in cwd). +bad54() { # bad54 : broken payload on stdout + printf '{"tool_input":{"command":"%s"},"cwd":"\\ud800"}' "$1" +} +bad54 'git status' > "$WORK/bad.json" +if jq -e . <"$WORK/bad.json" >/dev/null 2>&1; then + check T54-precondition-unparseable parsed unparsed +fi +out54=$(cd "$WORK/auto" && bad54 'git push' | bash "$H" 2>/dev/null); rc54=$? +check T54-deny "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out54")" deny +check T54-internal "$(grep -c 'internal error' <<<"$out54")" 1 +check T54-rc "$rc54" 0 +out54=$(cd "$WORK/auto" && bad54 'git status' | bash "$H" 2>/dev/null) +check T54b-no-push-allow "$out54" "" +out54=$(cd "$WORK/auto" && bad54 'git add -A\ngit push' | bash "$H" 2>/dev/null) +check T54c-escaped-newline "$(grep -c 'permissionDecision":"deny"' <<<"$out54")" 1 +out54=$(cd "$WORK/auto" \ + && bad54 'git subtree push --prefix=x origin main' | bash "$H" 2>/dev/null) +check T54d-loose "$(grep -c 'permissionDecision":"deny"' <<<"$out54")" 1 + +# T55: a missing core tool (grep) warns on stderr and stays inactive. +mkdir -p "$WORK/nogrep" +for tool in bash cat jq git sed sort head; do + real=$(command -v "$tool") || continue + case "$real" in /*) ln -sf "$real" "$WORK/nogrep/$tool" ;; esac +done +out55=$(cd "$M" && printf '%s' "$payload47" \ + | PATH="$WORK/nogrep" "$(command -v bash)" "$H" 2>"$WORK/nogrep.err"); rc55=$? +check T55a-rc "$rc55" 0 +check T55b-stdout-empty "$out55" "" +check T55c-warn "$(grep -c 'grep missing' "$WORK/nogrep.err")" 1 + +# T56: lib missing (hook copied away from its lib/) denies, own reason. +mkdir -p "$WORK/alone/hooks"; cp "$ROOT/hooks/push-guard.sh" "$WORK/alone/hooks/" +saved_h=$H; H="$WORK/alone/hooks/push-guard.sh" +run 'git push' "$M" +H=$saved_h +check T56-lib-missing "$(verdict)" deny +check T56-reason "$(grep -c 'gitflow lib missing' <<<"$(reason)")" 1 + # ── settings.json wiring (file content only) ── S="$ROOT/settings.json" check T40-wiring "$(jq -e '.hooks.PreToolUse[] @@ -213,12 +284,28 @@ Write(~/.config/git/config) EOF check T41-new-deny-present "$missing" "" -# Nothing removed: every deny entry of the pre-run-B settings is still there. -base=HEAD -lost=$(git -C "$ROOT" show "$base:settings.json" 2>/dev/null \ - | jq -r --slurpfile now "$S" \ - '.permissions.deny[] | select(. as $e | ($now[0].permissions.deny | index($e)) == null)') -check T42-nothing-removed "$lost" "" +# Nothing removed: every deny entry of the fresher of origin/main and main +# (the last release) is still there. Neither ref resolves: SKIP, no count. +rv() { git -C "$ROOT" rev-parse -q --verify "$1" >/dev/null 2>&1; } +base="" +if rv origin/main && rv main; then + if git -C "$ROOT" merge-base --is-ancestor main origin/main; then + base=origin/main; else base=main; fi +elif rv origin/main; then base=origin/main +elif rv main; then base=main +fi +if [ -z "$base" ]; then + echo "SKIP T42 (no main ref)" +else + echo "T42 base: $base" + basedeny=$(git -C "$ROOT" show "$base:settings.json" 2>/dev/null \ + | jq -r '.permissions.deny[]' 2>/dev/null) + check T42-base-nonempty "$([ -n "$basedeny" ] && echo yes || echo no)" yes + lost=$(git -C "$ROOT" show "$base:settings.json" 2>/dev/null \ + | jq -r --slurpfile now "$S" \ + '.permissions.deny[] | select(. as $e | ($now[0].permissions.deny | index($e)) == null)') + check T42-nothing-removed "$lost" "" +fi soft=$(jq -r '.autoMode.soft_deny[]' "$S") check T43a-soft-rule "$(grep -c 'manual-push mode' <<<"$soft" | tr -d ' ')" 1 @@ -235,5 +322,7 @@ check T45-banner-manual "$(grep -c 'push : manual (autopush=false)' <<<"$out")" out=$(banner "$WORK/auto") check T46a-auto-control "$(grep -c 'Claude Code config' <<<"$out")" 1 check T46b-auto-silent "$(grep -c 'push : manual' <<<"$out")" 0 +out=$(banner "$WORK/bad") +check T57-banner-invalid "$(grep -c 'push : manual (autopush bad)' <<<"$out")" 1 printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/skills/tour/SKILL.md b/skills/tour/SKILL.md index 9f88245..a486a4d 100644 --- a/skills/tour/SKILL.md +++ b/skills/tour/SKILL.md @@ -240,12 +240,12 @@ order: 5. Push state, only when a branch exists (report-only, skipped or dirty-tree projects have none: their row keeps `no branch`, no push column). Two read-only calls, probe first: - `git -C remote get-url origin >/dev/null 2>&1 || echo no-origin` + `git -C "" remote get-url origin >/dev/null 2>&1 || echo no-origin` then - `git -C rev-list --count --not --remotes=origin 2>/dev/null || echo unknown` + `git -C "" rev-list --count --not --remotes=origin 2>/dev/null || echo unknown` (`` = the name `gitflow start` returned, suffixed `-2`/`-3` on a same-day re-run — never the bare `chore/tour-`). 0 → `on origin`; - else `local only → ! git -C push -u origin ` (probe + else `local only → ! git -C "" push -u origin ` (probe printed `no-origin` → `local only (no origin remote)`). ```markdown @@ -282,7 +282,7 @@ without that approval — neither this repo's nor any target project's. never push `main`/`develop`** — "the tour is green" is not a signal. The gitflow hooks push the chore branch in auto-push mode only; when it is not on origin (manual push mode, or a `push FAILED` warning) the USER - pushes it — `! git -C push -u origin ` — the tour + pushes it — `! git -C "" push -u origin ` — the tour never pushes or retries. - Scoped pathspecs only; `git add -A` is forbidden. - Target TODO.md and target `.claude/memory/` are READ-ONLY. Reconcile