Hardening after the security gate on a2ac018 (3 MEDIUM, all closed and
re-measured):
- dir tokens are deduplicated and capped: more than 20 distinct cd/-C
targets in one command denies before any git fork (20000 tokens: 0.15 s
against the 10 s hook timeout that used to turn a flood into an allow)
- a git or cd failure while reading gitflow.autopush denies instead of
reading as auto (git absent, usage error, unenterable dir); the key
being unset is the only "auto" answer; the decision is recorded only
after one candidate was evaluated cleanly, else the EXIT trap denies
- cd/pushd/-C targets that follow a quote or backtick (bash -c '…') are
extracted; quote characters are excluded from unquoted tokens
User decision (contract, gated): both fail-closed cases also fire in
auto mode on such pathological commands; silence in auto mode holds for
every ordinary push. Header limits list the residual misses (quotes or
backslashes inside a token, cumulative relative cd, unparseable payload)
backed by the soft_deny rule. Tests: 71 checks (T48–T50b added).
Run B of manual-push mode (BDR-111). With `gitflow.autopush false`
nothing stops Claude from typing `git push` itself: the `ask` tier is
inert under auto mode. This adds the mechanical block the user chose.
- hooks/push-guard.sh (PreToolUse, matcher Bash|Monitor, timeout 10):
detects a push in the command text (strict, quote-stripped loose and
alias patterns; backslash-newline folded in bash, BSD sed/grep only),
reads gitflow.autopush in the payload cwd and in every literal -C/cd
dir the command names (global config counts outside a repo), denies
with the documented JSON form and a reason that tells the user to run
the command with `!`. Unparseable value = manual (fail closed); once
a push is detected an EXIT trap emits a static deny on any internal
error. jq missing = one stderr warning, allow (sibling-hook policy).
- settings.json: hook wiring; 18 deny entries closing the write forms
of the human-only toggle (any `git … config` spelling, section
removal/rename, `-c`, config env overrides, direct edits of git config
files); one soft_deny on pushing in manual mode in any form, with no
per-turn clearance; the routing-around rule names hook refusals.
- hooks/session-start.sh: `🔒 push : manual (autopush=false) — ! git push`
banner line when the key reads false (padding in bytes).
- lib/tests/push-guard.test.sh: 61 checks (push forms, over-blocks,
invalid value, global key, fail-closed trap, no-jq, wiring, banner).
Known limits are listed in the hook header; the soft_deny rule is the
backstop. Run C (skills that push on their own) and run D (fail-closed
readers everywhere) follow. Do not enable manual mode at work before C.
`gitflow.autopush false` (human-set git config) now means "nothing is
pushed" end to end, not only in the post-commit/post-merge hooks:
- lib/gitflow.sh: `_gitflow_push_off` is the single reader of
GITFLOW_NO_PUSH / gitflow.autopush for the lib's push sites; `start`
and `finish` stop pushing in manual mode. `gitflow_delete` checks out
the base that contains the branch and drops a lagging upstream before
`git branch -d` (LRN-161: `-d` judges against the upstream when set).
Skipped remote deletes say `left in place`; `_gitflow_sync_base`
replaces the silent `pull --ff-only || true` and warns when a base is
behind origin and cannot fast-forward.
- hooks/unpushed-guard.sh: manual mode is silent at Stop and gives one
`ℹ manual push mode:` line at SessionStart counting every local
branch; an unparseable value is named and treated as auto.
- CLAUDE.global.md: manual-push mode doctrine, "ahead = defect" scoped
to auto mode.
- Tests: gitflow-test T18m block (T18m0, T18i-T18o, 7 cases),
unpushed-guard T10-T16.
Follow-ups (TODO.md): run B push-guard hook + settings deny widening +
banner; run C skills that push on their own (/close STEP 5C, …).
Do not enable manual mode on the work machine before B and C land.
The suite and seven libs assumed a GNU userland: `cmd | grep -q` under
pipefail (grep exits at the first match, the producer takes SIGPIPE,
rc 141 → 15 false "merged into" FAILs in gitflow-test), `sed -i` with no
suffix, `wc -l` padding compared as a string, `stat -c`, `touch -d`,
`realpath -m` (gstack-links refusal never fired), bare `timeout` off the
sanitized PATH (design gate READY BUT UNVERIFIED), `grep -oP` (update-all
emptied the plugin list). Thirteen suites were red on this machine.
Portable forms on the native userland of both OS: producer captured out
of the pipeline, `sed -i.bak` in tests and a temp-sibling `sed_profile`
on the user dotfile, `tr -d ' '`, python3 perms, `touch -t`, a
`realpath -m` emulation that refuses `..`, perl `alarm` for the 15 s
bound, `sed -n` token extraction. Regression tests: gstack-links T4b,
doctrine-citers Alphabet/Alpha flip, profile-set-managed T18 (failing CLI
no longer aborts `set`), new portability-census suite over the tracked
shell files. Linux run deferred (see TODO).
The literal deny patterns miss spellings such as `npm i <pkg> -g`.
The classifier entry covers every form and asks for a vetting summary
(publisher, age, downloads, install scripts, advisories) first.