SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hooksecurityblockingstable

Secret Pre-commit Scanner

Git pre-commit hook and CI scanner that blocks commits containing secret-shaped strings. Two modes: (1) pre-commit — scans the staged diff (git diff --cached -U0) and aborts the commit on any match; (2) CI/range scan (--ci --base BASE --head HEAD) — scans the diff from BASE..HEAD for use in secret-scan.yml workflows. Reads canonical patterns from hooks/patterns.json.

id hook/secret-precommitv1.0.1by convergent-systems-key
Event
git-pre-commit
Trigger
always
Language
python
Side effects
  • aborts git commit on secret detection
  • exits non-zero in CI scan mode on detection
Platforms
linuxmacoswindows
Notes
Logic is cross-platform. Wiring: use 'ai hooks run secret-precommit' in settings.json — the ai binary discovers Python on each OS. Calls subprocess git diff — git is available cross-platform. Pre-commit mode uses 'exec python3' in the shim; Windows users should use 'ai hooks run' instead.
Depends on
hook/lib

Script · secret-precommit.py

#!/usr/bin/env python3
"""hooks/secret-precommit.py — git pre-commit hook (and CI scanner).

Two modes:

  1. Pre-commit (default):  scans the staged diff
     (`git diff --cached -U0`). Aborts the commit on any match.

  2. CI / range scan (`--ci --base BASE --head HEAD`):
     scans the diff from BASE..HEAD. Same matcher; intended for
     .github/workflows/secret-scan.yml.

Reads the canonical pattern set from hooks/patterns.json
(+ patterns.local.json if present). Per SPEC.md §10.2 + §10.4.

Self-check:
  --self-check    Loads patterns.json and compiles every regex.
"""
from __future__ import annotations

import argparse
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402


def staged_diff() -> str:
    """Return the staged diff with 0 lines of context."""
    result = subprocess.run(
        ["git", "diff", "--cached", "-U0", "--no-color"],
        capture_output=True, text=True, check=False,
    )
    if result.returncode != 0:
        _lib.log("git diff --cached failed:", result.stderr.strip())
        return ""
    return result.stdout


def range_diff(base: str, head: str) -> str:
    """Return the diff between two revs, 0 lines of context."""
    result = subprocess.run(
        ["git", "diff", "-U0", "--no-color", f"{base}...{head}"],
        capture_output=True, text=True, check=False,
    )
    if result.returncode != 0:
        _lib.log(f"git diff {base}...{head} failed:", result.stderr.strip())
        return ""
    return result.stdout


def added_lines(diff: str):
    """Yield (file, lineno, content) for every '+'-prefixed line in a
    unified diff (skipping the '+++ b/<file>' filename markers)."""
    cur_file = None
    cur_lineno = 0
    for line in diff.splitlines():
        if line.startswith("+++ "):
            cur_file = line[6:].strip() if line.startswith("+++ b/") else line[4:].strip()
            cur_lineno = 0
            continue
        if line.startswith("@@"):
            # Hunk header: "@@ -a,b +c,d @@"
            try:
                plus = line.split("+", 1)[1].split(" ", 1)[0]
                cur_lineno = int(plus.split(",", 1)[0]) - 1
            except (IndexError, ValueError):
                cur_lineno = 0
            continue
        if line.startswith("+") and not line.startswith("+++"):
            cur_lineno += 1
            yield (cur_file, cur_lineno, line[1:])
        elif not line.startswith("-") and not line.startswith("\\"):
            cur_lineno += 1


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument("--self-check", action="store_true")
    # The command wrapper passes --mode=wrapper to every hook. This hook scans
    # the staged diff regardless of mode, so the flag is accepted and ignored.
    parser.add_argument("--mode", choices=["claude", "wrapper"], default=None,
                        help="invocation mode (set by the command wrapper)")
    parser.add_argument("--ci", action="store_true",
                        help="CI mode: scan a diff range instead of staged diff")
    parser.add_argument("--base", default=None,
                        help="(CI) base ref")
    parser.add_argument("--head", default="HEAD",
                        help="(CI) head ref")
    args = parser.parse_args(argv)

    if args.self_check:
        return _lib.self_check_ok()

    if args.ci:
        if not args.base:
            _lib.log("--ci requires --base")
            return 2
        diff = range_diff(args.base, args.head)
    else:
        diff = staged_diff()

    if not diff.strip():
        return 0

    patterns = _lib.load_patterns()
    findings = []
    for file, lineno, content in added_lines(diff):
        for entry in patterns:
            for m in entry["_compiled"].finditer(content):
                findings.append({
                    "file": file,
                    "line": lineno,
                    "pattern": entry["id"],
                    "severity": entry.get("severity", "medium"),
                    "snippet": _lib.redact_snippet(content, m.start(), m.end(),
                                                  entry.get("redaction", "[REDACTED]")),
                })

    if not findings:
        return 0

    _lib.log(f"{len(findings)} secret-like match(es) in the diff. Aborting commit.")
    for f in findings[:25]:
        _lib.log(
            f"  {f['file']}:{f['line']}  pattern={f['pattern']} severity={f['severity']}"
        )
        _lib.log(f"      {f['snippet']}")
    if len(findings) > 25:
        _lib.log(f"  ... and {len(findings) - 25} more")
    _lib.log("")
    _lib.log("Per Common.md §1.P4 (no secrets in artifacts; non-overridable).")
    _lib.log("To fix: remove the secret-shaped content, OR add an exception to")
    _lib.log("hooks/patterns.local.json if this is a false positive (and please")
    _lib.log("file a `finding` issue so the false-positive class can be tracked).")
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
securitysecretsgitpre-commitciclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.