Claude Skill

push-gate

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_push-gate-3dfaf0b.zip · 14 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/push-gate
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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. Confirmed-safe findings go into the committed repo-local allowlists (.gitleaksignore for gitleaks, .pushgate-allow for the regex layer — see §False-positive handling), never an inline override. Real secrets 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  ────────┐  gitleaks honours .gitleaksignore,
                                │  regex layer honours .pushgate-allow
Step 7  →  Forbidden-file scan  │ refuse on any hit
Step 8  →  Size advisory        │
        →  Open-issue advisory (github-ops; informational, never gates)
Step 9  →  Explicit confirm     │
Step 10 →  git push <remote> <branch>
Step 11 →  Post-push verify (ls-remote matches pushed SHA)

Invocation

# From the repo root (most common)
bash .claude/skills/push-gate/scripts/preflight.sh <remote> <branch>

# When calling from another skill with a different cwd (e.g. github-ops)
bash $HOME/.claude/skills/push-gate/scripts/preflight.sh --cwd <repo-root> <remote> <branch>

--cwd must precede the positional arguments. When omitted, the script operates against $PWD.

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, .claude/settings.local.json, 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. Gitleaks runs with references/gitleaks-config.toml (default rule set + an allowlist for public-by-design tokens, e.g. Mapbox pk.*); if that file is absent, the scan falls back to gitleaks' built-in default config.

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

Both secret layers have a repo-local, committed allowlist. The gate still refuses any hit not explicitly allowed, and the skill will not offer an inline bypass — a confirmed-safe finding earns a reviewed entry in the repo, not a one-off override.

Layer Allowlist file (repo root) Entry format
gitleaks .gitleaksignore gitleaks fingerprint (commit:file:rule:line)
regex .pushgate-allow <repo-relative-path>:<line-regex>

The regex layer also drops common false positives automatically (env-var references, shell fallbacks, placeholders with ...) before the allowlist is consulted.

.pushgate-allow rules:

  • One entry per line; # comments allowed. Each entry must carry a reason comment directly above it — the scanner warns when one is missing.
  • Entries split on the first : — a repo-relative path (forward slashes, no : in the path), then a Rust-regex matched against the added line's full content. No line numbers anywhere: they drift on every edit above them; a content anchor does not.
  • An entry suppresses hits only in that exact file. Every other hit still refuses.
  • On refusal, the scanner prints a ready-made anchored entry per hit — copy it under a reason comment, commit, and re-run the gate.
  • Entries whose file is gone, or whose regex no longer matches any line of that file at the branch tip, are reported as stale (warning, non-gating) — prune them.
  • The gate's clean-tree rule (hard rule 5) means the file is always committed by the time it is consulted — an uncommitted allowlist edit fails Step 3 before it can suppress anything.

Example:

# reason: test fixture — wire-snapshot key exercised by MCP wire tests, not a credential
packages/mcp/test/client.test.ts:^  apiKey: "wire-snapshot-key",$

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
references/gitleaks-config.toml Gitleaks config: default rules + public-token allowlist (used by scan-secrets.sh when present)
tests/run.sh Offline behavioural self-test for the secret scanner (planted secrets, FP filter, allowlist, stale entries)
assets/ (empty; reserved for future report templates)

Per-repo (not shipped with the skill): .gitleaksignore and .pushgate-allow, committed at the scanned repo's root — see §False-positive handling.

