<< All versions

Skill v1.0.0

currentAutomated scan100/100
alexzio00/sovereign-skills/pre-push
──Details
PublishedOctober 2, 2026 at 02:19 PM
Content Hashsha256:f310f35a20947553...
Git SHAd814a3a6188e
──Files
Files (1 file, 57.3 KB)
SKILL.md57.3 KBactive
SKILL.md · 788 lines · 57.3 KB

version: "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 after SECRETS_EXIT=0 is already known, not before scanning.

Key Assumptions

  1. `scan_secrets.pl` script accessible — if broken: secrets scan unavailable → push blocked.
  2. git staged files exist — if broken: inform user and halt.
  3. 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:

bash
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 -20
SMOKE_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)"
else
echo "➖ 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.)

bash
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 it
CURRENT_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" ]; then
OUTGOING_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))"
else
OUTGOING_DIFF=""
OUTGOING_COMMIT_COUNT=0
OUTGOING_MODE="unresolvable (no upstream, no origin/HEAD) — staged-only, degraded mode"
fi
COMBINED_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; then
SECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | python "$SCAN_SCRIPT_PY")
SECRETS_EXIT=$?
elif [ -n "$SCAN_SCRIPT_PL" ]; then
SECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | perl "$SCAN_SCRIPT_PL")
SECRETS_EXIT=$?
else
echo "🚨 No scanner found (scan_secrets.py or scan_secrets.pl) — secrets scan unavailable, push blocked"
SECRETS_EXIT=1
fi
SCAN_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:

