Browse Source

feat(skills): add push-gate — pre-push secret + safety gate

Gitleaks + regex-layer secret scan, forbidden-file check (.env, keys,
worktree paths), dirty-tree refuse, non-ff divergence refuse, size
advisory, and explicit confirm before `git push <remote> <branch>`.
Fails closed on any secret hit — no bypass flag.

Deps: gitleaks (required), ripgrep, git >= 2.30.

Skills total: 68 -> 69.
0xDarkMatter 1 month ago
parent
commit
e249bdd0b3

+ 2 - 2
README.md

@@ -12,13 +12,13 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 68 specialized skills, 13 output styles, 4 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 69 specialized skills, 13 output styles, 4 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**23 agents. 68 skills. 5 styles. 4 hooks. One install.**
+**23 agents. 69 skills. 5 styles. 4 hooks. One install.**
 
 ## Recent Updates
 

+ 99 - 0
skills/push-gate/SKILL.md

@@ -0,0 +1,99 @@
+---
+name: push-gate
+description: "Pre-push safety gate for any git push to a remote (GitHub, GitLab, Bitbucket, self-hosted). Runs gitleaks + regex-layer secret scan, forbidden-file check, divergence check, size warning, and requires explicit confirm before pushing. Refuses on any secret hit. Triggers on: push to origin, push to github, push to remote, git push, can we push, safe to push, ready to push, pre-push check, push-gate."
+license: MIT
+allowed-tools: "Read Bash Glob Grep"
+metadata:
+  author: claude-mods
+  related-skills: git-ops, security-ops
+---
+
+# Push Gate
+
+Formalised pre-push safety check. Runs before **every** `git push <remote>` where the remote is not a local file path. Refuses on secret hits; warns on size/forbidden-file; confirms intent before pushing.
+
+Use this skill whenever the user asks to push, or before Claude runs `git push` to any remote. Complements `git-ops` (which handles the push itself) — this is the gate that runs immediately before.
+
+## Hard rules
+
+1. **Gitleaks is a required dependency.** If not installed, emit the install instructions and refuse. Do not silently fall back to regex-only.
+2. **Any secret-scanner hit ⇒ refuse.** No bypass flag. Force the user to rewrite history and re-invoke the gate.
+3. **Never `--force` push.** The gate never passes a force flag. If the user needs to force-push, that's a separate conversation with explicit authorization.
+4. **Never `--no-verify`.** Don't skip hooks.
+5. **Working tree must be clean.** Refuse on dirty tree (uncommitted work could be accidentally stashed into the push flow).
+6. **Remote must be named.** Refuse if `git push` is called without an explicit remote and branch.
+
+## Workflow
+
+```
+Step 1  →  Identify remote + branch
+Step 2  →  git fetch <remote>
+Step 3  →  Verify working tree clean
+Step 4  →  Compute pending commits (count + list)
+Step 5  →  Check divergence (non-ff ⇒ require user to rebase first)
+Step 6  →  Secret scan  ────────┐
+Step 7  →  Forbidden-file scan  │ refuse on any hit
+Step 8  →  Size advisory        │
+Step 9  →  Explicit confirm     │
+Step 10 →  git push <remote> <branch>
+Step 11 →  Post-push verify (ls-remote matches pushed SHA)
+```
+
+## Invocation
+
+```bash
+bash .claude/skills/push-gate/scripts/preflight.sh <remote> <branch>
+```
+
+The script prints a structured report and exits with:
+
+| Exit code | Meaning | What Claude does |
+|---|---|---|
+| 0 | All gates passed; ready for push | Ask user to confirm, then `git push <remote> <branch>` |
+| 1 | Secret-scanner hit | Report to user; refuse; suggest `git filter-repo` / BFG |
+| 2 | Forbidden file added (.env, key files, worktree paths, etc.) | Report; refuse |
+| 3 | Dirty working tree | Report; ask user to commit or stash first |
+| 4 | Non-ff divergence | Report; ask user to rebase or merge first |
+| 5 | Missing dependency (gitleaks) | Report install instructions; refuse |
+| 6 | No remote specified / unknown remote | Report; ask for clarification |
+
+## Dependencies
+
+| Tool | Purpose | Install |
+|---|---|---|
+| **gitleaks** (required) | Secret detection with maintained rule corpus | Windows: `scoop install gitleaks` or `winget install gitleaks.gitleaks` / macOS: `brew install gitleaks` / Linux: `apt install gitleaks` or binary from https://github.com/gitleaks/gitleaks/releases |
+| **ripgrep** (required) | Regex fallback layer + forbidden-file scan | Usually pre-installed; `winget install BurntSushi.ripgrep.MSVC` / `brew install ripgrep` |
+| **git** ≥ 2.30 | Core operations | Standard |
+
+Both secret layers must pass: gitleaks detects known token formats with a maintained corpus; the regex layer catches generic `password = "..."` / DSN / connection-string patterns that gitleaks may miss. See `references/secret-patterns.txt` for the regex corpus.
+
+## Trigger phrases
+
+| User intent | Triggers |
+|---|---|
+| Direct | "push to origin", "push to github", "push to remote", "git push" |
+| Question | "can we push?", "safe to push?", "ready to push?" |
+| Explicit | `/push-gate`, "run push-gate" |
+
+Claude should invoke `scripts/preflight.sh` on any of these. Do not invoke on local pushes (`git push <path>` or `git push .`) — those are the `updateInstead` pattern for cross-worktree landings and don't leave the host.
+
+## False-positive handling
+
+The regex layer filters common false positives automatically (env-var references, shell fallbacks, placeholders with `...`). Gitleaks has its own `.gitleaksignore` file mechanism — add entries there for confirmed-safe findings, committed at repo root. The skill **will not** offer an inline bypass.
+
+## Not in scope
+
+- Release automation (changelog, tagging, version bumps) — that's `ci-cd-ops` / `git-ops` territory.
+- Full security audit — that's `security-ops` (broader SAST + dep scanning).
+- Force-push / history rewriting — intentionally excluded; requires explicit out-of-band authorization.
+- Signed-commit verification — add later if needed.
+
+## Files
+
+| File | Role |
+|---|---|
+| `SKILL.md` | This file — workflow + rules |
+| `scripts/preflight.sh` | Main orchestration (Steps 1–8) |
+| `scripts/scan-secrets.sh` | Gitleaks + regex layer (Step 6) |
+| `references/secret-patterns.txt` | Regex corpus + false-positive filter words |
+| `assets/` | (empty; reserved for future report templates) |

