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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/push-gate
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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
- Gitleaks is a required dependency. If not installed, emit the install instructions and refuse. Do not silently fall back to regex-only.
- Any secret-scanner hit ⇒ refuse. No bypass flag. Confirmed-safe findings go into the committed repo-local allowlists (
.gitleaksignorefor gitleaks,.pushgate-allowfor the regex layer — see §False-positive handling), never an inline override. Real secrets force the user to rewrite history and re-invoke the gate. - Never
--forcepush. The gate never passes a force flag. If the user needs to force-push, that's a separate conversation with explicit authorization. - Never
--no-verify. Don't skip hooks. - Working tree must be clean. Refuse on dirty tree (uncommitted work could be accidentally stashed into the push flow).
- Remote must be named. Refuse if
git pushis 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-opsterritory. - 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.
Reviews (0)
No reviews yet.
No comments yet.