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

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 (JSON hookSpecificOutput.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 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 push.sh, git help push, a commit message containing "git push") and MISSES ("git" push, git "push", git send-pack caught, git -c alias.p=push p caught by the alias pattern; expansions ~/$VAR/$(…) in -C/cd never 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 -qE on a single-write printf '%s'): STRICT on one: (^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$) LOOSE on bare: (^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$) ALIAS on bare: alias\.[^=[:space:]]+=[^[:space:]]*push Not a push → exit 0 silently (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 with cwd; grep -oE on one for (^|[[: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> → prints manual / 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 repo git config reads global/system (work-machine --global deployment). Decide: loop candidates; first manual → deny reason push-guard: manual push mode (gitflow.autopush=false in <dir>) — Claude never pushes. Run it yourself in the terminal: ! <cmd>; first invalid:<raw> → deny reason push-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) pipes jq -n '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c},cwd:$d}' into bash "$H", records stdout AND rc; fire() → deny iff rc=0 and stdout parses with .hookSpecificOutput.permissionDecision=="deny", allow iff rc=0 and stdout empty, else error:<rc>; reason(). Multi-line producers never piped into grep -q (LRN-191): use grep -q … <<<"$out". Fixtures: plain/ (dir, not a repo), auto/ (git init, no key), manual/ (key false; sub/, my dir/ inside), bad/ (key flase), manual2/ (toggle), gconf (file [gitflow] / autopush = false), shim/ (dir with a jq script: [ "$1" = -cn ] && exit 1; exec /usr/bin/jq "$@", resolved via command -v jq at test time). Cases (≥40): auto/none: T1 plain git push allow; T2 auto git push allow; T3 auto git push -u origin feature/x allow. manual deny (cwd manual unless stated): T4 git push (also asserts stdout is ONE JSON line); T5 git push -u origin feature/x; T6 (cwd plain) git -C "$WORK/manual" push; T7 cd sub && git push; T8 git push --dry-run; T9 git -c a=b push origin HEAD; T10 (cd sub && git push); T11 bash -c 'git push'; T12 git push; echo done; T13 /usr/bin/git push; T14 git --no-pager push; T15 (cwd plain) cd "$WORK/manual/my dir"; git push; T16 git push&&echo ok; T17 (cwd plain) cd -- $WORK/manual && git push (literal expanded path, written by the test); T18 two-line git \ + newline + push; T19 git push|tee /dev/null; T20 git subtree push --prefix=x origin main (documented over-block); T21 (cwd plain) (cd $WORK/manual&&git push); T22 git -c alias.p=push p; T23 git send-pack origin; T24 grep -rn "git push" skills/ (documented over-block, locked); T25 git config --get push.default (documented over-block, locked). manual allow: T26 git status && git commit -m "fix push guard"; T27 bash ~/.claude/lib/gitflow.sh finish; T28 git pushd; T29 git stash; T30 echo pushed; T31 rg -C 3 push src/; T32 git branch --show-current. invalid: T33 bad git push → deny, reason contains not a boolean and flase. global: T34a cwd auto, GIT_CONFIG_GLOBAL=$WORK/gconf for that one run (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 manual2 git push deny, then git config --unset gitflow.autopush in manual2 → allow. fail-closed: T36 cwd manual, PATH="$WORK/shim:$PATH" for that run, git push → deny with rc 0 and reason contains internal error (jq -cn fails → static deny). T37 cwd manual git push under default PATH → reason contains ! git push and manual push mode. payload: T38 {} → allow, empty stdout, rc 0; T39 payload with tool_input.command but no cwd → uses PWD (run from manual/) → deny. wiring (file-content assertions, never typed as a command): T40 jq -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 of git show HEAD:settings.json still present (nothing removed); T43 soft_deny contains manual-push mode and 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 control grep -q 'Claude Code config' <<<"$out"; T45 grep -q 'push : manual (autopush=false)' <<<"$out"; T46 same from auto/: positive control present AND no push : manual.
  • settings.json (hand-formatted; text edits; jq . settings.json >/dev/null; git diff settings.json shows 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": insert a 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": keep which 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**: append Exception, 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_REFRESHED block:
    # ── 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=*) leaves make test untouched (the export lives inside the Makefile).
  • Hook timeout 10 s → Claude Code treats a timeout as non-blocking (allow); the soft_deny and ask remain.
  • ! 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-run in a scratch repo under autopush=false.
  • Run C: the bare read is denied for Claude after (b); run C adds a lib verb (gitflow.sh push-mode, prints auto|manual|invalid) and gates skills on it — TODO updated by the orchestrator.

Disposition

  • honors BDR-111 / BDR-095 (static deny first, prose second, ask entrusted 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 -E semantics 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 test assertions T41/T42); readers outside this run → run D.