Imported from AlexZio00/sovereign-skills (
pre-push/SKILL.md). Install upstream withnpx skills add AlexZio00/sovereign-skills --skill pre-push. Copyright stays with the author (MIT).
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 (nothing to commit)
*.md/docs/**changes only (fast-exit at Step 2, but agent review overhead unnecessary at this condition)
Key Assumptions
scan_secrets.plscript 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, gitignored state file (.harness/test-count-floor.json). 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 -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.
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 . || echo 0)
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_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"
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: If $STAGED_FILES is empty → inform the user and stop (per Discard If, this skill doesn't engage at all in that case). Note this means outgoing-commit scanning above only ever runs as a supplement to a staged-diff review, not as a standalone "just push what's already committed" path — that scenario remains a known Discard-If gap, unchanged by this fix.
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*. |
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_IOCor⚠️ MCP_POISONINGin the report.
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
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.
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
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.
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$")
Python — run when .py files changed and a test runner is configured:
if [ -n "$CHANGED_PY" ] && ([ -f "pyproject.toml" ] || [ -f "setup.py" ] || [ -f "requirements.txt" ]); then
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=".harness/test-count-floor.json"
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
fi
Go — run when .go files changed and go.mod exists:
if [ -n "$CHANGED_GO" ] && [ -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))
fi
JS/TS — build then test when source files changed:
if [ -f "package.json" ] && [ -n "$CHANGED_JS" ]; 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
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:
if [ -n "$CHANGED_PY" ]; then
# ${PIPESTATUS[0]} — not $? — is the linter's own exit code; $? here would be tail's
if command -v ruff >/dev/null 2>&1; then
timeout 30 ruff check $CHANGED_PY 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[0]}
elif command -v flake8 >/dev/null 2>&1; then
timeout 30 flake8 $CHANGED_PY 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[0]}
fi
fi
Go — go vet (always available):
if [ -n "$CHANGED_GO" ]; then
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:
if [ -n "$CHANGED_JS" ] && ls .eslintrc* eslint.config* 2>/dev/null | head -1 | grep -q .; then
timeout 30 npx eslint $CHANGED_JS 2>&1 | tail -20; ESLINT_EXIT=${PIPESTATUS[0]} # not $? — tail's exit code
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.
- 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
- 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.
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.
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 |