Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" skill_type: workflow tools: Read, Edit, Bash, Glob, Grep triggers:
- "push"
- "git push"
- "푸시해"
name: "pre-push" description: "Mandatory pre-push security and quality pipeline. TRIGGER automatically whenever the user requests any git push: 'push my changes', 'push to origin', 'push this', 'push the code', 'commit and push', 'ship it', 'deploy to remote', 'deploy to prod/staging/production', or any git push command. Blocks hardcoded credentials + prompt-injection/exfiltration markers (14 patterns: AWS/GCP/Azure/LLM keys, private keys, connection strings, platform tokens, merge conflicts, embedded prompt-injection strings, non-standard package sources, Slack webhooks), supply chain risks (9-IOC), MCP tool poisoning (3 patterns), auth bypasses, and OWASP Top 10 vulnerabilities. Do NOT skip unless user says 'skip review' or 'force push'." license: "MIT" metadata: version: "3.11.0" author: "coinangel" user-invocable: true not_for:
- "Code review only -> code-reviewer agent"
- "Lint/type check only -> code-reviewer --quick"
depends_on:
- "code-reviewer"
- "security-reviewer"
- "database-reviewer"
- "refactor-cleaner"
- "build-error-resolver"
- "scripts/scan_secrets.py"
concurrency_profile: agents: "parallel (Step 6 review agents, cap 5 per wave; large diffs use deterministic bundling)" bash: "serial (git state advances sequentially — Steps 1/4/5/8 each depend on the prior step's exit code)" see_also: []
<!-- v3.11.0 (2026-09-25) — delta port from the internal fork's own 90-day execution-log audit. (1) Discard If / Step 1's empty check used to stop as soon as staged files were 0, even when 1+ commits were already made and outgoing (not yet pushed) — a secret committed earlier in the session with staging now empty slipped past the scan entirely. Both checks now also look at the outgoing-commit count before stopping. (2) Step 4's Python gate only recognized pyproject.toml/setup.py/requirements.txt as "a test runner is configured", so a repo using only pytest.ini/setup.cfg/tox.ini/ Pipfile/poetry.lock (or with tracked test_.py files and no manifest at all) silently skipped pytest — trigger widened to cover those. (3) Step 5a passed `$CHANGED_PY`/`$CHANGED_JS` unquoted into ruff/flake8/eslint, so a staged path containing a space broke the invocation — switched to `printf '%s\n' | xargs -d '\n'` and adjusted the `PIPESTATUS` index accordingly. (4) Steps 1-5a share shell variables, but a harness whose Bash tool doesn't keep state across separate calls silently emptied them when the steps ran as separate calls, turning Step 4/5a's gates into silent no-ops — Step 1 now persists the core variables (plus Step 4's `$CHANGED_) to a state file under .git/, keyed to the current push, and Steps 3/3.5/4/5a restore from it when their own copy is empty; an actually- empty $CHANGED_* now prints an explicit note instead of running quietly. (5) FILE_COUNT=... grep -c . || echo 0 printed a spurious second "0" line on empty input (grep -c already returns 0) — fallback removed. (6) The pytest test-count-floor state file moved from the PWD-relative .harness/test-count-floor.json (which could get committed in a project that doesn't gitignore .harness/) to .git/pre-push-test-count-floor.json. (7) scripts/scan_secrets.py gained a same-line allow marker (# scan-secrets: allow / the pre-existing # gitleaks:allow convention) for a confirmed false positive — every skip is counted and printed to stderr as [ALLOW] N ... so it can't hide a real finding silently; Step 2's remediation table and a new Invariant both require a fresh SECRETS_EXIT=0 re-scan after any block is cleared, not a hand-checked judgment call alone. v3.10.0 (2026-09-06) — fixed 3 external-audit findings. (1) Step 1 scanned only git diff --staged, but git push sends every commit from upstream (or the merge-base with the remote's default branch) to HEAD — commits already made earlier in a session could carry secrets past the staged-only net. Step 1 now resolves that outgoing-commit range (with a documented degraded-mode fallback when no upstream/origin-HEAD is resolvable) and scans it alongside the staged diff, combined into one pass; staged-diff scanning is kept as its own distinct check, not replaced. (2) Scanner-script discovery was hardcoded to find ~/.claude ..., so a standalone clone of this skill's own repo (not installed under ~/.claude or a project .claude/) couldn't find its bundled scan_secrets.py/.pl. Replaced with a 3-candidate portable search (~/.claude → project .claude/ → $PWD tree). ${CLAUDE_PLUGIN_ROOT} was evaluated and rejected for this — verified against anthropics/claude-code#38699 (inconsistent path across hook vs. agent Bash contexts) and #9354 (doesn't expand inside markdown-embedded instructions). (3) Autonomy Boundary claimed test runners "read without mutating" — inaccurate, since Step 4 writes a local test-count-floor state file and npm run build writes build artifacts. Reworded to distinguish "no git-state mutation" (still true, still needs no confirmation) from "read-only" (false) — labeled these side-effecting-but-non-git instead. v3.9.0 (2026-09-06) — fixed a real regression: 9 spots in Steps 0/3/4/5a used the cmd | tail -N / VAR=$? pattern, which (with no pipefail in this shell) captures tail's exit code, not the underlying command's — lint, build, and test failures were silently swallowed and reported as pass. Switched each to ${PIPESTATUS[0]} (or, where output is already captured via $(...), split the exit-code capture from the truncation step so the exit code is read before tail runs). Step 4 Python gained an opt-in test-count-floor check (WARN-only, pattern credited to CopilotKit/openbot's scripts/test-ci.ts): compares the current pytest passed-count against the previous run's count (stored in a local, gitignored state file) and warns on a sharp drop — catches a whole file silently dropping out of collection (e.g. an import-time exception) that would otherwise still read as a clean pass. Also corrected a stale count: scan_secrets.py already implements 14 patterns (f1-f13 + the merge-conflict marker) since the f11/f12/f13 ports landed in v3.6.0/v3.8.0, but the frontmatter and Step 1 body text still said "12 patterns" from before those ports — scan_secrets.pl` has f11 and f12 but not f13, so it covers 13. v3.8.0 (2026-07-24) — dual scanner support: scripts/scan_secrets.py added alongside scan_secrets.pl (Python preferred when a Python runtime is available, Perl kept as fallback — both are maintained). Step 6 deterministic claim-verification sub-step (generic since v3.7.0) now cites a concrete example script/invocation, still optional and environment-specific. LICENSE.txt added for upstream (coinangel/claude-pre-push-skill) attribution. Note: scan_secrets.py's pattern coverage has diverged from scan_secrets.pl since Python-side v2.2.0/v2.3.0 evasion-hardening (see Step 1 parity note) — reported, not reconciled, in this release. v3.7.0 (2026-07-18) — Step 6: cross-bundle joint pass (catches changes fragmented across bundles that look safe in isolation) + deterministic claim verification (optional, checks finding citations against the actual diff) added. Step 7: three-state false-positive gate for high-FP pattern classes (PII/name-like matches) added. v3.6.0 (2026-07-15) — scan_secrets.pl synced to v2.1.0: f11 (prompt-injection string detection) + f12 (non-PyPI/HTTP-git supply-chain source detection) ported. Step 0: Hook Pipeline Health section added (WARN-only smoke-test check before Step 1). v3.5.0 (2026-07-07) — 6-IOC→9-IOC expansion: dependency confusion / missing pinned version / post-install hook network calls. MCP tool poisoning 3 patterns (hidden instructions/Unicode deception/parameter injection) ported. v3.3.0 (2026-06-12) — Step 6 Large diff deterministic bundling (coverage guaranteed by structure, not agent integrity) v3.2.0 (2026-05-06) — Error Recovery (Step 7) + State Sync (Step 6 parallel result merge) v3.1.0 (2026-04-11) — defense structure: Dominant variable, Discard if, Rationalization Table (6), Invariants (4), Scope Boundary v3.0.0 (2026-04-10) — scanner: scripts/scan_secrets.pl (12 patterns) | multi-lang tests + direct lint | parallel AI review agents -->
Pre-Push Pipeline
Dominant Variable
Does the secrets scanner run without exception — a single skip permanently records credentials in git history.
Trigger
- "push"
- "git push"
- "푸시해"
Discard If
- User explicitly says "skip review" or "force push" → proceed directly to Emergency Override
- 0 staged files AND 0 outgoing (already-committed, not-yet-pushed) commits — check both before discarding:
git rev-list --count '@{upstream}'..HEAD 2>/dev/null || git rev-list --count origin/HEAD..HEAD 2>/dev/null. If outgoing has 1+ commits, this is NOT a discard case — proceed to Step 1 so the outgoing commits get scanned too (a secret committed earlier this session, with staging now empty, used to slip past a staged-files-only check entirely). *.md/docs/**changes only (fast-exit at Step 2, but agent review overhead unnecessary at this condition). This skips the agent-review overhead only — Step 1/2/3.5 scanning still runs; the Step 2 fast-exit fires only afterSECRETS_EXIT=0is already known, not before scanning.
Key Assumptions
- `scan_secrets.pl` script accessible — if broken: secrets scan unavailable → push blocked.
- git staged files exist — if broken: inform user and halt.
- Agent tools (code-reviewer, etc.) dispatchable — if broken: replace code-reviewer with an inline abbreviated review (see Error Recovery below) rather than skipping it outright — an unconditional skip trades away review quality. Conditional agents (security/database/refactor, etc.) still skip with an explicit note.
Autonomy Boundary
Every step through Step 6 only inspects git state — git diff, git status, git branch --show-current, git log, linters, and the parallel review agents never mutate the repo or its remote, so none of them need a per-command confirmation to run. Build and test runners (Step 4) are not fully read-only: they don't touch git state (no commits, no staged-index changes), but they do have local filesystem side effects — npm run build writes build artifacts to the project's configured output directory, and the Python test-count-floor check writes/updates a local state file under .git/ (pre-push-vars.sh, pre-push-test-count-floor.json -- never staged or committed, so nothing to gitignore). These are non-git, locally-reversible outputs (re-running regenerates them), so they still need no per-command confirmation, but "read-only" is the wrong label for them — treat them as side-effecting-but-non-git. git push is the one write action against git/remote state in this entire pipeline, and it's exactly where the gates apply: it only fires after Step 8's Overall verdict is READY TO PUSH, and a push to main/master additionally needs an explicit "yes" (Step 1 protected-branch block, Safety Layers L3). Treat "runs freely" and "needs approval" as following directly from read-vs-write-to-git-state, not from step number or perceived risk.
Step 0: Hook Pipeline Health (Fast, WARN-only)
Before scanning the diff, confirm your PreToolUse/Stop hook pipeline itself hasn't silently regressed — a broken hook is invisible until something it should have caught gets through.
If your setup has a hook smoke-test script (a script that exercises your hooks end-to-end and reports pass/fail), run it here:
SMOKE_SCRIPT=$(find ~/.claude -maxdepth 3 -iname "hook_smoke_test*" -type f 2>/dev/null | head -1)if [ -n "$SMOKE_SCRIPT" ]; then"$SMOKE_SCRIPT" full 2>&1 | tail -20SMOKE_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not the smoke test's[ $SMOKE_EXIT -ne 0 ] && echo "⚠️ hook smoke test FAIL — hook pipeline regression suspected, recommend investigating before Step 7 Gate Check (does not block)"elseecho "➖ No hook smoke-test script found — skipping Step 0 (optional check)"fi
WARN-only — a smoke failure does not block push (avoids introducing a new hard gate outside design scope), but MUST be surfaced in the Step 8 report.
Step 1: Assess & Scan
Run everything in one bash call — variables share the same shell session, so $STAGED_DIFF is reused for the secrets scan without a second git diff invocation.
This "one shell session" requirement is not limited to Step 1 alone: $STAGED_FILES/$STAGED_DIFF/$CURRENT_BRANCH/$DIFF_LINES/$FILE_COUNT (set in Step 1) and $CHANGED_PY/$CHANGED_JS/$CHANGED_GO (set in Step 4) are reused all the way through Step 1-Step 5a (Step 6 only dispatches agents and runs no bash, so it is out of scope here). If your Bash tool does not keep shell state across separate tool calls (some agent harnesses reset the shell each call, keeping only the working directory), running Steps 1-5a as separate calls silently empties these variables in later steps — Step 4's Build & Test "Fail Fast" and Step 5a's Lint "BLOCK" gate can then no-op quietly (an empty $CHANGED_PY means nothing gets checked, which reads as "passed"). Run Steps 1-5a as one continuous bash call whenever your environment allows it — do not split each bash code fence into a separate tool call. When a split is unavoidable, Step 1 persists the core variables to $(git rev-parse --git-dir)/pre-push-vars.sh (inside .git/, never staged or committed) and Steps 3/3.5/4/5a each restore from it if their own copy is empty, keyed to the current HEAD plus the staged-diff hash so a stale file from an earlier push is never silently reused. Even with that fallback, Steps 4/5a print an explicit ⚠️ CHANGED_* empty note instead of quietly doing nothing, so a genuinely empty list isn't mistaken for a passed check.
Scan scope — staged diff AND outgoing commits: git diff --staged alone is not what git push actually sends. A push transmits every commit from the upstream (or the merge-base with the remote's default branch) up to HEAD — including commits made earlier in this session that were already committed and are therefore invisible to a staged-only scan. Step 1 scans both: the staged diff (about to be committed) and the outgoing-commit range (already committed, not yet on the remote), combined into one pass. Staged-diff scanning is not replaced by this — it stays a distinct check, since staged changes aren't part of any commit's history yet.
Scanner selection: prefer scan_secrets.py when a Python runtime is available, otherwise fall back to scan_secrets.pl — both are maintained. Python needs no extra runtime install in most environments, but this package started as a Perl-based scanner, so both implementations are kept for compatibility.
Scanner script discovery — portable, not `~/.claude`-only: this skill's bundled scripts may live under the global ~/.claude install, a project-local .claude/, or (if this skill's own repo was cloned standalone rather than installed into either) somewhere under the current working directory. find tries each candidate in that order and stops at the first hit, so the common case (~/.claude) pays no extra cost. (${CLAUDE_PLUGIN_ROOT} was considered and rejected — it's known to point to inconsistent paths across hook vs. agent Bash contexts, and does not expand inside markdown-embedded instructions like this one; see anthropics/claude-code#38699 and #9354.)
STAGED_FILES=$(git diff --staged --name-only)STAGED_DIFF=$(git diff --staged)DIFF_LINES=$(echo "$STAGED_DIFF" | wc -l | tr -d ' ')FILE_COUNT=$(echo "$STAGED_FILES" | grep -c .) # grep -c already prints 0 on no match -- a `|| echo 0` fallback prints a second "0" line on top of itCURRENT_BRANCH=$(git branch --show-current)# Outgoing-commit range (already committed, not yet pushed). Falls back to the# remote's default branch when no upstream is configured (new/never-pushed# branch); if that's also unresolvable (no network, origin/HEAD never set# locally), outgoing-commit scanning degrades to staged-only — surfaced below# and in the Step 8 report, never silently dropped.UPSTREAM_REF=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)[ -z "$UPSTREAM_REF" ] && UPSTREAM_REF=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#^refs/remotes/##')MERGE_BASE=""[ -n "$UPSTREAM_REF" ] && MERGE_BASE=$(git merge-base "$UPSTREAM_REF" HEAD 2>/dev/null)if [ -n "$MERGE_BASE" ]; thenOUTGOING_DIFF=$(git diff "$MERGE_BASE"..HEAD)OUTGOING_COMMIT_COUNT=$(git rev-list --count "$MERGE_BASE"..HEAD)OUTGOING_MODE="${UPSTREAM_REF}..HEAD (${OUTGOING_COMMIT_COUNT} commit(s))"elseOUTGOING_DIFF=""OUTGOING_COMMIT_COUNT=0OUTGOING_MODE="unresolvable (no upstream, no origin/HEAD) — staged-only, degraded mode"fiCOMBINED_DIFF="$STAGED_DIFF$OUTGOING_DIFF"SCAN_START=$(date +%s)# Portable script discovery: try ~/.claude, then a project-local .claude/, then# finally the current working directory tree (covers a standalone clone of this# skill's own repo living under neither ~/.claude nor a project .claude/).find_bundled_script() {local name="$1" found=""found=$(find "$HOME/.claude" -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)[ -z "$found" ] && [ -d "$PWD/.claude" ] && found=$(find "$PWD/.claude" -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)[ -z "$found" ] && found=$(find "$PWD" -maxdepth 6 -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)echo "$found"}SCAN_SCRIPT_PY=$(find_bundled_script "scan_secrets.py")SCAN_SCRIPT_PL=$(find_bundled_script "scan_secrets.pl")if [ -n "$SCAN_SCRIPT_PY" ] && command -v python >/dev/null 2>&1; thenSECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | python "$SCAN_SCRIPT_PY")SECRETS_EXIT=$?elif [ -n "$SCAN_SCRIPT_PL" ]; thenSECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | perl "$SCAN_SCRIPT_PL")SECRETS_EXIT=$?elseecho "🚨 No scanner found (scan_secrets.py or scan_secrets.pl) — secrets scan unavailable, push blocked"SECRETS_EXIT=1fiSCAN_TIME=$(($(date +%s) - SCAN_START))echo "Branch: $CURRENT_BRANCH | Files: $FILE_COUNT | Diff: $DIFF_LINES lines | Outgoing: $OUTGOING_MODE | Scan: ${SCAN_TIME}s"[ $SECRETS_EXIT -ne 0 ] && echo "$SECRETS_OUTPUT"# Persist core vars so a later Step (3/3.5/4/5a) running in a fresh bash call# (see the note above this block) can restore them. Stored under .git/# (git-dir, not the worktree), so it is never staged or committed.STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"{printf 'STAGED_FILES=%q\n' "$STAGED_FILES"printf 'STAGED_DIFF=%q\n' "$STAGED_DIFF"printf 'OUTGOING_DIFF=%q\n' "$OUTGOING_DIFF"printf 'CURRENT_BRANCH=%q\n' "$CURRENT_BRANCH"printf 'DIFF_LINES=%q\n' "$DIFF_LINES"printf 'FILE_COUNT=%q\n' "$FILE_COUNT"printf 'MERGE_BASE=%q\n' "$MERGE_BASE"printf 'OUTGOING_MODE=%q\n' "$OUTGOING_MODE"printf 'OUTGOING_COMMIT_COUNT=%q\n' "$OUTGOING_COMMIT_COUNT"printf 'SECRETS_EXIT=%q\n' "$SECRETS_EXIT"printf 'STATE_KEY=%s\n' "$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" # ties the file to this exact push} > "$STATE_FILE"
The preferred scanner (scripts/scan_secrets.py) covers 14 patterns across three categories:
- Credentials (f1–f10): AWS keys, private keys, connection-string passwords, hardcoded assignments (quoted/unquoted), platform tokens (Slack, GitHub 6 types, Stripe live), Dockerfile ENV secrets, Google/Gemini API keys, npm auth tokens, LLM provider keys (Anthropic/OpenAI/HuggingFace/Replicate/Groq), Azure Storage/SAS/connection strings.
- Injection & exfiltration channels (f11–f13): embedded prompt-injection strings (supply-chain prompt attack), non-standard package install sources, Slack incoming webhook URLs.
- Code integrity (f_merge): unresolved merge conflict markers.
Parity note: scripts/scan_secrets.py began as a straight port of the Perl scanner but has since received independent anti-evasion hardening (Unicode/homoglyph normalization, reversed-line re-scan) not back-ported to scan_secrets.pl. Pattern coverage has also diverged: scan_secrets.pl has f11 and f12 but lacks f13 (Slack webhook detection), so it covers 13 patterns, not 14 — the two implementations are not guaranteed to have identical coverage. Treat scan_secrets.py as the more complete/current scanner where they diverge.
Design note: the scanner intentionally scans only added (`+`) lines, not removed (-) lines — this avoids blocking commits that are removing a secret. Merge conflict markers are an exception and checked on all lines. This applies identically to both diffs inside $COMBINED_DIFF.
Empty check: only stop when $STAGED_FILES and $OUTGOING_COMMIT_COUNT are both empty/zero (matches the Discard If condition above). If staged is empty but outgoing has 1+ commits, the scan above already ran against $COMBINED_DIFF (staged + outgoing) — carry that $SECRETS_EXIT forward into Step 2 rather than stopping early. This closes the "already committed, staging is empty" gap: a secret committed earlier in the session but never (re-)staged used to be treated as "nothing to check" and skipped entirely.
Protected branch block: If $CURRENT_BRANCH is main or master → stop and ask for an explicit "yes" before proceeding.
Step 2: Routing & Remediation
SECRETS_EXIT=1 → BLOCKED. Print each finding with its specific remediation:
| Finding | Remediation | |
|---|---|---|
| Merge conflict markers | Resolve conflicts: git status to find files, fix markers, re-stage. | |
| AWS Access Key | Replace with process.env.AWS_ACCESS_KEY_ID. If real key: rotate immediately at AWS IAM console. | |
| Private key | Move to ~/.ssh/ or a secrets manager. Add path to .gitignore. | |
| Connection string password | Use process.env.DATABASE_URL. Never embed credentials in URLs. | |
| Platform token (GitHub/Slack/Stripe) | Revoke in provider dashboard. Re-create with minimal scopes. | |
| LLM API key | Replace with process.env.ANTHROPIC_API_KEY (or provider equivalent). Rotate if exposed. | |
| Azure credential | Replace with Managed Identity or environment variable. | |
| Dockerfile ENV secret | Use --secret mount or ARG with external injection. Never hardcode in ENV. | |
| Generic hardcoded credential | Move to .env.local → process.env.YOUR_KEY. Verify .gitignore covers .env*. | |
| False positive (scanner's own pattern-definition line, a synthetic test fixture) | Append # scan-secrets: allow (or the pre-existing # gitleaks:allow convention) to that same line only. Every skip is counted and printed as [ALLOW] N ... so it can't hide a real finding silently — never add this marker to a line that holds an actual value. |
Re-scan required after any fix: once SECRETS_EXIT=1 has fired, do not push on a hand-checked "that was a false positive" judgment alone — re-run Step 1's scan after fixing/marking the finding and paste the resulting SECRETS_EXIT=0 output into the Step 8 report before pushing.
SECRETS_EXIT=0 AND only `*.md` / `docs/` changed** → fast exit, push directly, skip all agents.
Otherwise → continue to Step 3.
Step 3: Supply Chain & Infrastructure Check (WARN — never blocks)
Scan $STAGED_FILES and list findings in the final report:
- Package manifests (
package.json,yarn.lock,pnpm-lock.yaml,requirements.txt,Gemfile,go.mod,Cargo.toml): list all added dependencies. Flag misspelled or unfamiliar names as potential typosquats. - Infrastructure files (
Dockerfile,docker-compose*.yml,*.tf,*.yaml/*.ymlink8s/orinfra/,nginx.conf): flag any ENV, ARG, or environment sections for human review. - Python CVE scan (
pip-audit): run whenrequirements.txtorpyproject.tomlchanged andpip-auditis installed. WARN only — never blocks. - 9-IOC Supply Chain + MCP Check: for newly added dependencies and MCP configuration, check 9 indicators of compromise:
- External install links — does the package README or setup script fetch from non-registry URLs?
- Obfuscated exfiltration — base64/hex-encoded strings in post-install scripts?
- Capability mismatch — does a "utility" package request network/filesystem access beyond its stated purpose?
- MCP hidden instructions — does an MCP tool description/parameter embed text meant to steer agent behavior?
- MCP Unicode deception — direction-override characters (e.g. U+202E) or homoglyphs in a tool name/description?
- MCP parameter injection — an executable command or URL embedded in a tool parameter default/enum?
- Dependency confusion — is an internal/private package name unregistered on public PyPI/npm? An attacker can register the same name (Birsan 2021). Check registry existence when adding a new package to
requirements.txt/package.json. - Missing pinned version — warn when a new dependency has no pinned version (
requestsvsrequests==2.32.3). Unpinned = supply-chain attack surface. - Post-install hook — does
setup.py/pyproject.toml's[tool.setuptools.cmdclass]or an npmpostinstallscript make network calls or write files?
Flag any match as ⚠️ SUPPLY_CHAIN_IOC or ⚠️ MCP_POISONING in the report.
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Step 3 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elseCHANGED_REQS=$(echo "$STAGED_FILES" | grep -E "(requirements.*\.txt|pyproject\.toml|setup\.py)$")if [ -n "$CHANGED_REQS" ] && command -v pip-audit >/dev/null 2>&1; then# capture pip-audit's own exit code before truncating -- piping straight into# $(... | tail -20) would capture tail's exit code insteadAUDIT_RAW=$(pip-audit --format=columns 2>&1); AUDIT_EXIT=$?AUDIT_OUT=$(echo "$AUDIT_RAW" | tail -20)[ $AUDIT_EXIT -ne 0 ] && echo "pip-audit: $AUDIT_OUT"fifi
Install:pip install pip-audit(Python native, no Go binary needed — preferred over osv-scanner for Python projects)
Further reading: the 9-IOC checklist above is a lightweight, offline heuristic run inline by this skill — it is not a substitute for dedicated supply-chain tooling. For broader, actively maintained coverage (SBOM generation, provenance attestation, dependency health scoring), see the OpenSSF Scorecard project and the OWASP Cheat Sheet Series.
Step 3.5: Public-Mirror Scrub (WARN — never blocks, conditional)
A push-time backstop for any write-time reminders you run elsewhere (e.g. a pre-commit leak scanner) — different layers, so if one is bypassed the other still catches it (defense in depth). Useful if this repo is a curated public mirror of a private source (internal identifiers, section numbers, or internal-only tool paths can leak into a public-facing copy through copy-paste).
Trigger condition: only runs when git remote get-url origin matches a repo you've configured as a known public mirror. Private/internal repos should skip this step entirely.
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }[ -z "$COMBINED_DIFF" ] && COMBINED_DIFF="$STAGED_DIFF$OUTGOING_DIFF"if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Step 3.5 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elseORIGIN_URL=$(git remote get-url origin 2>/dev/null || echo "")# Replace the pattern below with your own public-mirror repo name(s).if echo "$ORIGIN_URL" | grep -qiE "YOUR_PUBLIC_MIRROR_REPO_NAME_HERE"; then# (a) org-identifier / internal-jargon term list — reuse your leak-scan# script's pattern list if you have one, e.g. internal §-numbered rule# citations, private absolute paths, internal-only repo names.echo "$STAGED_DIFF" | grep -nE "§[0-9]+|internal-only-path-pattern" \&& echo "⚠️ PUBLIC_MIRROR_SCRUB: possible internal identifier/section-number string found — verify before pushing to public mirror (${ORIGIN_URL})"# (b) binary sweep — staged non-text assetsBINARY_FILES=$(git diff --staged --name-only --diff-filter=A | while read -r f; dofile --mime-encoding "$f" 2>/dev/null | grep -q "binary" && echo "$f"done)[ -n "$BINARY_FILES" ] && echo "⚠️ PUBLIC_MIRROR_SCRUB: new binary file(s) — confirm these are intentional public assets: $BINARY_FILES"# (c) a surface the diff won't catch — internal identifiers baked into the branch name itselfecho "$CURRENT_BRANCH" | grep -qiE "§|internal-only-path-pattern" \&& echo "⚠️ PUBLIC_MIRROR_SCRUB: possible internal identifier in branch name — this surface isn't visible in the diff"fifi
WARN-only (advisory, never blocks) — mirrors the same explicit non-blocking decision as the write-time leak scanner it backstops. Adjust the grep patterns to whatever your own internal-only vocabulary actually is.
Step 4: Build & Test (Fail Fast)
Detect changed languages first, then run only the relevant test/build commands. Skip entirely for config, docs, or style-only commits.
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Step 4 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elseCHANGED_PY=$(echo "$STAGED_FILES" | grep -E "\.py$")CHANGED_JS=$(echo "$STAGED_FILES" | grep -E "\.(ts|tsx|js|jsx)$")CHANGED_GO=$(echo "$STAGED_FILES" | grep -E "\.go$"){printf 'CHANGED_PY=%q\n' "$CHANGED_PY"printf 'CHANGED_JS=%q\n' "$CHANGED_JS"printf 'CHANGED_GO=%q\n' "$CHANGED_GO"} >> "$STATE_FILE" # append so a later split-off Step 5a call can restore these toofi
Python — run when .py files changed and a test runner is configured:
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_PY" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Python test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_PY" ]; thenecho "⚠️ CHANGED_PY empty -- nothing to test"elif [ -f "pyproject.toml" ] || [ -f "setup.py" ] || [ -f "requirements.txt" ] || [ -f "pytest.ini" ] || [ -f "setup.cfg" ] || [ -f "tox.ini" ] || [ -f "Pipfile" ] || [ -f "poetry.lock" ] || git ls-files | grep -qE '(^|/)(test_[^/]+|[^/]+_test)\.py$'; then# Trigger widened beyond pyproject.toml/setup.py/requirements.txt -- repos using only# pytest.ini/setup.cfg/tox.ini/Pipfile/poetry.lock, or with tracked test_*.py/*_test.py# files and no manifest at all, previously skipped pytest silently.TEST_START=$(date +%s)PYTEST_OUTPUT=$(timeout 120 pytest -q 2>&1)PYTEST_EXIT=$?echo "$PYTEST_OUTPUT" | tail -20TEST_TIME=$(($(date +%s) - TEST_START))# Test-count floor (WARN-only, pattern credited to CopilotKit/openbot's scripts/test-ci.ts) —# exit=0 (all passed) doesn't mean nothing broke: if a whole file silently drops out of# collection (e.g. an import-time exception), the suite just got smaller and still "passes".# Track the previous run's passed-count in a local, gitignored state file and compare —# warn only on a sharp drop; a first run (no state file yet) just records and passes.PASSED_COUNT=$(echo "$PYTEST_OUTPUT" | grep -oE "[0-9]+ passed" | tail -1 | grep -oE "^[0-9]+")FLOOR_FILE="$(git rev-parse --git-dir)/pre-push-test-count-floor.json" # was PWD-relative .harness/test-count-floor.json -- could get committed in other repos; .git/ is never trackedif [ -n "$PASSED_COUNT" ]; thenif [ -f "$FLOOR_FILE" ]; thenLAST_COUNT=$(grep -oE '"count": *[0-9]+' "$FLOOR_FILE" | grep -oE '[0-9]+' | tail -1)if [ -n "$LAST_COUNT" ] && [ "$LAST_COUNT" -gt 0 ]; thenDROP_PCT=$(( (LAST_COUNT - PASSED_COUNT) * 100 / LAST_COUNT ))[ "$DROP_PCT" -ge 10 ] && echo "⚠️ TEST_COUNT_FLOOR: ${PASSED_COUNT} passed (down ${DROP_PCT}% from ${LAST_COUNT} last run) — a file may have silently dropped out of collection. Ignore if intentional deletion, otherwise check for a collection error."fifimkdir -p "$(dirname "$FLOOR_FILE")"printf '{"count": %s, "updated": "%s"}\n' "$PASSED_COUNT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$FLOOR_FILE"fielseecho "➖ Python test runner config not found -- skipping pytest"fi
Go — run when .go files changed and go.mod exists:
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_GO" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Go test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_GO" ]; thenecho "⚠️ CHANGED_GO empty -- nothing to test"elif [ -f "go.mod" ]; thenTEST_START=$(date +%s)timeout 120 go test ./... 2>&1 | tail -20GO_TEST_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not go test'sTEST_TIME=$(($(date +%s) - TEST_START))elseecho "➖ go.mod not found -- skipping go test"fi
JS/TS — build then test when source files changed:
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_JS" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ JS/TS build&test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_JS" ]; thenecho "⚠️ CHANGED_JS empty -- nothing to build/test"elif [ -f "package.json" ]; thenBUILD_START=$(date +%s)timeout 120 npm run build 2>&1 | tail -30BUILD_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not the build'sBUILD_TIME=$(($(date +%s) - BUILD_START))if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)" 2>/dev/null; thentimeout 60 npm test -- --passWithNoTests 2>&1 | tail -20JS_TEST_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not the test run'sfielseecho "➖ package.json not found -- skipping build/test"fi
Any failure → stop immediately. Run build-error-resolver agent, then restart from Step 1.
Step 5: Lint Gate (Direct → AI)
Two sub-steps in order. Direct lint runs first (fast, no token cost). AI layer runs after.
5a: Direct Lint (Blocking)
Run only for changed files of the matching language.
Python — ruff preferred, flake8 fallback:
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_PY" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Python lint step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_PY" ]; thenecho "⚠️ CHANGED_PY empty -- nothing to lint"else# printf+xargs -d '\n' keeps paths with spaces intact ($CHANGED_PY was previously passed# unquoted and word-split on spaces, breaking on any staged path containing a space).# PIPESTATUS[1] is xargs here (printf | xargs | tail pipeline, not [0]) -- xargs maps the# linter's own nonzero exit to 123 (GNU xargs convention); any nonzero from xargs still# means the linter found issues, so it's still treated as BLOCK below.if command -v ruff >/dev/null 2>&1; thenprintf '%s\n' "$CHANGED_PY" | xargs -d '\n' -r timeout 30 ruff check 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[1]}elif command -v flake8 >/dev/null 2>&1; thenprintf '%s\n' "$CHANGED_PY" | xargs -d '\n' -r timeout 30 flake8 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[1]}fifi
Go — go vet (always available):
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_GO" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ Go vet step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_GO" ]; thenecho "⚠️ CHANGED_GO empty -- nothing to lint"elsetimeout 30 go vet ./... 2>&1 | tail -20; GO_VET_EXIT=${PIPESTATUS[0]} # not $? — tail's exit codefi
JS/TS — eslint if config file present:
STATE_FILE="$(git rev-parse --git-dir)/pre-push-vars.sh"[ -z "$STAGED_FILES$CHANGED_JS" ] && [ -f "$STATE_FILE" ] && { grep -qxF "STATE_KEY=$(git rev-parse HEAD 2>/dev/null):$(git diff --staged | git hash-object --stdin)" "$STATE_FILE" && . "$STATE_FILE" || { echo "⚠️ pre-push-vars.sh does not match this push (HEAD or staged diff changed since Step 1) -- rerun from Step 1"; SECRETS_EXIT=1; }; }if [ "${SECRETS_EXIT:-1}" -ne 0 ]; thenecho "⏭️ ESLint step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"elif [ -z "$CHANGED_JS" ]; thenecho "⚠️ CHANGED_JS empty -- nothing to lint"elif ls .eslintrc* eslint.config* 2>/dev/null | head -1 | grep -q .; then# printf+xargs -d '\n' keeps paths with spaces intact. PIPESTATUS[1] is xargs here# (printf | xargs | tail pipeline) -- xargs maps eslint's nonzero exit to 123; treat as BLOCK.printf '%s\n' "$CHANGED_JS" | xargs -d '\n' -r timeout 30 npx eslint 2>&1 | tail -20; ESLINT_EXIT=${PIPESTATUS[1]} # not $? — tail's exit codeelseecho "➖ No eslint config found -- skipping eslint"fi
Lint fails → BLOCK. Fix errors before continuing to 5b.
5b: code-reviewer --quick Gate (AI, Serial)
Skip if `$DIFF_LINES` < 50 — tiny diffs have negligible type/lint risk.
Run code-reviewer --quick (haiku) for type errors and logical lint issues that static tools miss. FAIL → fix before continuing.
Review the following staged diff for type errors and lint issues only.Do NOT read entire files unless absolutely necessary.<diff>[paste full output of: git diff --staged]</diff>
Step 6: Launch Review Agents in Parallel
Spawn all applicable agents in a SINGLE response turn using concurrent subagent calls — never sequentially. Parallel execution cuts total wall time by the duration of the slowest agent.
Large diff — deterministic bundling ($DIFF_LINES > 500 OR files > 10):
In large changesets, agents choosing which files to read on their own leads to coverage gaps — coverage is guaranteed by structure, not agent integrity:
- Deterministic bundle split: group
$STAGED_FILESby top-level directory/module. Natural pairs (source+test, implementation+config) go in the same bundle. Bundle size guide: diff ≤ ~300 lines or ≤ 5 files; if larger, re-split. - One reviewer per bundle: dispatch code-reviewer once per bundle — each agent receives only its bundle's diff (isolated context, never send full diff). Parallel cap 5 — if 6+ bundles, batch in groups of 5.
- Coverage validation (deterministic): before dispatch, verify
union of bundle files == all of $STAGED_FILESby count. Mismatch → add missing files to final bundle. Never dispatch without this check. - Cross-bundle joint pass: after all per-bundle reviews complete, forward the union of each bundle's finding-list summary to a single reviewer (code-reviewer) for one final pass across bundle boundaries. This catches fragmentation — a change deliberately or accidentally split across bundles can look safe in each isolated review but be unsafe once combined. Do not declare a large-diff review complete without this joint pass.
- Deterministic claim verification (optional, environment-specific): save the joint-pass output (finding list with file/line-range citations — e.g.
【F:path†Lstart-Lend】-style anchors) to a file, then run a claim-verification script against it if your setup has one — for examplepython "$HOME/.claude/scripts/verify_review_claims.py" --review <finding-list-file> --repo-root .. It should check, per finding: (a) the cited file exists in the staged diff, (b) the cited line range overlaps the diff hunk, and (c) an optional grep cross-check passes — labeling each findingCONFIRMEDorUNVERIFIED-CLAIM. KeepUNVERIFIED-CLAIMfindings open for re-review before Step 7 Gate Check — do not auto-downgrade them. This script is optional and environment-specific: if~/.claude/scripts/verify_review_claims.py(or your own equivalent) is not present, skip this sub-step and proceed with the cross-bundle joint pass alone; note the skip in the Step 8 report. - Opt-in high-risk gap-sweep second pass: code-reviewer's own Resource Limits describe a single-pass principle, but categories like a guard lost during a move/extract, a dataclass default evaluated once,
hash()non-determinism, a shrunk lock scope, a side-effecting predicate, asymmetric test setup/teardown, or a flipped config default are exactly the kind of thing a first pass tends to miss structurally. Only when `$DIFF_LINES` > 500 OR files > 10 (same trigger as the large-diff bundling above) — dispatch one fresh code-reviewer instance dedicated solely to this miss-category checklist (not re-checking findings already surfaced), capped at 8 new findings (empty result if none — no padding). This runs against the single-pass principle, so it's opt-in and scoped to large/high-risk diffs only — don't apply it to small diffs. - Conditional agents (security/database/refactor): follow existing trigger rules, but only pass diff for bundles matching those triggers.
- Multi-angle parallel re-attack (opt-in, not the default): code-reviewer's 12 questions already sweep correctness/resource-management/security/design/convention in one pass, so this doesn't belong in the default pipeline. For changes that are extremely hard to reverse (a merged DB migration, payment/funds logic) — only when the user explicitly asks for it — dispatch 5 parallel code-reviewer instances, each pinned to one of those five lenses, to re-attack the same diff.
Small diff (≤500 lines AND ≤10 files): use existing approach, send full diff to each agent. Bundling overhead not needed.
Always run
| Agent | Model | Role | |
|---|---|---|---|
| code-reviewer | sonnet | Quality, dead code, duplication, logic |
Conditional — trigger security-reviewer (opus) if ANY match
| Category | Trigger | |
|---|---|---|
| API routes | src/app/api/**, **/routes/**, **/controllers/** | |
| Auth & access control | **/auth*, **/middleware*, **/guard*, **/permission*, **/rbac*, **/acl* | |
| Secrets & config | **/.env*, **/config*, **/settings*, **/secrets* | |
| Infrastructure | Dockerfile, docker-compose*.yml, *.tf, nginx.conf, *.conf | |
| Dangerous patterns | diff contains child_process, exec(, spawn(, eval(, new Function(, dangerouslySetInnerHTML | |
| Sensitive filenames | filename contains secret, token, password, key, credential, cert, private | |
| Supply chain | package.json with new packages added |
Trigger database-reviewer (sonnet): prisma/**, **/migrations/**, **/db*, *.sql
Trigger refactor-cleaner (sonnet): 10+ files changed, or user explicitly requested refactoring
Agent prompt template (intent-passing — distinguish intentional decisions from mistakes to reduce false positives):
Review the following staged diff. Focus on changed lines.Only read full files if you need more context.<intent>[What the user aimed to achieve in this change — verbatim from session conversation. Goal + consciously made decisions/trade-offs + intentionally excluded scope. Not a diff explanation. Do not leave blank — blank intent causes reviewers to misclassify intentional choices as mistakes.]</intent><diff>[paste full output of: git diff --staged]</diff>The <intent> above is the user's goal. If diff deviates from intent, flag it — but do not flag conscious decisions or trade-offs explicitly stated in intent as mistakes.
Intent source: not a separate input — the pre-push executor agent (you) fills this from what you already know in this session conversation. If unsure, leave as "intent unclear", but note in Step 8 report that false positives are possible.
Step 7: Gate Check
| Severity | Action | |
|---|---|---|
| Critical / High | Fix before push. No exceptions. | |
| Medium | Fix if < 5 min. Otherwise add // TODO(security): comment and report. | |
| Low / Info | Report to user. Push allowed. |
Test file exceptions: Findings in **/__tests__/**, **/*.test.*, **/*.spec.*, or **/fixtures/** are likely test fixtures, not real secrets. Downgrade to Medium severity and note in report.
Three-state false-positive gate: for pattern classes prone to false positives (e.g. PII/name-like matches), don't force a binary hard-block/skip decision — present each hit to the user individually. If the user confirms it's a false positive or non-sensitive, record a one-line reason and let it through (stays auditable in the report). If it's real, require removal. Blanket bypass without a stated reason is not allowed. Low-false-positive hard-block classes (the 12 credential patterns) remain immediate-block as before — this gate does not soften those.
Fix loop (Critical/High found):
- Apply fixes with the Edit tool
- Re-run only the agent(s) that reported the issue
- Max 1 retry per agent
- Still failing → halt and report exact issue + file location to user
- Skip-reason logging: when a finding is knowingly left unfixed (superseded by a later feature change / genuinely out of scope / judged a false positive), record
SKIP_REASON: {finding} — {feature_change|out_of_scope|false_positive} — {one-line reason}in the Step 8 report. No silent skips without a stated reason.
Error Recovery: if Step 6 agents or Bash tools fail:
- Classify:
tool_failure - Apply: retry same agent once → still fail:
- code-reviewer (Always run): instead of an outright skip, fall back to an inline abbreviated review — the pre-push executor itself reads the staged diff once, applying code-reviewer's
--fast-mode bar (runtime bugs only, max 8 findings, no padding), and merges those findings into the Step 8 report. Note⚠️ INLINE_FALLBACK: code-reviewer subagent unavailable — replaced with inline abbreviated review. - Conditional agents (security/database/refactor): as before, report
⚠️ TOOL_FAILURE: {agent} — manual review neededand continue remaining steps. No silent failures. - Step 8 report that line: explicitly note
⚠️ SKIPPED (tool_failure — {agent})or⚠️ INLINE_FALLBACK (code-reviewer).
Parallel Conflict Resolution: if Step 6 agents report conflicting verdicts on the same file:
- security-reviewer Critical + code-reviewer Non-critical → Critical takes priority (weakest link principle).
- Multiple agents, same file, both Critical → sum-once (no double-counting).
- Complete conflict (one PASS, other Critical FAIL) → escalate to user. No arbitrary consensus.
Step 8: Report & Push
Include elapsed time next to each step result.
## Pre-Push Review SummaryBranch: <current> → originFiles: N | Diff: N lines | Total time: Xs- Secrets scan: ✅ CLEAN (Xs) / 🚨 CRITICAL (N findings — push BLOCKED)- Supply chain: ✅ No new deps / ⚠️ N new packages (listed below)- Build: ✅ PASS (Xs) / ❌ FAIL (Xs) / ➖ SKIPPED (no source changes)- Tests: ✅ PASS (Xs) / ⚠️ SKIPPED / ❌ FAIL (N failed)- Lint (direct): ✅ PASS (Xs) / ❌ FAIL (N errors) / ➖ SKIPPED (no linter found)- code-reviewer --quick: ✅ PASS (Xs) / ❌ FAIL (N issues) / ➖ SKIPPED (<50 lines)- code-reviewer: ✅ PASS / ⚠️ N issues (X fixed, Y remaining)- security-reviewer: ✅ PASS / ❌ N issues / ➖ NOT TRIGGERED- database-reviewer: ✅ PASS / ⚠️ N issues / ➖ NOT TRIGGERED- refactor-cleaner: ✅ PASS / ⚠️ N suggestions / ➖ NOT TRIGGERED[Supply chain — new packages listed here if applicable][Secrets remediation steps listed here if blocked]Overall: ✅ READY TO PUSH / ❌ BLOCKED — <reason>
Execute git push only when Overall = READY TO PUSH.
Once the pipeline ends (push succeeded or BLOCKED, either way), delete the persisted-variable file so a later push never reads this run's values (each step's key check is the first line of defense, this deletion is the second):
rm -f "$(git rev-parse --git-dir)/pre-push-vars.sh"
Memory Sync Reminder (only when READY TO PUSH)
If push target files include memory path changes:
💡 Memory Sync: Code has changed.If you haven't run session-checkpoint yet, memory may be stale.Consider running /session-checkpoint before ending this session.
Emergency Override
If user explicitly says "skip review" or "force push":
- Print:
⚠️ Pre-push pipeline bypassed by user request. Secrets scan and agent reviews were NOT run. - Execute
git pushimmediately.
Safety Layers
| Risky Action | Reversibility | Applied Layers | |
|---|---|---|---|
git push (regular branch) | low | L1+L2+L3 | |
git push (main/master) | low | L1+L2+L3+L4 | |
git push --force | low | L1+L2 (deny recommended) | |
| Secrets exposure (secrets scan FAIL) | none | L1+L2+L3+L4 (BLOCK) |
- L1 (Invariants): secrets scan must pass, Critical/High issues must be fixed.
- L2 (Tool Restriction): recommend deny
git push --force,--no-verifyinsettings.jsonhooks. - L3 (User Approval): main/master branches require explicit "yes" confirmation. Emergency Override only with "skip review"/"force push" explicit.
- L4 (Independent Verification): Step 6 review agents (code-reviewer/security-reviewer/etc) provide independent verification separate from implementation.
Session approval validity: Emergency Override is valid for that single push only. No "continue skipping" — re-approval required for each push.
Truthful Reporting
After pipeline execution:
- no mock deception: Report actual results per step. Unexecuted steps marked
➖ SKIPPED, reason noted. - no test façade: Don't trust test results blindly. If
pytest --passWithNoTestspasses, verify actual tests exist. - no silent brokenness: Final state is either
✅ READY TO PUSHor❌ BLOCKED. If⚠️ PARTIAL, state reason explicitly.
Rationalization Table
| Rationalization | Rebuttal | |
|---|---|---|
| "diff is small, no need to scan" | A 1-line diff can contain a single API key | |
| "already passed tests locally" | Local pass ≠ staged diff safe. Other files may be mixed into staging | |
| "only changed docs, so it's fine" | If SECRETS_EXIT=0 + docs-only, Step 2 auto fast-exits. Do not manually skip | |
| "secrets are okay because private repo" | Private repo offers no protection. All team members have access, git history is permanent | |
| "security reviewer trigger doesn't match, so I'll skip it manually" | If trigger not met, auto-skips as NOT TRIGGERED. Manual skip is a separate concern | |
| "urgent bug fix, okay to skip" | Urgency increases mistake likelihood. Secrets scan < 10 seconds |
Error Recovery
On failure: Stop → Classify → Apply Recovery → Report & Resume.
| Failure Type | Detection Condition | Recovery Path | |
|---|---|---|---|
tool_failure | Bash secrets scan / pytest execution fails | Note "check unable to run" → halt push. Never treat unexecuted check as passed | |
input_error | Push target branch/remote unclear | Re-confirm git status, clarify intent. Never push on assumption | |
missing_data | Secrets pattern file / pytest missing | Skip check + note ⚠️. If checks are missing, confirm with user before push | |
logic_inconsistency | pytest passed but lint failed | Push only after all checks pass. Do not treat partial pass as sufficient |
Invariants (never violate)
- Secrets scan always runs: if SECRETS_EXIT=1, push BLOCKED. Even "just push" requests cannot bypass without Emergency Override. Violation → credentials permanently in git history, entire remote repo contaminated.
- Protected branch check: main/master pushes require explicit "yes". Violation → unreviewed code flows directly to production branch.
- Critical/High blocks push: if agent review finds Critical/High, no push without fix. Medium: fix within 5 min or add TODO tag. Violation → known vulnerabilities deployed to remote.
- Added lines only scanned:
-(removed) lines are not scanned. Violation → secret removal commits get BLOCKED, cleanup becomes impossible.
- Re-scan after a block, don't eyeball it: once
SECRETS_EXIT=1has fired, only push after re-running Step 1's scan and gettingSECRETS_EXIT=0-- a manual "that's a false positive" judgment without a fresh scan run is not sufficient. Violation → a genuine finding gets waved through on a hurried visual check.
These rules are unconditional. Emergency Override applies only when user explicitly says "skip review" or "force push".
Scope Boundary
| Does | Does NOT | |
|---|---|---|
| [BASH] scan secrets in staged diff (added lines only) | Create or modify commits | |
| [BASH] run language-specific tests (changed files only) | Force entire test suite | |
| [AGENT] parallel agent reviews (code/security/db/refactor) | Modify code directly (except fix loop) | |
| [BASH] check protected branches (main/master) | Modify git history or rebase | |
| [BASH] execute push (all gates passed) | --force or --no-verify push |