14 KiB
PLAN — manual-push-guard (run B) — REVISED r2 (3 lenses + robustness confirmation)
Contract: .claude/tasks/contracts/2026-10-07-manual-push-guard-1003.md
Context
Run A made gitflow.autopush false stop every lib push. Nothing yet stops Claude from typing git push itself: Bash(git push *) is on ask, inert under auto mode (BDR-095, LRN-155). hooks/guard-bash.sh does not exist (BLK-022); this guard is ONE narrow rule. The human-only toggle (git config gitflow.* deny) is prefix-only; run A widened its reach to the lib, so the bypass forms close now. A trailing * in a permission glob also matches end-of-string (evidence: git config --local core.hooksPath with no value is denied by Bash(git config --local core.hooksPath *)), so NO infix rule can spare the bare read git config … gitflow.autopush: Claude loses the read, hooks and lib (not tool calls) keep it, and run C reads the mode through a lib verb (recorded in TODO). jq is a hard dependency (install-plugins.sh); sibling hooks fail open without it. /usr/bin/sed is BSD sed: no N-on-last-line idiom (an unconditional N on the last line quits WITHOUT printing → empty string on single-line input).
Checklist
- hooks/push-guard.sh (new, ≤100 lines, functions ≤25 logic lines,
set -u,unset CDPATH): Header: purpose, BDR-111, deny form (JSONhookSpecificOutput.permissionDecision=deny, exit 0), what it sees (command TEXT only), candidate dirs, fail-closed policy (unparseable value = manual; once a push is detected an EXIT trap emits the static deny with exit 0 unless a decision was recorded), limits: OVER-BLOCKS in manual mode (any command whose text carries a laterpushword after agittoken:git subtree push,git stash push,git log -S "git push",grep -rn "git push" skills/,git config --get push.default,git add push.sh,git help push, a commit message containing "git push") and MISSES ("git" push,git "push",git send-packcaught,git -c alias.p=push pcaught by the alias pattern; expansions~/$VAR/$(…)in-C/cdnever resolved;--git-dir/GIT_DIR; a push inside a script, Makefile target or user alias it runs → soft_deny rule). jq missing → one stderr warning, allow (sibling-hook behaviour). Parse:payload=$(cat 2>/dev/null); jq check;field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; };cmd=$(field '.tool_input.command');cwd=$(field '.cwd');[ -n "$cmd" ] || exit 0;[ -d "$cwd" ] || cwd=$PWD. Normalize IN BASH, no sed:one=${cmd//$'\\\n'/ }; one=${one//$'\n'/ }(backslash-newline, then bare newlines → spaces);bare=$(printf '%s' "$one" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")(quoted spans removed; unbalanced quotes → documented limit).is_push()(any of three,grep -qEon a single-writeprintf '%s'): STRICT onone:(^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$)LOOSE onbare:(^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$)ALIAS onbare:alias\.[^=[:space:]]+=[^[:space:]]*pushNot a push →exit 0silently (nothing armed yet). Arm:STATIC_DENY= compact literal JSON (reason "push-guard: internal error while checking manual push mode — push refused (fail closed). Run it yourself in the terminal with !");decided=0; trap '[ "$decided" = 1 ] || printf "%s" "$STATIC_DENY"; exit 0' EXIT(the trap forces exit 0 so Claude Code parses the JSON).candidates(): start withcwd;grep -oEononefor(^|[[:space:];&|()])(cd|pushd)[[:space:]]+(--[[:space:]]+)?("[^"]*"|'[^']*'|[^[:space:];&|()]+)and(^|[[:space:]])-C[[:space:]]+("[^"]*"|'[^']*'|[^[:space:];&|()]+); take the LAST field of each match, strip one pair of surrounding quotes, skip-and empty; resolve( cd -- "$cwd" && cd -- "$tok" 2>/dev/null && pwd -P ); unresolvable → skipped (never expands~,$, backticks; no eval). Over-inclusion (rg -C 3,tar -C /tmp) only adds dirs. Empty list is impossible (cwd always present).mode_in <dir>→ printsmanual/invalid:<raw>/ nothing:( cd -- "$dir" || exit 0; raw=$(git config gitflow.autopush 2>/dev/null); val=$(git config --bool --default true gitflow.autopush 2>/dev/null); [ "$val" = false ] && echo manual; [ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && echo "invalid:$raw" ). NO work-tree gate: outside a repogit configreads global/system (work-machine--globaldeployment). Decide: loop candidates; firstmanual→ deny reasonpush-guard: manual push mode (gitflow.autopush=false in <dir>) — Claude never pushes. Run it yourself in the terminal: ! <cmd>; firstinvalid:<raw>→ deny reasonpush-guard: gitflow.autopush='<raw>' is not a boolean in <dir> — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! <cmd>; none →decided=1; exit 0. Deny:out=$(jq -cn --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}') || out=$STATIC_DENY; printf '%s' "$out"; decided=1; exit 0.<cmd>= original command (jq --arg escapes it). - lib/tests/push-guard.test.sh (new) — top:
set -u; export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null(hermetic even when run directly; file content, not a command line),ROOT,H="$ROOT/hooks/push-guard.sh",WORK=$(mktemp -d), trap cleanup. Harness like rtk-rewrite.test.sh:run(cmd, cwd)pipesjq -n '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c},cwd:$d}'intobash "$H", records stdout AND rc;fire()→denyiff rc=0 and stdout parses with.hookSpecificOutput.permissionDecision=="deny",allowiff rc=0 and stdout empty, elseerror:<rc>;reason(). Multi-line producers never piped intogrep -q(LRN-191): usegrep -q … <<<"$out". Fixtures:plain/(dir, not a repo),auto/(git init, no key),manual/(key false;sub/,my dir/inside),bad/(keyflase),manual2/(toggle),gconf(file[gitflow]/autopush = false),shim/(dir with ajqscript:[ "$1" = -cn ] && exit 1; exec /usr/bin/jq "$@", resolved viacommand -v jqat test time). Cases (≥40): auto/none: T1 plaingit pushallow; T2 autogit pushallow; T3 autogit push -u origin feature/xallow. manual deny (cwd manual unless stated): T4git push(also asserts stdout is ONE JSON line); T5git push -u origin feature/x; T6 (cwd plain)git -C "$WORK/manual" push; T7cd sub && git push; T8git push --dry-run; T9git -c a=b push origin HEAD; T10(cd sub && git push); T11bash -c 'git push'; T12git push; echo done; T13/usr/bin/git push; T14git --no-pager push; T15 (cwd plain)cd "$WORK/manual/my dir"; git push; T16git push&&echo ok; T17 (cwd plain)cd -- $WORK/manual && git push(literal expanded path, written by the test); T18 two-linegit \+ newline +push; T19git push|tee /dev/null; T20git subtree push --prefix=x origin main(documented over-block); T21 (cwd plain)(cd $WORK/manual&&git push); T22git -c alias.p=push p; T23git send-pack origin; T24grep -rn "git push" skills/(documented over-block, locked); T25git config --get push.default(documented over-block, locked). manual allow: T26git status && git commit -m "fix push guard"; T27bash ~/.claude/lib/gitflow.sh finish; T28git pushd; T29git stash; T30echo pushed; T31rg -C 3 push src/; T32git branch --show-current. invalid: T33 badgit push→ deny, reason containsnot a booleanandflase. global: T34a cwd auto,GIT_CONFIG_GLOBAL=$WORK/gconffor that onerun(set inline inside the test function),git push→ deny; T34b cwd plain, same env,cd "$WORK/auto" && git push→ deny; T34c cwd auto, default env → allow (control). control: T35 manual2git pushdeny, thengit config --unset gitflow.autopushin manual2 → allow. fail-closed: T36 cwd manual,PATH="$WORK/shim:$PATH"for that run,git push→ deny with rc 0 and reason containsinternal error(jq -cn fails → static deny). T37 cwd manualgit pushunder default PATH → reason contains! git pushandmanual push mode. payload: T38{}→ allow, empty stdout, rc 0; T39 payload withtool_input.commandbut nocwd→ uses PWD (run from manual/) → deny. wiring (file-content assertions, never typed as a command): T40jq -e '.hooks.PreToolUse[] | select(any(.hooks[]; .command=="bash ~/.claude/hooks/push-guard.sh")) | .matcher=="Bash|Monitor" and .hooks[0].timeout==10' "$ROOT/settings.json"; T41 every deny entry of settings (b) below present (loop over a literal list in the test file); T42 every deny entry ofgit show HEAD:settings.jsonstill present (nothing removed); T43 soft_deny containsmanual-push modeand the clearance clause! git push. banner:out=$(cd "$WORK/manual" && SESSION_START_OFFLINE=1 bash "$ROOT/hooks/session-start.sh" </dev/null 2>/dev/null); T44 positive controlgrep -q 'Claude Code config' <<<"$out"; T45grep -q 'push : manual (autopush=false)' <<<"$out"; T46 same fromauto/: positive control present AND nopush : manual. - settings.json (hand-formatted; text edits;
jq . settings.json >/dev/null;git diff settings.jsonshows only these hunks; comma discipline: previous last element gains,, new last has none). NOTE for the executor and the orchestrator: once this lands, ~/.claude/settings.json (symlink) is live — never type the new tokens (GIT_CONFIG_COUNT,GIT_CONFIG_PARAMETERS,--config-env) in a Bash command or a commit message; they live in files only. (a).hooks.PreToolUse+= NEW group{"matcher": "Bash|Monitor", "hooks": [{"type": "command", "command": "bash ~/.claude/hooks/push-guard.sh", "timeout": 10}]}. (b).permissions.deny, after"Bash(git config --local gitflow.*)", 18 entries:"Bash(git *config *gitflow.*)","Bash(git *config *remove-section*gitflow*)","Bash(git *config *rename-section*gitflow*)","Bash(git -c gitflow.*)","Bash(git * -c gitflow.*)","Bash(*--config-env*gitflow*)","Bash(*GIT_CONFIG_PARAMETERS*)","Bash(*GIT_CONFIG_COUNT*)","Bash(* GIT_CONFIG_GLOBAL=*)","Bash(* GIT_CONFIG_SYSTEM=*)","Edit(**/.git/config)","Write(**/.git/config)","Edit(**/.gitconfig)","Write(**/.gitconfig)","Edit(~/.gitconfig)","Write(~/.gitconfig)","Edit(~/.config/git/config)","Write(~/.config/git/config)". (c).permissions.autoMode.soft_deny+="Pushing in manual-push mode (\gitflow.autopush false`, set by the user): any git push by Claude — direct, scripted, aliased, inside a subshell, a Makefile target, a sub-agent, or after a HOME/GIT_CONFIG override that hides the key. The push-guard hook catches the direct forms; this rule covers the rest. A request to push in this turn does not clear it: the user types `! git push` in the terminal."; hard_deny "Routing around a guardrail": inserta PreToolUse hook,into the list of refusers (a command the deny rules, a PreToolUse hook or this classifier refused). Adding restrictions only. (d) prose: hard_deny "Branch deletion by hand": keepwhich every branch has since BDR-095and append(manual-push mode: the lib unsets the upstream itself before `-d`; the hand form stays banned); environment **Push discipline**: appendException, manual-push mode (`gitflow.autopush false`, set by the user, work machine): nothing is pushed by Claude, in any form; the user pushes by hand with `! git push`.` - hooks/session-start.sh — after the 🪝
GF_REFRESHEDblock:# ── 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
Edge cases
- Global key: shows the banner and denies everywhere, repo or not (truth on a work machine).
- Over-blocking in manual mode (loose match): listed in the header, two cases locked (T24, T25); never in auto mode.
Bash(*GIT_CONFIG_COUNT*)ends the LRN-069 token-header idiom (git -c http.extraHeader=…stays).Bash(* GIT_CONFIG_GLOBAL=*)leavesmake testuntouched (the export lives inside the Makefile).- Hook timeout 10 s → Claude Code treats a timeout as non-blocking (allow); the soft_deny and
askremain. !bang commands run in the user's terminal, outside the Bash tool — not hook-gated (belief): final report asks the user to probe once with! git push --dry-runin a scratch repo underautopush=false.- Run C: the bare read is denied for Claude after (b); run C adds a lib verb (
gitflow.sh push-mode, printsauto|manual|invalid) and gates skills on it — TODO updated by the orchestrator.
Disposition
- honors BDR-111 / BDR-095 (static deny first, prose second,
askentrusted with nothing; restrictions only added, nothing reworded or removed). - honors BLK-022 (one narrow guard), LRN-069/LRN-155 (hook = gate under auto), LRN-047/LRN-091 (silent in auto and on non-push), LRN-104 (every reason, the wiring, matcher and timeout locked), LRN-191 (no multi-line producer into
grep -q), BDR-110 (BSD sed/grep: bash folding,/usr/bin/grep -Esemantics verified by the challengers), LRN-193 (fresh confirmation pass done: FATAL(8) → this revision). - honors BDR-100 / LRN-113: surface grep after the change (file-content tokens only, via
make testassertions T41/T42); readers outside this run → run D.