forked from bchanot/claude
chore(memory): BDR-112 + LRN-194..196 + journal — feat manual-push-guard run B
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# 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-095` and 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.
|
||||
Reference in New Issue
Block a user