Files (claude-mods)
  • references
    • gitleaks-config.toml 761 B
      # push-gate gitleaks config — the full built-in rule set PLUS an allowlist for
      # tokens that are PUBLIC BY DESIGN and therefore not secrets. Loaded via --config
      # from scan-secrets.sh. `useDefault = true` keeps every built-in gitleaks rule
      # active; the allowlist only carves out known-public token shapes.
      [extend]
      useDefault = true
      
      # Mapbox public client tokens (pk.*) are embedded in the page source of every
      # Mapbox GL site and are exposed in-browser by design — they are not secrets.
      # Secret tokens (sk.*) are deliberately NOT allowlisted and remain flagged.
      [[allowlists]]
      description = "Mapbox public pk.* client tokens (public by design; sk.* still flagged)"
      regexTarget = "secret"
      regexes = ['''pk\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}''']
      
    • secret-patterns.txt 1.7 KB
      # 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,}$
      
  • scripts
    • preflight.sh 7.6 KB
      #!/usr/bin/env bash
      # preflight.sh — Full pre-push gate orchestration.
      #
      # Usage:   preflight.sh [--cwd <repo-root>] <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)
      #
      # After all gates pass, an advisory open-issue check (github-ops/check-issues.sh)
      # surfaces unseen external/stale issues for the target remote. It is read-only,
      # timeout-bounded, and NEVER changes the exit code — purely informational.
      
      set -euo pipefail
      
      # Optional --cwd <path> must come before positional args
      REPO_ROOT=""
      if [ "${1:-}" = "--cwd" ]; then
        REPO_ROOT="${2:?"push-gate: --cwd requires a path argument"}"
        shift 2
      fi
      
      REMOTE="${1:-}"
      BRANCH="${2:-}"
      
      SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      
      if [ -z "$REMOTE" ] || [ -z "$BRANCH" ]; then
        echo "push-gate: usage: preflight.sh [--cwd <repo-root>] <remote> <branch>" >&2
        exit 6
      fi
      
      if [ -n "$REPO_ROOT" ]; then
        cd "$REPO_ROOT"
      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" | sed -n '1,20p' | 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 -20 "$RANGE" | 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/|(^|/)\.claude/settings\.local\.json$|(^|/)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
      
      # ── Step 9: open-issue advisory (informational; does NOT gate) ────────────────
      # Surface externally-authored / stale issues you may not have seen, for the remote
      # you're pushing to. Read-only, timeout-bounded, silent when gh is absent/unauthed
      # or the remote isn't GitHub. Its exit never affects the gate verdict.
      ISSUE_CHECK="$SCRIPT_DIR/../../github-ops/scripts/check-issues.sh"
      if [ -f "$ISSUE_CHECK" ]; then
        issue_rc=0
        bash "$ISSUE_CHECK" --advisory --remote "$REMOTE" || issue_rc=$?
        if [ "$issue_rc" -eq 10 ]; then
          echo "ISSUES  NOTE  open issues flagged above (advisory — does not block the push)"
        else
          echo "ISSUES  OK    no unseen open issues (or check unavailable)"
        fi
      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
      
    • scan-secrets.sh 11.6 KB
      #!/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 ─────────────────────────────────────────────────────────────
      # Two cases:
      #   (a) origin/<branch> exists  → diff range scan (incremental push)
      #   (b) origin/<branch> missing → full branch scan (first push to new remote)
      # The well-known empty-tree SHA lets us express "everything as added" for the
      # regex layer's diff-based extraction without special-casing its plumbing.
      EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
      
      if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
        RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
        GITLEAKS_LOG_OPTS="$RANGE"
        DIFF_RANGE="$RANGE"
        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
        SCAN_LABEL="${COMMIT_COUNT} commits via gitleaks (${RANGE})"
      else
        COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
        if [ "$COMMIT_COUNT" -eq 0 ]; then
          echo "push-gate: branch ${BRANCH} has no commits."
          exit 0
        fi
        GITLEAKS_LOG_OPTS="$BRANCH"
        DIFF_RANGE="${EMPTY_TREE}..${BRANCH}"
        SCAN_LABEL="full branch — ${COMMIT_COUNT} commits via gitleaks (first push to new remote)"
      fi
      
      # ── Layer 1: gitleaks on the commit range ─────────────────────────────────────
      echo "push-gate: scanning ${SCAN_LABEL}"
      GITLEAKS_REPORT="$(mktemp -t gitleaks.XXXXXX.json)"
      DIFF_FILE="" ADDED_FILE="" PATHS_FILE=""
      trap 'rm -f "$GITLEAKS_REPORT" "$DIFF_FILE" "$ADDED_FILE" "$PATHS_FILE" 2>/dev/null || true' EXIT
      
      # Config: default rule set + allowlist for public-by-design tokens (e.g. Mapbox pk.*).
      # Guarded so push-gate still runs with the built-in default config if it's absent.
      GL_PUBTOKEN_CFG="$SCRIPT_DIR/../references/gitleaks-config.toml"
      GL_CONFIG_ARG=()
      [ -f "$GL_PUBTOKEN_CFG" ] && GL_CONFIG_ARG=(--config "$GL_PUBTOKEN_CFG")
      
      GITLEAKS_EXIT=0
      gitleaks detect \
        --source . \
        "${GL_CONFIG_ARG[@]}" \
        --log-opts="$GITLEAKS_LOG_OPTS" \
        --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)"
      # Exclude push-gate's own pattern corpus — it contains examples of every
      # secret shape it's trying to detect, so scanning it matches everything.
      # (Classic snake-eating-tail when push-gate is part of the pushed content.)
      # Same for .pushgate-allow: its entries are regexes of confirmed-safe hits,
      # which by construction resemble the shapes the corpus matches. Only the
      # regex pass skips it — the gitleaks layer still scans the allowlist file.
      git diff "$DIFF_RANGE" -- . \
        ':(exclude,glob)**/push-gate/references/secret-patterns.txt' \
        ':(exclude,top).pushgate-allow' \
        > "$DIFF_FILE"
      
      # Extract added lines, keeping per-line file attribution: ADDED_FILE holds the
      # content ('+' stripped, trailing CR dropped for CRLF checkouts), PATHS_FILE the
      # repo-relative path each line was added to — same line count, same order.
      # Attribution is what lets .pushgate-allow scope an allow to one file instead
      # of the whole diff.
      ADDED_FILE="$(mktemp -t push-gate-added.XXXXXX)"
      PATHS_FILE="$(mktemp -t push-gate-paths.XXXXXX)"
      awk -v added="$ADDED_FILE" -v paths="$PATHS_FILE" '
        /^\+\+\+ / {
          p = substr($0, 5)
          gsub(/^"|"$/, "", p)          # git quotes paths containing special chars
          sub(/^b\//, "", p)
          path = (p == "/dev/null") ? "" : p
          next
        }
        /^\+/ {
          l = substr($0, 2)
          sub(/\r$/, "", l)
          print l > added
          print path > paths
        }
      ' "$DIFF_FILE"
      
      # 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; line numbers index into PATHS_FILE
      RAW_HITS="$(rg --no-filename --line-number --no-heading "${PATTERN_ARGS[@]}" "$ADDED_FILE" 2>/dev/null || true)"
      
      # Common false positives, filtered before the allowlist is consulted.
      # Note: the `\.\.\.'` ellipsis-apostrophe patterns were removed because they
      # required an embedded `'` inside a bash single-quoted string, which closes
      # the string early and breaks the regex ("Unmatched ( or \("). The remaining
      # patterns (placeholder/example/getenv/etc) cover the bulk of false positives.
      FP_FILTER='(example|placeholder|\<dummy\>|\<fake\>|\<TODO\>|<unset>|os\.environ|process\.env|getenv|\$\{[A-Z_]+:-|\$\{[A-Z_]+\}|\$\([A-Z_]+\)|\$env:[A-Z_]+|\.\.\.<|pk\.eyJ[A-Za-z0-9_-]{6,})'
      
      # ── Repo-local allowlist (.pushgate-allow) ────────────────────────────────────
      # Committed at the scanned repo's root, mirroring gitleaks' .gitleaksignore.
      # Entry format (one per line):   <repo-relative-path>:<line-regex>
      #   - split on the FIRST ':' — the path portion must not contain ':' (git's
      #     repo-relative paths never do on Windows; avoid them elsewhere)
      #   - <line-regex> is Rust-regex matched against the full added-line content;
      #     no line numbers anywhere — they drift, a content anchor does not
      #   - '#' comment lines allowed; each entry must carry a reason comment
      #     directly above it (warned when missing, not gated)
      # An entry only suppresses regex-layer hits in that exact file. Any hit NOT
      # allowlisted still refuses. Entries that no longer match any line of their
      # file at the branch tip are reported as stale (warning, non-gating).
      TOPLEVEL="$(git rev-parse --show-toplevel)"
      ALLOW_FILE="$TOPLEVEL/.pushgate-allow"
      ALLOW_PATHS=()
      ALLOW_REGEXES=()
      if [ -f "$ALLOW_FILE" ]; then
        prev_comment=0
        allow_lineno=0
        while IFS= read -r al || [ -n "$al" ]; do
          allow_lineno=$((allow_lineno + 1))
          al="${al%$'\r'}"
          case "$al" in
            '') continue ;;
            \#*) prev_comment=1; continue ;;
          esac
          case "$al" in
            *:*) : ;;
            *)
              echo "push-gate: WARN .pushgate-allow:${allow_lineno} malformed (want <path>:<regex>): $al" >&2
              prev_comment=0
              continue
              ;;
          esac
          if [ "$prev_comment" -eq 0 ]; then
            echo "push-gate: WARN .pushgate-allow:${allow_lineno} entry has no reason comment above it: ${al%%:*}" >&2
          fi
          prev_comment=0
          ALLOW_PATHS+=("${al%%:*}")
          ALLOW_REGEXES+=("${al#*:}")
        done < "$ALLOW_FILE"
      
        # Stale-entry check against the branch tip (not just this diff): an entry
        # whose file is gone, or whose regex matches no line of that file anymore,
        # documents a hit that was since removed — prune it. A malformed regex also
        # lands here (rg errors are treated as no-match).
        for i in "${!ALLOW_PATHS[@]}"; do
          ap="${ALLOW_PATHS[$i]}"; ar="${ALLOW_REGEXES[$i]}"
          if ! git cat-file -e "${BRANCH}:${ap}" 2>/dev/null; then
            echo "push-gate: WARN stale .pushgate-allow entry — ${ap} does not exist at ${BRANCH} tip"
          elif ! git show "${BRANCH}:${ap}" 2>/dev/null | rg -e "$ar" >/dev/null 2>&1; then
            echo "push-gate: WARN stale .pushgate-allow entry — no line in ${ap} matches: ${ar}"
          fi
        done
      fi
      
      # ── Per-hit verdicts: FP filter → allowlist → refuse ─────────────────────────
      FILTERED_HITS=""
      SUGGESTIONS=""
      if [ -n "$RAW_HITS" ]; then
        while IFS= read -r hit; do
          [ -z "$hit" ] && continue
          n="${hit%%:*}"
          content="${hit#*:}"
          if grep -qiE "$FP_FILTER" <<<"$content"; then
            continue
          fi
          hit_path="$(sed -n "${n}p" "$PATHS_FILE")"
          allowed=0
          for i in "${!ALLOW_PATHS[@]}"; do
            if [ "$hit_path" = "${ALLOW_PATHS[$i]}" ] \
               && rg -e "${ALLOW_REGEXES[$i]}" >/dev/null 2>&1 <<<"$content"; then
              allowed=1
              break
            fi
          done
          [ "$allowed" -eq 1 ] && continue
          FILTERED_HITS+="${hit_path}: ${content}"$'\n'
          # Ready-made anchored allowlist entry: escape Rust-regex metacharacters in
          # the line content so the suggestion matches it literally and exactly.
          esc="$(sed 's/[][\\.^$*+?(){}|]/\\&/g' <<<"$content")"
          SUGGESTIONS+="  ${hit_path}:^${esc}"'$'$'\n'
        done <<<"$RAW_HITS"
      fi
      
      rm -f "$ADDED_FILE" "$PATHS_FILE" "$DIFF_FILE"
      
      if [ -n "$FILTERED_HITS" ]; then
        echo ""
        echo "═══════════════════════════════════════════════════════════════"
        echo "  SECRET-PATTERN MATCH (regex layer)"
        echo "═══════════════════════════════════════════════════════════════"
        printf '%s' "$FILTERED_HITS" | head -40
        echo ""
        echo "Refusing push. These are added lines matching secret-shape patterns."
        echo "If a hit is a REAL secret: rotate it now, then rewrite history."
        echo "If a hit is confirmed safe (test fixture, deliberate example), add an"
        echo "entry to .pushgate-allow at the repo root with a reason comment above"
        echo "it, commit, and re-run push-gate. Ready-made entries for these hits:"
        echo ""
        echo "  # reason: <why this line is not a live credential>"
        printf '%s' "$SUGGESTIONS" | awk '!seen[$0]++'
        echo ""
        echo "See SKILL.md §False-positive handling."
        exit 1
      fi
      
      echo "push-gate: secret scan CLEAN (gitleaks + regex layer)"
      exit 0
      
  • tests
    • run.sh 7.1 KB
      #!/usr/bin/env bash
      # Behavioural self-test for push-gate's secret scanner. Fully offline.
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      SOURCE_SCANNER="$SKILL/scripts/scan-secrets.sh"
      SCANNER="$SOURCE_SCANNER"
      
      SB="$(mktemp -d)"
      trap 'rm -rf "$SB"' EXIT
      PASS=0
      FAIL=0
      SECRETS_CAUGHT=0
      
      # Use a byte-identical scanner in a temporary skill mirror, normalising only the
      # regex corpus so Git-for-Windows CRLF checkouts behave like Ubuntu CI.
      mkdir -p "$SB/skill/scripts" "$SB/skill/references"
      cp "$SOURCE_SCANNER" "$SB/skill/scripts/scan-secrets.sh"
      cp "$SKILL/references/secret-patterns.txt" "$SB/skill/references/secret-patterns.txt"
      sed -i 's/\r$//' "$SB/skill/references/secret-patterns.txt"
      cp "$SKILL/references/gitleaks-config.toml" "$SB/skill/references/gitleaks-config.toml"
      SCANNER="$SB/skill/scripts/scan-secrets.sh"
      
      ok() { PASS=$((PASS + 1)); printf '  PASS  %s\n' "$1"; }
      no() { FAIL=$((FAIL + 1)); printf '  FAIL  %s\n' "$1"; }
      expect_exit() {
        if [[ "$2" == "$3" ]]; then ok "$1 (exit $3)"; else no "$1 (want $2 got $3)"; fi
      }
      expect_has() {
        case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac
      }
      
      mkdir -p "$SB/bin"
      cat > "$SB/bin/gitleaks" <<'EOF'
      #!/usr/bin/env bash
      exit 0
      EOF
      chmod +x "$SB/bin/gitleaks"
      STUB_PATH="$SB/bin:$PATH"
      
      new_repo() {
        local repo="$1"
        mkdir -p "$repo"
        git -C "$repo" init -q -b main
        git -C "$repo" config user.name push-gate-test
        git -C "$repo" config user.email push-gate-test@example.invalid
        printf '%s\n' 'baseline' > "$repo/README.md"
        git -C "$repo" add README.md
        git -C "$repo" commit -q -m 'test: baseline'
        git -C "$repo" update-ref refs/remotes/origin/main HEAD
      }
      
      commit_file() {
        local repo="$1" content="$2"
        printf '%s\n' "$content" > "$repo/candidate.txt"
        git -C "$repo" add candidate.txt
        git -C "$repo" commit -q -m 'test: add candidate'
      }
      
      scan_stubbed() {
        local repo="$1" output_file="$2"
        (cd "$repo" && TMPDIR="$SB" PATH="$STUB_PATH" bash "$SCANNER" origin main) >"$output_file" 2>&1
      }
      
      echo "=== push-gate behavioural self-test ==="
      
      echo "-- contract --"
      bash -n "$SCANNER" 2>/dev/null && ok "bash -n scan-secrets.sh" || no "bash -n scan-secrets.sh"
      
      echo "-- clean diff --"
      repo="$SB/clean"
      new_repo "$repo"
      commit_file "$repo" 'message = "ordinary configuration"'
      scan_stubbed "$repo" "$SB/clean.out"; rc=$?
      expect_exit "clean committed diff reports clean" 0 "$rc"
      expect_has "clean verdict is reported" "secret scan CLEAN" "$(cat "$SB/clean.out")"
      
      echo "-- planted secrets --"
      names=("AWS access key" "generic password" "private-key header" "high-entropy token")
      values=(
        "$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLZ')"
        "$(printf '%s%s%s' 'password = "correct-' 'horse-battery-staple' '"')"
        "$(printf '%s%s' '-----BEGIN RSA ' 'PRIVATE KEY-----')"
        "$(printf '%s%s' 'sk-' 'abcdefghijklmnopqrstuvwxyz0123456789')"
      )
      
      for i in "${!names[@]}"; do
        repo="$SB/secret-$i"
        new_repo "$repo"
        commit_file "$repo" "${values[$i]}"
        scan_stubbed "$repo" "$SB/secret-$i.out"; rc=$?
        if [[ "$rc" -eq 1 ]] && grep -q "SECRET-PATTERN MATCH" "$SB/secret-$i.out"; then
          SECRETS_CAUGHT=$((SECRETS_CAUGHT + 1))
          ok "${names[$i]} is caught by regex layer"
        else
          no "${names[$i]} is caught by regex layer (exit $rc)"
          sed 's/^/        /' "$SB/secret-$i.out"
        fi
      done
      
      echo "-- false-positive filter --"
      repo="$SB/false-positives"
      new_repo "$repo"
      printf '%s\n' \
        'password = os.environ["PW"]' \
        'password = "..."' \
        'password = "${PASSWORD:-change-me-now}"' > "$repo/candidate.txt"
      git -C "$repo" add candidate.txt
      git -C "$repo" commit -q -m 'test: add references'
      scan_stubbed "$repo" "$SB/false-positives.out"; rc=$?
      expect_exit "env reference, placeholder, and shell fallback stay clean" 0 "$rc"
      
      echo "-- repo-local allowlist (.pushgate-allow) --"
      secret_line="$(printf '%s%s' 'sk-' 'abcdefghijklmnopqrstuvwxyz0123456789')"
      
      # Allowlisted hit in the right file → clean
      repo="$SB/allow-hit"
      new_repo "$repo"
      printf '%s\n' \
        '# reason: test fixture token, not a live credential' \
        "candidate.txt:^${secret_line}\$" > "$repo/.pushgate-allow"
      printf '%s\n' "$secret_line" > "$repo/candidate.txt"
      git -C "$repo" add .pushgate-allow candidate.txt
      git -C "$repo" commit -q -m 'test: allowlisted fixture'
      scan_stubbed "$repo" "$SB/allow-hit.out"; rc=$?
      expect_exit "allowlisted hit reports clean" 0 "$rc"
      expect_has "allowlisted verdict is CLEAN" "secret scan CLEAN" "$(cat "$SB/allow-hit.out")"
      
      # Entry scoped to another file → hit still refuses, and a ready-made entry is suggested
      repo="$SB/allow-scope"
      new_repo "$repo"
      printf '%s\n' \
        '# reason: entry deliberately points at a different file' \
        "other.txt:^${secret_line}\$" > "$repo/.pushgate-allow"
      printf '%s\n' "$secret_line" > "$repo/candidate.txt"
      printf '%s\n' 'nothing to see' > "$repo/other.txt"
      git -C "$repo" add .pushgate-allow candidate.txt other.txt
      git -C "$repo" commit -q -m 'test: mis-scoped allowlist'
      scan_stubbed "$repo" "$SB/allow-scope.out"; rc=$?
      expect_exit "entry for another file does not suppress the hit" 1 "$rc"
      expect_has "refusal names the hit file" "candidate.txt: " "$(cat "$SB/allow-scope.out")"
      expect_has "refusal suggests a ready-made entry" "candidate.txt:^" "$(cat "$SB/allow-scope.out")"
      expect_has "mis-scoped entry is reported stale" "stale .pushgate-allow entry" "$(cat "$SB/allow-scope.out")"
      
      # Stale entry (file gone) on an otherwise clean push → clean exit + warning
      repo="$SB/allow-stale"
      new_repo "$repo"
      printf '%s\n' \
        '# reason: file was deleted after this entry was added' \
        'ghost.txt:^never-matches\$' > "$repo/.pushgate-allow"
      printf '%s\n' 'message = "ordinary configuration"' > "$repo/candidate.txt"
      git -C "$repo" add .pushgate-allow candidate.txt
      git -C "$repo" commit -q -m 'test: stale allowlist entry'
      scan_stubbed "$repo" "$SB/allow-stale.out"; rc=$?
      expect_exit "stale entry does not gate a clean push" 0 "$rc"
      expect_has "stale entry is warned about" "stale .pushgate-allow entry" "$(cat "$SB/allow-stale.out")"
      
      # Entry without a reason comment → warned, still honoured
      repo="$SB/allow-noreason"
      new_repo "$repo"
      printf '%s\n' "candidate.txt:^${secret_line}\$" > "$repo/.pushgate-allow"
      printf '%s\n' "$secret_line" > "$repo/candidate.txt"
      git -C "$repo" add .pushgate-allow candidate.txt
      git -C "$repo" commit -q -m 'test: entry without reason'
      scan_stubbed "$repo" "$SB/allow-noreason.out"; rc=$?
      expect_exit "comment-less entry still suppresses its hit" 0 "$rc"
      expect_has "missing reason comment is warned about" "no reason comment" "$(cat "$SB/allow-noreason.out")"
      
      echo "-- gitleaks integration --"
      if command -v gitleaks >/dev/null 2>&1; then
        repo="$SB/gitleaks"
        new_repo "$repo"
        commit_file "$repo" "$(printf '%s%s' 'ghp_' '9xQ7zRtY2wodFb3KpL8mN0aBcDeFgHiJkLmN')"
        (cd "$repo" && TMPDIR="$SB" bash "$SCANNER" origin main) >"$SB/gitleaks.out" 2>&1; rc=$?
        if [[ "$rc" -eq 1 ]] && grep -q "SECRET DETECTED (gitleaks)" "$SB/gitleaks.out"; then
          ok "known-bad blob is caught by installed gitleaks"
        else
          no "known-bad blob is caught by installed gitleaks (exit $rc)"
        fi
      else
        echo "  SKIP  gitleaks integration (gitleaks not installed)"
      fi
      
      echo ""
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]] || exit 1
      exit 0
      
  • SKILL.md 8 KB
    ---
    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. Confirmed-safe findings go into the committed repo-local allowlists (`.gitleaksignore` for gitleaks, `.pushgate-allow` for the regex layer — see §False-positive handling), never an inline override. Real secrets 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  ────────┐  gitleaks honours .gitleaksignore,
                                    │  regex layer honours .pushgate-allow
    Step 7  →  Forbidden-file scan  │ refuse on any hit
    Step 8  →  Size advisory        │
            →  Open-issue advisory (github-ops; informational, never gates)
    Step 9  →  Explicit confirm     │
    Step 10 →  git push <remote> <branch>
    Step 11 →  Post-push verify (ls-remote matches pushed SHA)
    ```
    
    ## Invocation
    
    ```bash
    # From the repo root (most common)
    bash .claude/skills/push-gate/scripts/preflight.sh <remote> <branch>
    
    # When calling from another skill with a different cwd (e.g. github-ops)
    bash $HOME/.claude/skills/push-gate/scripts/preflight.sh --cwd <repo-root> <remote> <branch>
    ```
    
    `--cwd` must precede the positional arguments. When omitted, the script operates against `$PWD`.
    
    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, `.claude/settings.local.json`, 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. Gitleaks runs with `references/gitleaks-config.toml` (default rule set + an allowlist for public-by-design tokens, e.g. Mapbox `pk.*`); if that file is absent, the scan falls back to gitleaks' built-in default config.
    
    ## 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
    
    Both secret layers have a repo-local, **committed** allowlist. The gate still refuses any hit not explicitly allowed, and the skill **will not** offer an inline bypass — a confirmed-safe finding earns a reviewed entry in the repo, not a one-off override.
    
    | Layer | Allowlist file (repo root) | Entry format |
    |---|---|---|
    | gitleaks | `.gitleaksignore` | gitleaks fingerprint (`commit:file:rule:line`) |
    | regex | `.pushgate-allow` | `<repo-relative-path>:<line-regex>` |
    
    The regex layer also drops common false positives automatically (env-var references, shell fallbacks, placeholders with `...`) before the allowlist is consulted.
    
    `.pushgate-allow` rules:
    
    - One entry per line; `#` comments allowed. **Each entry must carry a reason comment directly above it** — the scanner warns when one is missing.
    - Entries split on the **first** `:` — a repo-relative path (forward slashes, no `:` in the path), then a Rust-regex matched against the added line's full content. No line numbers anywhere: they drift on every edit above them; a content anchor does not.
    - An entry suppresses hits **only in that exact file**. Every other hit still refuses.
    - On refusal, the scanner prints a ready-made anchored entry per hit — copy it under a reason comment, commit, and re-run the gate.
    - Entries whose file is gone, or whose regex no longer matches any line of that file at the branch tip, are reported as **stale** (warning, non-gating) — prune them.
    - The gate's clean-tree rule (hard rule 5) means the file is always committed by the time it is consulted — an uncommitted allowlist edit fails Step 3 before it can suppress anything.
    
    Example:
    
    ```
    # reason: test fixture — wire-snapshot key exercised by MCP wire tests, not a credential
    packages/mcp/test/client.test.ts:^  apiKey: "wire-snapshot-key",$
    ```
    
    ## 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 |
    | `references/gitleaks-config.toml` | Gitleaks config: default rules + public-token allowlist (used by `scan-secrets.sh` when present) |
    | `tests/run.sh` | Offline behavioural self-test for the secret scanner (planted secrets, FP filter, allowlist, stale entries) |
    | `assets/` | (empty; reserved for future report templates) |
    
    Per-repo (not shipped with the skill): `.gitleaksignore` and `.pushgate-allow`, committed at the scanned repo's root — see §False-positive handling.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related