+ 42 - 0
skills/push-gate/references/secret-patterns.txt

@@ -0,0 +1,42 @@
+# Secret-pattern corpus for push-gate regex layer.
+#
+# Format: one regex per non-empty, non-comment line. Lines starting with `#`
+# are comments. The scan-secrets.sh script feeds these to ripgrep.
+#
+# Patterns use Rust regex syntax (ripgrep's default). Anchors are NOT added
+# automatically — write them yourself if needed. Match is against the diff
+# body (added lines only; prefix `^+` is stripped before match).
+
+# === API / OAuth tokens (vendor-specific) ===
+sk-[A-Za-z0-9_-]{20,}
+sk-ant-api[0-9]+-[A-Za-z0-9_-]{20,}
+sk-ant-oauth-[A-Za-z0-9_-]{20,}
+sk-proj-[A-Za-z0-9_-]{20,}
+ghp_[A-Za-z0-9]{36}
+gh[suor]_[A-Za-z0-9]{30,}
+github_pat_[A-Za-z0-9_]{20,}
+xox[baprs]-[0-9A-Za-z-]{10,}
+AKIA[0-9A-Z]{16}
+ASIA[0-9A-Z]{16}
+sk_live_[0-9a-zA-Z]{24,}
+rk_live_[0-9a-zA-Z]{24,}
+AIza[0-9A-Za-z_-]{35}
+ya29\.[0-9A-Za-z_-]+
+SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}
+(?i)dckr_pat_[A-Za-z0-9_-]{20,}
+npm_[A-Za-z0-9]{36}
+glpat-[0-9A-Za-z_-]{20}
+
+# === Cryptographic material ===
+-----BEGIN (RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |PGP PRIVATE KEY BLOCK)?PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+
+
+# === DSNs with embedded credentials ===
+(postgres|postgresql|mysql|mongodb|mongodb\+srv|redis|rediss|amqp|amqps|clickhouse)://[^:/"' ]+:[^@/"' ]{4,}@
+
+# === Generic (high false-positive; FP-filter applies) ===
+(?i)(password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token|oauth[_-]?token|bearer)\s*[=:]\s*["'][^"'\s]{12,}["']
+
+# === Env-file leakage (by content shape, not filename) ===
+(?i)^\s*(password|secret|token|api_key|apikey|access_key|private_key|client_secret|auth_token)\s*=\s*[^$\s<]{12,}$

+ 148 - 0
skills/push-gate/scripts/preflight.sh

