# 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 `: `( 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, so `my\ dir` is one word (verified on BSD grep: `/W/manual/my\ dir`, `a\ b\ c`, `/a'/../b'`, `"Bob's"`). Keep the `--` optional prefix and the same `pre` class. Then a `classify_tok` step 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 `unquote` helper if `classify_tok` replaces it. f. Header: DENIED now lists mixed-quote tokens and the case where a `cd` 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: `. - [ ] 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 ` → `git -C ""` 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).