{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/secret-precommit",
  "version": "1.0.1",
  "name": "Secret Pre-commit Scanner",
  "description": "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.",
  "event": "git-pre-commit",
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": true,
  "side_effects": [
    "aborts git commit on secret detection",
    "exits non-zero in CI scan mode on detection"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "security",
    "secrets",
    "git",
    "pre-commit",
    "ci",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_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.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/secret-precommit.py — git pre-commit hook (and CI scanner).\n\nTwo modes:\n\n  1. Pre-commit (default):  scans the staged diff\n     (`git diff --cached -U0`). Aborts the commit on any match.\n\n  2. CI / range scan (`--ci --base BASE --head HEAD`):\n     scans the diff from BASE..HEAD. Same matcher; intended for\n     .github/workflows/secret-scan.yml.\n\nReads the canonical pattern set from hooks/patterns.json\n(+ patterns.local.json if present). Per SPEC.md §10.2 + §10.4.\n\nSelf-check:\n  --self-check    Loads patterns.json and compiles every regex.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\ndef staged_diff() -> str:\n    \"\"\"Return the staged diff with 0 lines of context.\"\"\"\n    result = subprocess.run(\n        [\"git\", \"diff\", \"--cached\", \"-U0\", \"--no-color\"],\n        capture_output=True, text=True, check=False,\n    )\n    if result.returncode != 0:\n        _lib.log(\"git diff --cached failed:\", result.stderr.strip())\n        return \"\"\n    return result.stdout\n\n\ndef range_diff(base: str, head: str) -> str:\n    \"\"\"Return the diff between two revs, 0 lines of context.\"\"\"\n    result = subprocess.run(\n        [\"git\", \"diff\", \"-U0\", \"--no-color\", f\"{base}...{head}\"],\n        capture_output=True, text=True, check=False,\n    )\n    if result.returncode != 0:\n        _lib.log(f\"git diff {base}...{head} failed:\", result.stderr.strip())\n        return \"\"\n    return result.stdout\n\n\ndef added_lines(diff: str):\n    \"\"\"Yield (file, lineno, content) for every '+'-prefixed line in a\n    unified diff (skipping the '+++ b/<file>' filename markers).\"\"\"\n    cur_file = None\n    cur_lineno = 0\n    for line in diff.splitlines():\n        if line.startswith(\"+++ \"):\n            cur_file = line[6:].strip() if line.startswith(\"+++ b/\") else line[4:].strip()\n            cur_lineno = 0\n            continue\n        if line.startswith(\"@@\"):\n            # Hunk header: \"@@ -a,b +c,d @@\"\n            try:\n                plus = line.split(\"+\", 1)[1].split(\" \", 1)[0]\n                cur_lineno = int(plus.split(\",\", 1)[0]) - 1\n            except (IndexError, ValueError):\n                cur_lineno = 0\n            continue\n        if line.startswith(\"+\") and not line.startswith(\"+++\"):\n            cur_lineno += 1\n            yield (cur_file, cur_lineno, line[1:])\n        elif not line.startswith(\"-\") and not line.startswith(\"\\\\\"):\n            cur_lineno += 1\n\n\ndef main(argv: list[str]) -> int:\n    parser = argparse.ArgumentParser(add_help=True)\n    parser.add_argument(\"--self-check\", action=\"store_true\")\n    # The command wrapper passes --mode=wrapper to every hook. This hook scans\n    # the staged diff regardless of mode, so the flag is accepted and ignored.\n    parser.add_argument(\"--mode\", choices=[\"claude\", \"wrapper\"], default=None,\n                        help=\"invocation mode (set by the command wrapper)\")\n    parser.add_argument(\"--ci\", action=\"store_true\",\n                        help=\"CI mode: scan a diff range instead of staged diff\")\n    parser.add_argument(\"--base\", default=None,\n                        help=\"(CI) base ref\")\n    parser.add_argument(\"--head\", default=\"HEAD\",\n                        help=\"(CI) head ref\")\n    args = parser.parse_args(argv)\n\n    if args.self_check:\n        return _lib.self_check_ok()\n\n    if args.ci:\n        if not args.base:\n            _lib.log(\"--ci requires --base\")\n            return 2\n        diff = range_diff(args.base, args.head)\n    else:\n        diff = staged_diff()\n\n    if not diff.strip():\n        return 0\n\n    patterns = _lib.load_patterns()\n    findings = []\n    for file, lineno, content in added_lines(diff):\n        for entry in patterns:\n            for m in entry[\"_compiled\"].finditer(content):\n                findings.append({\n                    \"file\": file,\n                    \"line\": lineno,\n                    \"pattern\": entry[\"id\"],\n                    \"severity\": entry.get(\"severity\", \"medium\"),\n                    \"snippet\": _lib.redact_snippet(content, m.start(), m.end(),\n                                                  entry.get(\"redaction\", \"[REDACTED]\")),\n                })\n\n    if not findings:\n        return 0\n\n    _lib.log(f\"{len(findings)} secret-like match(es) in the diff. Aborting commit.\")\n    for f in findings[:25]:\n        _lib.log(\n            f\"  {f['file']}:{f['line']}  pattern={f['pattern']} severity={f['severity']}\"\n        )\n        _lib.log(f\"      {f['snippet']}\")\n    if len(findings) > 25:\n        _lib.log(f\"  ... and {len(findings) - 25} more\")\n    _lib.log(\"\")\n    _lib.log(\"Per Common.md §1.P4 (no secrets in artifacts; non-overridable).\")\n    _lib.log(\"To fix: remove the secret-shaped content, OR add an exception to\")\n    _lib.log(\"hooks/patterns.local.json if this is a false positive (and please\")\n    _lib.log(\"file a `finding` issue so the false-positive class can be tracked).\")\n    return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1:]))\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "security"
}