Files
claude_mac/.claude/tasks/plans/2026-10-07-manual-push-guard-residuals-d2-1526.md
T

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 early static_deny call): unparsed=0; cmd=$(field '.tool_input.command') || { cmd=$payload; unparsed=1; } (jq's rc is the rc of field; a parse failure → the raw payload becomes the text to scan). In the fold step, when unparsed=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_push runs unchanged on it. When unparsed=1, also set bare=$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 a description mentioning a push also denies. c. Source the lib once: after LIB: # shellcheck source=/dev/null then if [ -r "$LIB" ]; then . "$LIB"; LIB_OK=1; else LIB_OK=0; fi (the source=/dev/null directive is the only clean way to source a path variable; it is not a disable) (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 for invalid:*: 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 for failed:*: 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 old unquotehelper ifclassify_tokreplaces it. f. Header: DENIED now lists mixed-quote tokens and the case where acd argument 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 truerepo fixture (key true) git push → allow. T52 (cwd plain) cd $WORK/auto'/../manual' && git push (test writes the expanded $WORK) → deny, reason contains mixes quoted and unquoted. T52b (cwd plain) cd "$WORK/manual/my dir" && git push → deny, reason manual push mode (fully quoted, resolved). T52c (cwd auto) fixture dir $WORK/auto/bob's → cd "$WORK/auto/bob's" && git status → allow (no push) and cd "$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, reason manual push mode (backslash unescaped, resolved). T53b (cwd plain) cd "$WORK/manual"/sub"" && git push → deny, reason mixes quoted and unquoted. T54 unparseable payload: fixture written with printf '%s' holding the 6-char escape \ud800 in cwd; precondition ! jq -e . <"$WORK/bad.json" (FAIL the test if jq parses it); run from $WORK/auto with command git push → stdout contains "permissionDecision":"deny", reason contains internal error, rc 0; T54b same broken payload with git status → empty stdout; T54c broken payload whose command is git add -A\ngit push (two-char escape) → deny. T54d broken payload whose command is git subtree push --prefix=x origin main → deny (loose regex must still see it: bare=$one when unparsed). T55 missing core tool: shim with bash, cat, jq, git, sed, sort, head but NO grep → rc 0, empty stdout, stderr grep 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 temporary H="$WORK/alone/hooks/push-guard.sh" then restore H, cwd manual → deny, reason gitflow lib missing. T57 banner invalid: reuse the existing banner helper → banner "$WORK/bad" contains push : manual (autopush bad). T42 → base: whichever of origin/main / main resolves (git -C "$ROOT" rev-parse -q --verify); both → the fresher by ancestry (merge-base --is-ancestor main origin/main → origin/main, else main); neither → print SKIP T42 (no main ref) and count nothing; assert the base deny count > 0 (FAIL otherwise); print T42 base: <ref>.
  • hooks/session-start.sh — replace the --default true test with: _pm=$( [ -r "$_gf_lib" ] && bash "$_gf_lib" push-mode 2>/dev/null ) (reuse _gf_lib, computed at line ~52 — move its unset after 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/site example row stays unquoted (a quoted ~ would not expand).

Edge cases

  • Sourcing lib/gitflow.sh inside the hook: functions only; set -uo pipefail is 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 own set -u.
  • gitflow_push_mode inside $(…) 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 true grep 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.autopush grep across hooks/ ends at zero after D1+D2).