9.4 KiB
PLAN — manual-push-guard-residuals-d2 — REVISED r2 (3 lenses + correctness confirmation)
Contract: .claude/tasks/contracts/2026-10-07-manual-push-guard-residuals-d2-1526.md
Context
push-guard (run B, hardened) is live on every Bash|Monitor call (~/.claude/hooks is a symlink into this tree: every edit above is_push || exit 0 runs machine-wide at once → bash -n after each edit). Residuals: arg_tokens' unquoted alternative stops at the first quote, so cd /m/'a b' yields /m/ and the wrong dir is checked (fail-open toward the parent); an unparseable payload allows silently; the mode case has no default; missing core tools allow silently; T42 is vacuous once committed; no literal-true test; the banner shows nothing on an invalid value. After D1 the lib verb is the single reader: push-guard sources the lib once and calls gitflow_push_mode per candidate; the banner calls the verb.
Checklist (bash -n hooks/push-guard.sh after every edit)
- hooks/push-guard.sh
a. Top:
LIB="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)/gitflow.sh"(absolute, before anything else). Tools: after the jq check,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(jq policy; documented). b. Payload fallback (no regex hoist, no earlystatic_denycall):unparsed=0; cmd=$(field '.tool_input.command') || { cmd=$payload; unparsed=1; }(jq's rc is the rc offield; a parse failure → the raw payload becomes the text to scan). In the fold step, whenunparsed=1, also replace the two-character JSON escapes\n,\r,\t,\\by spaces:one=${one//\\n/ }; one=${one//\\r/ }; one=${one//\\t/ }; one=${one//\\\\/ }.is_pushruns unchanged on it. Whenunparsed=1, also setbare=$one(JSON quotes are syntax, not shell quoting: deleting every"…"span would blind the loose and alias regexes). Right after the EXIT trap is installed:[ "$unparsed" = 1 ] && exit 0→ the trap emits the static deny (mode-blind, fail closed). Accepted limit (header): on a broken payload the whole JSON text is scanned, so adescriptionmentioning a push also denies. c. Source the lib once: after LIB:# shellcheck source=/dev/nullthenif [ -r "$LIB" ]; then . "$LIB"; LIB_OK=1; else LIB_OK=0; fi(thesource=/dev/nulldirective is the only clean way to source a path variable; it is not adisable) (sourcing defines functions only; the CLI dispatcher runs only when executed).mode_in <dir>:( cd -- "$1" 2>/dev/null || { echo "failed:cannot enter the directory"; exit 0; }; [ "$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 ). No temp file. Reason forinvalid:*:push-guard: ${mode#invalid:} in $dir — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! $cmd(the verb's line already says "gitflow.autopush='x' is not a boolean (git rc N)" or "could not read …"). Reason forfailed:*:push-guard: push mode unreadable in $dir (${mode#failed:}) — push refused (fail closed). Run it yourself: ! $cmd. d.case "$mode"gains*) deny "push-guard: unexpected push mode '$mode' in $dir — push refused (fail closed). Run it yourself: ! $cmd" ;;. e. Tokens:arg_tokens' argument alternative becomes a REPETITION of segments so adjacent quoted and unquoted parts form one shell word:arg='(--[[:space:]]+)?((\\.|"[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|()"'"'"'\]+)+)'— an escape alternative\.and the backslash removed from the unquoted class, somy\ diris one word (verified on BSD grep:/W/manual/my\ dir,a\ b\ c,/a'/../b',"Bob's"). Keep the--optional prefix and the samepreclass. Then aclassify_tokstep in the MAIN shell, before the 20-token cap: for each raw token — if it starts and ends with the same quote → strip that pair, then if the inner text still contains THAT same quote character → deny (mixed:"/m"/x"/y",'/u/Bob'''s repo'); inner quotes of the OTHER kind are fine ("Bob's repo"); a token not enclosed that contains any quote → deny:push-guard: directory token $tok mixes quoted and unquoted parts — this guard refuses to interpolate it (fail closed). Quote the whole path, or run it yourself: ! $cmd; else unescape backslashes in the unquoted token with ONE mechanism:tok=$(printf '%s' "$tok" | sed -E 's/\(.)/\1/g')(BSD sed verified:a\b→a\b,my\ dir→my dir; deterministic, no eval). Resolution and dedup unchanged after that. Drop the oldunquotehelper ifclassify_tokreplaces it. f. Header: DENIED now lists mixed-quote tokens and the case where acdargument touches a closing quote followed by another quote on the line (bash -c 'cd /x' && bash -c 'git push'→ one mixed token → denied; accepted, fail closed); MISSES drops inner-quote tokens, keeps~/$VAR/$(…)`; add "payload jq cannot parse → raw text scanned (JSON escapes folded), static deny on a push match; grep/sed/sort/head/cat or jq missing → stderr warning, allow"; LIMITS unchanged (20 tokens). - lib/tests/push-guard.test.sh
T51
truerepofixture (keytrue)git push→ allow. T52 (cwd plain)cd $WORK/auto'/../manual' && git push(test writes the expanded$WORK) → deny, reason containsmixes quoted and unquoted. T52b (cwd plain)cd "$WORK/manual/my dir" && git push→ deny, reasonmanual push mode(fully quoted, resolved). T52c (cwd auto) fixture dir$WORK/auto/bob's→cd "$WORK/auto/bob's" && git status→ allow (no push) andcd "$WORK/auto/bob's" && git push→ allow (auto; inner apostrophe inside a fully-quoted token is fine). T53 (cwd plain)cd $WORK/manual/my\ dir && git push→ deny, reasonmanual push mode(backslash unescaped, resolved). T53b (cwd plain)cd "$WORK/manual"/sub"" && git push→ deny, reasonmixes quoted and unquoted. T54 unparseable payload: fixture written withprintf '%s'holding the 6-char escape\ud800incwd; precondition! jq -e . <"$WORK/bad.json"(FAIL the test if jq parses it); run from$WORK/autowith commandgit push→ stdout contains"permissionDecision":"deny", reason containsinternal error, rc 0; T54b same broken payload withgit status→ empty stdout; T54c broken payload whose command isgit add -A\ngit push(two-char escape) → deny. T54d broken payload whose command isgit subtree push --prefix=x origin main→ deny (loose regex must still see it:bare=$onewhen unparsed). T55 missing core tool: shim with bash, cat, jq, git, sed, sort, head but NO grep → rc 0, empty stdout, stderrgrep missing. T56 lib missing:mkdir -p "$WORK/alone/hooks" && cp "$ROOT/hooks/push-guard.sh" "$WORK/alone/hooks/"(copy; no$WORK/alone/lib), run with a temporaryH="$WORK/alone/hooks/push-guard.sh"then restoreH, cwd manual → deny, reasongitflow lib missing. T57 banner invalid: reuse the existingbannerhelper →banner "$WORK/bad"containspush : manual (autopush bad). T42 → base: whichever oforigin/main/mainresolves (git -C "$ROOT" rev-parse -q --verify); both → the fresher by ancestry (merge-base --is-ancestor main origin/main→ origin/main, else main); neither → printSKIP T42 (no main ref)and count nothing; assert the base deny count > 0 (FAIL otherwise); printT42 base: <ref>. - hooks/session-start.sh — replace the
--default truetest with:_pm=$( [ -r "$_gf_lib" ] && bash "$_gf_lib" push-mode 2>/dev/null )(reuse_gf_lib, computed at line ~52 — move itsunsetafter this block);case "$_pm" in manual) printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push" ;; invalid) printf "│ 🔒 %-46s│\n" "push : manual (autopush bad) — ! git push" ;; esac(41 chars + 2 bytes for—→ fits the box);unset _pm. Keep the byte-padding comment. - skills/tour/SKILL.md —
git -C <abs project>→git -C "<abs project>"at every placeholder site (~243, 245, 248, 285); the~/proj/siteexample row stays unquoted (a quoted~would not expand).
Edge cases
- Sourcing
lib/gitflow.shinside the hook: functions only;set -uo pipefailis NOT set by sourcing (the lib sets it only in its CLI branch) — verify by reading the lib's last block; the hook keeps its ownset -u. gitflow_push_modeinside$(…)in a subshell: one git call per candidate, no bash spawn (BASH_ENV irrelevant).- Broken payload: the static deny is mode-blind by design (the mode cannot be read without a command); documented.
- Mixed-quote tokens are denied in auto mode too (user-gated fail-closed for pathological commands, run B); fully-quoted tokens with inner apostrophes and backslash-escaped spaces are resolved, not denied.
- D1 owns hooks/unpushed-guard.sh; D2 is verified after D1's commit, so AC3's
--default truegrep over hooks/ is run then (contract says so).
Disposition
- honors BDR-112 (fail-closed guard; user-gated pathological cases), BDR-113/BDR-114 (single reader = the verb, sourced), LRN-196 (trap exit 0 unchanged; caps; read rc), LRN-198 (quoted paths; no eval, deterministic unescape), LRN-104 (every new string locked), LRN-191, LRN-194, LRN-193 (fresh confirmation after this revision), BDR-100 (
--default true gitflow.autopushgrep across hooks/ ends at zero after D1+D2).