FindingRemediation
Merge conflict markersResolve conflicts: git status to find files, fix markers, re-stage.
AWS Access KeyReplace with process.env.AWS_ACCESS_KEY_ID. If real key: rotate immediately at AWS IAM console.
Private keyMove to ~/.ssh/ or a secrets manager. Add path to .gitignore.
Connection string passwordUse 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 keyReplace with process.env.ANTHROPIC_API_KEY (or provider equivalent). Rotate if exposed.
Azure credentialReplace with Managed Identity or environment variable.
Dockerfile ENV secretUse --secret mount or ARG with external injection. Never hardcode in ENV.
Generic hardcoded credentialMove 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/*.yml in k8s/ or infra/, nginx.conf): flag any ENV, ARG, or environment sections for human review.
  • Python CVE scan (pip-audit): run when requirements.txt or pyproject.toml changed and pip-audit is installed. WARN only — never blocks.
  • 9-IOC Supply Chain + MCP Check: for newly added dependencies and MCP configuration, check 9 indicators of compromise:
  1. External install links — does the package README or setup script fetch from non-registry URLs?
  2. Obfuscated exfiltration — base64/hex-encoded strings in post-install scripts?
  3. Capability mismatch — does a "utility" package request network/filesystem access beyond its stated purpose?
  4. MCP hidden instructions — does an MCP tool description/parameter embed text meant to steer agent behavior?
  5. MCP Unicode deception — direction-override characters (e.g. U+202E) or homoglyphs in a tool name/description?
  6. MCP parameter injection — an executable command or URL embedded in a tool parameter default/enum?
  7. 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.
  8. Missing pinned version — warn when a new dependency has no pinned version (requests vs requests==2.32.3). Unpinned = supply-chain attack surface.
  9. Post-install hook — does setup.py/pyproject.toml's [tool.setuptools.cmdclass] or an npm postinstall script make network calls or write files?

Flag any match as ⚠️ SUPPLY_CHAIN_IOC or ⚠️ MCP_POISONING in the report.

bash
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 ]; then
echo "⏭️ Step 3 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
else
CHANGED_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 instead
AUDIT_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"
fi
fi
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.

bash
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 ]; then
echo "⏭️ Step 3.5 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
else
ORIGIN_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 assets
BINARY_FILES=$(git diff --staged --name-only --diff-filter=A | while read -r f; do
file --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 itself
echo "$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"
fi
fi

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.

bash
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 ]; then
echo "⏭️ Step 4 skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
else
CHANGED_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 too
fi

Python — run when .py files changed and a test runner is configured:

bash
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 ]; then
echo "⏭️ Python test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_PY" ]; then
echo "⚠️ 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 -20
TEST_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 tracked
if [ -n "$PASSED_COUNT" ]; then
if [ -f "$FLOOR_FILE" ]; then
LAST_COUNT=$(grep -oE '"count": *[0-9]+' "$FLOOR_FILE" | grep -oE '[0-9]+' | tail -1)
if [ -n "$LAST_COUNT" ] && [ "$LAST_COUNT" -gt 0 ]; then
DROP_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."
fi
fi
mkdir -p "$(dirname "$FLOOR_FILE")"
printf '{"count": %s, "updated": "%s"}\n' "$PASSED_COUNT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$FLOOR_FILE"
fi
else
echo "➖ Python test runner config not found -- skipping pytest"
fi

Go — run when .go files changed and go.mod exists:

bash
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 ]; then
echo "⏭️ Go test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_GO" ]; then
echo "⚠️ CHANGED_GO empty -- nothing to test"
elif [ -f "go.mod" ]; then
TEST_START=$(date +%s)
timeout 120 go test ./... 2>&1 | tail -20
GO_TEST_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not go test's
TEST_TIME=$(($(date +%s) - TEST_START))
else
echo "➖ go.mod not found -- skipping go test"
fi

JS/TS — build then test when source files changed:

bash
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 ]; then
echo "⏭️ JS/TS build&test step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_JS" ]; then
echo "⚠️ CHANGED_JS empty -- nothing to build/test"
elif [ -f "package.json" ]; then
BUILD_START=$(date +%s)
timeout 120 npm run build 2>&1 | tail -30
BUILD_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not the build's
BUILD_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; then
timeout 60 npm test -- --passWithNoTests 2>&1 | tail -20
JS_TEST_EXIT=${PIPESTATUS[0]} # not $? — that would capture tail's exit code, not the test run's
fi
else
echo "➖ 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:

bash
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 ]; then
echo "⏭️ Python lint step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_PY" ]; then
echo "⚠️ 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; then
printf '%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; then
printf '%s\n' "$CHANGED_PY" | xargs -d '\n' -r timeout 30 flake8 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[1]}
fi
fi

Go — go vet (always available):

bash
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 ]; then
echo "⏭️ Go vet step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_GO" ]; then
echo "⚠️ CHANGED_GO empty -- nothing to lint"
else
timeout 30 go vet ./... 2>&1 | tail -20; GO_VET_EXIT=${PIPESTATUS[0]} # not $? — tail's exit code
fi

JS/TS — eslint if config file present:

bash
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 ]; then
echo "⏭️ ESLint step skipped -- SECRETS_EXIT=${SECRETS_EXIT:-unset} (secrets scan failed/unknown, push already BLOCKED)"
elif [ -z "$CHANGED_JS" ]; then
echo "⚠️ 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 code
else
echo "➖ 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:

  1. Deterministic bundle split: group $STAGED_FILES by 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.
  2. 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.
  3. Coverage validation (deterministic): before dispatch, verify union of bundle files == all of $STAGED_FILES by count. Mismatch → add missing files to final bundle. Never dispatch without this check.
  4. 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.
  5. 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 example python "$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 finding CONFIRMED or UNVERIFIED-CLAIM. Keep UNVERIFIED-CLAIM findings 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.
  6. 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.
  7. Conditional agents (security/database/refactor): follow existing trigger rules, but only pass diff for bundles matching those triggers.
  8. 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

AgentModelRole
code-reviewersonnetQuality, dead code, duplication, logic

Conditional — trigger security-reviewer (opus) if ANY match

CategoryTrigger
API routessrc/app/api/**, **/routes/**, **/controllers/**
Auth & access control**/auth*, **/middleware*, **/guard*, **/permission*, **/rbac*, **/acl*
Secrets & config**/.env*, **/config*, **/settings*, **/secrets*
InfrastructureDockerfile, docker-compose*.yml, *.tf, nginx.conf, *.conf
Dangerous patternsdiff contains child_process, exec(, spawn(, eval(, new Function(, dangerouslySetInnerHTML
Sensitive filenamesfilename contains secret, token, password, key, credential, cert, private
Supply chainpackage.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

SeverityAction
Critical / HighFix before push. No exceptions.
MediumFix if < 5 min. Otherwise add // TODO(security): comment and report.
Low / InfoReport 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):

  1. Apply fixes with the Edit tool
  2. Re-run only the agent(s) that reported the issue
  3. Max 1 retry per agent
  4. Still failing → halt and report exact issue + file location to user
  5. 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 needed and 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 Summary
Branch: <current> → origin
Files: 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):

bash
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":

  1. Print: ⚠️ Pre-push pipeline bypassed by user request. Secrets scan and agent reviews were NOT run.
  2. Execute git push immediately.

Safety Layers

Risky ActionReversibilityApplied Layers
git push (regular branch)lowL1+L2+L3
git push (main/master)lowL1+L2+L3+L4
git push --forcelowL1+L2 (deny recommended)
Secrets exposure (secrets scan FAIL)noneL1+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-verify in settings.json hooks.
  • 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:

  1. no mock deception: Report actual results per step. Unexecuted steps marked ➖ SKIPPED, reason noted.
  2. no test façade: Don't trust test results blindly. If pytest --passWithNoTests passes, verify actual tests exist.
  3. no silent brokenness: Final state is either ✅ READY TO PUSH or ❌ BLOCKED. If ⚠️ PARTIAL, state reason explicitly.

Rationalization Table

RationalizationRebuttal
"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 TypeDetection ConditionRecovery Path
tool_failureBash secrets scan / pytest execution failsNote "check unable to run" → halt push. Never treat unexecuted check as passed
input_errorPush target branch/remote unclearRe-confirm git status, clarify intent. Never push on assumption
missing_dataSecrets pattern file / pytest missingSkip check + note ⚠️. If checks are missing, confirm with user before push
logic_inconsistencypytest passed but lint failedPush only after all checks pass. Do not treat partial pass as sufficient

Invariants (never violate)

  1. 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.
  1. Protected branch check: main/master pushes require explicit "yes". Violation → unreviewed code flows directly to production branch.
  1. 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.
  1. Added lines only scanned: - (removed) lines are not scanned. Violation → secret removal commits get BLOCKED, cleanup becomes impossible.
  1. Re-scan after a block, don't eyeball it: once SECRETS_EXIT=1 has fired, only push after re-running Step 1's scan and getting SECRETS_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

DoesDoes 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
All versions