@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+# preflight.sh — Full pre-push gate orchestration.
+#
+# Usage:   preflight.sh <remote> <branch>
+# Exit codes:
+#   0  all gates passed; ready to push
+#   1  secret hit (gitleaks or regex)
+#   2  forbidden file added
+#   3  dirty working tree
+#   4  non-ff divergence
+#   5  missing dep (gitleaks / rg)
+#   6  bad invocation (missing remote/branch or unknown remote)
+
+set -euo pipefail
+
+REMOTE="${1:-}"
+BRANCH="${2:-}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if [ -z "$REMOTE" ] || [ -z "$BRANCH" ]; then
+  echo "push-gate: usage: preflight.sh <remote> <branch>" >&2
+  exit 6
+fi
+
+divider() { printf '%.0s─' $(seq 1 63); echo; }
+
+echo "push-gate preflight :: target = ${REMOTE}/${BRANCH}"
+divider
+
+# ── Step 1–2: verify remote, fetch ────────────────────────────────────────────
+if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
+  echo "STEP 1  FAIL  remote '${REMOTE}' not configured"
+  echo "          configured remotes:"
+  git remote -v | sed 's/^/            /'
+  exit 6
+fi
+REMOTE_URL="$(git remote get-url "$REMOTE")"
+echo "STEP 1  OK    remote '${REMOTE}' = ${REMOTE_URL}"
+
+# Reject local-path remotes (use `git push . HEAD:main` pattern directly, no gate needed)
+case "$REMOTE_URL" in
+  /*|[A-Za-z]:*|\.*|file:*)
+    echo "STEP 1  INFO  '${REMOTE}' looks local-filesystem; push-gate is for network remotes"
+    echo "          proceeding anyway (you can skip the gate for local updateInstead pushes)"
+    ;;
+esac
+
+echo "STEP 2  RUN   git fetch ${REMOTE}"
+if ! git fetch "$REMOTE" "$BRANCH" 2>&1 | sed 's/^/          /'; then
+  echo "STEP 2  WARN  fetch failed; proceeding with cached ${REMOTE}/${BRANCH} ref"
+fi
+
+# ── Step 3: clean working tree ────────────────────────────────────────────────
+DIRTY="$(git status --porcelain)"
+if [ -n "$DIRTY" ]; then
+  echo "STEP 3  FAIL  working tree dirty:"
+  printf '%s\n' "$DIRTY" | head -20 | sed 's/^/            /'
+  exit 3
+fi
+echo "STEP 3  OK    working tree clean"
+
+# ── Step 4: pending commits ───────────────────────────────────────────────────
+if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
+  echo "STEP 4  INFO  ${REMOTE}/${BRANCH} does not exist yet (new remote branch)"
+  COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
+  echo "          ${COMMIT_COUNT} commits will be pushed (creating the remote branch)"
+else
+  RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
+  COMMIT_COUNT="$(git rev-list --count "$RANGE")"
+  echo "STEP 4  OK    ${COMMIT_COUNT} commits pending"
+  if [ "$COMMIT_COUNT" -eq 0 ]; then
+    echo "          nothing to push; exiting cleanly"
+    exit 0
+  fi
+  git log --oneline "$RANGE" | head -20 | sed 's/^/            /'
+  if [ "$COMMIT_COUNT" -gt 20 ]; then
+    echo "            … and $((COMMIT_COUNT - 20)) more"
+  fi
+
+  # ── Step 5: divergence ──────────────────────────────────────────────────────
+  BEHIND="$(git rev-list --count "${BRANCH}..${REMOTE}/${BRANCH}")"
+  if [ "$BEHIND" -gt 0 ]; then
+    echo "STEP 5  FAIL  non-ff: ${REMOTE}/${BRANCH} has ${BEHIND} commits not in local ${BRANCH}"
+    echo "          rebase or merge first: git fetch ${REMOTE} && git rebase ${REMOTE}/${BRANCH}"
+    exit 4
+  fi
+  echo "STEP 5  OK    clean fast-forward (local is strictly ahead)"
+fi
+
+divider
+
+# ── Step 6: secret scan ───────────────────────────────────────────────────────
+SCAN_EXIT=0
+bash "$SCRIPT_DIR/scan-secrets.sh" "$REMOTE" "$BRANCH" || SCAN_EXIT=$?
+if [ "$SCAN_EXIT" -ne 0 ]; then
+  echo "STEP 6  FAIL  secret scan (exit=$SCAN_EXIT)"
+  exit "$SCAN_EXIT"
+fi
+echo "STEP 6  OK    secret scan clean"
+
+# ── Step 7: forbidden files ───────────────────────────────────────────────────
+# Files that should never ship to a remote. Matched against added-file paths.
+# Gitignore-style patterns would be nicer; for now, a small explicit list.
+FORBIDDEN_REGEX='(^|/)\.env(\.|$)|(^|/)\.env\.(local|development|production|test)$|\.(pem|key|pfx|p12|asc|ppk|id_rsa|id_ed25519|id_ecdsa|id_dsa)$|(^|/)\.aws/credentials$|(^|/)\.ssh/(id_|config)|(^|/)\.claude/worktrees/|(^|/)secrets?\.(json|ya?ml|toml|ini)$'
+
+if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
+  ADDED_FILES="$(git diff --name-only --diff-filter=A "${REMOTE}/${BRANCH}..${BRANCH}")"
+else
+  ADDED_FILES="$(git ls-tree -r --name-only "$BRANCH")"
+fi
+
+FORBIDDEN_HITS="$(printf '%s\n' "$ADDED_FILES" | grep -iE "$FORBIDDEN_REGEX" || true)"
+if [ -n "$FORBIDDEN_HITS" ]; then
+  echo "STEP 7  FAIL  forbidden files in push:"
+  printf '%s\n' "$FORBIDDEN_HITS" | sed 's/^/            /'
+  echo "          if any are genuinely needed on the remote, remove them from"
+  echo "          the push (git rm --cached) or relax the FORBIDDEN_REGEX in"
+  echo "          scripts/preflight.sh — the default is intentionally strict."
+  exit 2
+fi
+echo "STEP 7  OK    no forbidden file paths"
+
+# ── Step 8: size advisory ─────────────────────────────────────────────────────
+DIFF_BYTES=0
+if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
+  DIFF_BYTES="$(git diff --stat="10000,10000,10000" "${REMOTE}/${BRANCH}..${BRANCH}" \
+    | tail -1 | awk '{print $4 + $6}' 2>/dev/null || echo 0)"
+fi
+
+if [ "$COMMIT_COUNT" -gt 50 ]; then
+  echo "STEP 8  WARN  ${COMMIT_COUNT} commits in one push (>50). Consider whether"
+  echo "          this should be split into logical pushes for reviewability."
+elif [ "$COMMIT_COUNT" -gt 10 ]; then
+  echo "STEP 8  INFO  ${COMMIT_COUNT} commits (moderate batch)"
+else
+  echo "STEP 8  OK    ${COMMIT_COUNT} commits"
+fi
+
+divider
+echo "push-gate: ALL GATES PASSED"
+echo ""
+echo "Ready to push:"
+echo "  git push ${REMOTE} ${BRANCH}"
+echo ""
+echo "push-gate does not execute the push itself. Run it explicitly to"
+echo "preserve 'two-human-steps' separation between gate and action."
+exit 0

+ 132 - 0
skills/push-gate/scripts/scan-secrets.sh

@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# scan-secrets.sh — Secret-scan a pending push diff via gitleaks + regex layer.
+#
+# Usage:   scan-secrets.sh <remote> <branch>
+# Exit:    0 clean, 1 secret hit, 5 missing dep
+
+set -euo pipefail
+
+REMOTE="${1:?usage: scan-secrets.sh <remote> <branch>}"
+BRANCH="${2:?usage: scan-secrets.sh <remote> <branch>}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PATTERNS_FILE="$SCRIPT_DIR/../references/secret-patterns.txt"
+
+# ── Dep check ─────────────────────────────────────────────────────────────────
+if ! command -v gitleaks >/dev/null 2>&1; then
+  cat >&2 <<'EOF'
+push-gate: gitleaks not installed.
+
+Install:
+  Windows (scoop):    scoop install gitleaks
+  Windows (winget):   winget install gitleaks.gitleaks
+  macOS:              brew install gitleaks
+  Linux (apt):        apt install gitleaks
+  Any platform:       https://github.com/gitleaks/gitleaks/releases
+EOF
+  exit 5
+fi
+
+if ! command -v rg >/dev/null 2>&1; then
+  echo "push-gate: ripgrep (rg) not installed. See https://github.com/BurntSushi/ripgrep" >&2
+  exit 5
+fi
+
+# ── Range to scan ─────────────────────────────────────────────────────────────
+RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
+
+if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
+  echo "push-gate: remote ref ${REMOTE}/${BRANCH} not found locally — did you fetch?" >&2
+  exit 5
+fi
+
+COMMIT_COUNT="$(git rev-list --count "$RANGE")"
+if [ "$COMMIT_COUNT" -eq 0 ]; then
+  echo "push-gate: nothing to push (${RANGE} is empty)."
+  exit 0
+fi
+
+# ── Layer 1: gitleaks on the commit range ─────────────────────────────────────
+echo "push-gate: scanning ${COMMIT_COUNT} commits via gitleaks (${RANGE})"
+GITLEAKS_REPORT="$(mktemp -t gitleaks.XXXXXX.json)"
+trap 'rm -f "$GITLEAKS_REPORT" "$DIFF_FILE" 2>/dev/null || true' EXIT
+
+GITLEAKS_EXIT=0
+gitleaks detect \
+  --source . \
+  --log-opts="$RANGE" \
+  --report-format=json \
+  --report-path="$GITLEAKS_REPORT" \
+  --redact \
+  --no-banner \
+  --exit-code=1 \
+  2>&1 || GITLEAKS_EXIT=$?
+
+if [ "$GITLEAKS_EXIT" -ne 0 ]; then
+  echo ""
+  echo "═══════════════════════════════════════════════════════════════"
+  echo "  SECRET DETECTED (gitleaks)"
+  echo "═══════════════════════════════════════════════════════════════"
+  if command -v jq >/dev/null 2>&1 && [ -s "$GITLEAKS_REPORT" ]; then
+    jq -r '.[] | "  \(.RuleID) in \(.File):\(.StartLine) — \(.Description)"' "$GITLEAKS_REPORT" 2>/dev/null \
+      || cat "$GITLEAKS_REPORT"
+  else
+    cat "$GITLEAKS_REPORT"
+  fi
+  echo ""
+  echo "Refusing push. Remediate via one of:"
+  echo "  1. If the secret is real: rotate it NOW, then rewrite history"
+  echo "     (git filter-repo, BFG, or reset + re-commit)."
+  echo "  2. If it is a false positive: add to .gitleaksignore at repo root"
+  echo "     and commit, then re-run push-gate."
+  exit 1
+fi
+
+# ── Layer 2: regex corpus on the diff ─────────────────────────────────────────
+echo "push-gate: regex layer on added lines"
+DIFF_FILE="$(mktemp -t push-gate-diff.XXXXXX)"
+git diff "$RANGE" > "$DIFF_FILE"
+
+# Extract added lines only (strip the leading '+'), ignore file-header lines
+ADDED_FILE="$(mktemp -t push-gate-added.XXXXXX)"
+grep -E '^\+' "$DIFF_FILE" | grep -vE '^\+\+\+ ' | sed 's/^+//' > "$ADDED_FILE" || true
+
+# Load patterns (skip blanks/comments)
+PATTERN_ARGS=()
+while IFS= read -r line; do
+  case "$line" in
+    ''|\#*) continue ;;
+    *) PATTERN_ARGS+=(-e "$line") ;;
+  esac
+done < "$PATTERNS_FILE"
+
+# Run ripgrep with all patterns; capture matches
+RAW_HITS="$(rg --no-filename --line-number --no-heading "${PATTERN_ARGS[@]}" "$ADDED_FILE" 2>/dev/null || true)"
+
+# Filter common false positives
+FILTERED_HITS="$(
+  printf '%s\n' "$RAW_HITS" \
+    | grep -viE '(example|placeholder|\<dummy\>|\<fake\>|\<TODO\>|<unset>|os\.environ|process\.env|getenv|\$\{[A-Z_]+:-|\$\{[A-Z_]+\}|\$\([A-Z_]+\)|\$env:[A-Z_]+|\.\.\.<|\.\.\.')|\.\.\.'\s*$)' \
+    || true
+)"
+
+# Drop blank lines
+FILTERED_HITS="$(printf '%s\n' "$FILTERED_HITS" | grep -v '^$' || true)"
+
+rm -f "$ADDED_FILE" "$DIFF_FILE"
+
+if [ -n "$FILTERED_HITS" ]; then
+  echo ""
+  echo "═══════════════════════════════════════════════════════════════"
+  echo "  SECRET-PATTERN MATCH (regex layer)"
+  echo "═══════════════════════════════════════════════════════════════"
+  printf '%s\n' "$FILTERED_HITS" | head -40
+  echo ""
+  echo "Refusing push. These are added lines matching secret-shape patterns."
+  echo "Each match must be confirmed safe (placeholder/reference) or redacted"
+  echo "via history rewrite. See SKILL.md §False-positive handling."
+  exit 1
+fi
+
+echo "push-gate: secret scan CLEAN (gitleaks + regex layer)"
+exit 0