# Pre-commit Security Gate — and the proof that it blocks

The cheapest moment to prevent a security problem is before the code exists. The second cheapest is the
moment it tries to leave the reader's machine. Everything after that costs about a hundred times more.

But the reader will not remember. Habits are for the days you remember, and security problems arrive on
the other days. **So this skill does not install a habit. It installs a gate** — and then it makes the
gate block, in front of them, once, so they have seen it work.

> **A gate that has never been seen to block is not a gate.** It is a config file nobody has tested, and
> the day it silently stops working will look exactly like every other day.

## Step 0 — Detect the stack and the ground truth

```bash
ls package.json requirements.txt pyproject.toml go.mod Cargo.toml pom.xml Gemfile 2>/dev/null
git rev-parse --is-inside-work-tree && git rev-parse --show-toplevel
git config core.hooksPath || echo "core.hooksPath: (unset — hooks live in .git/hooks, which is NOT versioned)"
ls -a .git/hooks/ .githooks/ .husky/ .pre-commit-config.yaml 2>/dev/null
```

```bash
node -e "const fs=require('fs');for(const p of ['.claude/settings.json','.claude/settings.local.json'])
  if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p,'utf8'));console.log(p,'hooks:',JSON.stringify(j.hooks||{},null,2));}"
```

Two facts decide the design:

1. **`.git/hooks/` is not versioned.** A hook installed there protects one clone on one machine and
   disappears the moment anyone else checks the repo out. Use `core.hooksPath` and commit the directory.
2. **The agent commits too.** A git hook catches the agent's `git commit` (it is still `git`), but only
   the Claude Code hook can catch it *before the tool call runs* and hand the reason back to the agent so
   it fixes the finding instead of retrying. Install both. They are not redundant; they fire at different
   moments and fail differently.

## Step 1 — The scanners. Verify them; do not quote from memory.

Two different jobs, and one tool cannot do both.

### Secrets — gitleaks

Current release **v8.30.1** (2026-03-21), https://github.com/gitleaks/gitleaks — fetched 2026-07-14.

**The flags most guides still print are deprecated.** `detect` and `protect` were deprecated in v8.19.0;
they still run but are hidden from `--help`, and they will bite you when they go. The current commands
are three scan *modes*:

| Old (deprecated) | Current |
|---|---|
| `gitleaks detect` | `gitleaks git` |
| `gitleaks detect --no-git -s .` | `gitleaks dir -s .` |
| `gitleaks protect --staged` | `git diff --cached \| gitleaks stdin` |

Exit codes: **0** = no leaks, **1** = leaks found (configurable with `--exit-code`), **126** = unknown
flag. That `1` is what makes it a gate.

```bash
gitleaks version || echo "not installed"
```

The staged-only scan — this is the one that goes in the hook, because it scans exactly what is about to
be committed and nothing else:

```bash
git diff --cached | gitleaks stdin --redact --no-banner
```

`--redact` matters: a hook that prints the secret it found has just written the secret into the reader's
terminal scrollback and their CI logs.

### Code — semgrep

https://docs.semgrep.dev/cli-reference — fetched 2026-07-14. Current subcommand is `semgrep scan`.

The flags that make it a gate:

| Flag | What it does |
|---|---|
| `--config auto` | Registry rules picked for the detected languages. `--config p/default` pins a curated set instead. |
| `--error` | **Exit 1 if there are findings.** Without this it exits 0 and your "gate" is a printer. |
| `--severity ERROR` | Report only the high-severity rules. A gate that blocks on style is a gate that gets disabled by Friday. |
| `--baseline-commit <sha>` | Only findings *not* already in that commit. This is how you turn it on for a repo that already has findings without demanding a cleanup first. |
| `--quiet` / `--json --output` | Machine-readable output for the report. |

Exit codes: **0** clean, **1** findings (with `--error`), **2** fatal error. Treat 2 as a block as well —
a scanner that crashed has not cleared anything.

```bash
semgrep --version || echo "not installed"
```

```bash
pipx install semgrep || brew install semgrep
```

Scan only the staged files, and time it — **the single biggest cause of a bypassed gate is a slow gate:**

```bash
git diff --cached --name-only --diff-filter=ACM > /tmp/staged.txt
time semgrep scan --config auto --severity ERROR --error --quiet $(cat /tmp/staged.txt | tr '\n' ' ')
```

If that takes more than a few seconds on a typical commit, narrow `--config` to a pinned ruleset. A gate
the reader disables is worth less than no gate, because they think they have one.

### Second opinion on secrets — trufflehog (optional, and worth it)

https://docs.trufflesecurity.com/pre-commit-hooks — fetched 2026-07-14. Its distinguishing feature is
that it **verifies** a candidate credential by calling the provider's API, so `--results=verified` gives
you findings that are known-live rather than known-shaped. That is what makes it worth a second pass on
push, where a false positive costs more.

```bash
trufflehog git file://. --since-commit HEAD --results=verified --fail --trust-local-git-config
```

`--fail` is what makes it exit non-zero. Without it, it reports and returns 0.

### Dependencies

One line, whichever the stack is. This is the cheapest finding in the whole gate:

```bash
npm audit --audit-level=high
```

## Step 2 — Install the git hook (versioned, staged-only, fast)

Write `.githooks/pre-commit`, make it executable, and point git at the directory — **committed, so every
clone gets it**:

```bash
mkdir -p .githooks
```

```bash
cat > .githooks/pre-commit <<'HOOK'
#!/usr/bin/env bash
# Security gate — blocks the commit on a HIGH-severity finding in the STAGED diff.
# Installed by vc-precommit-security-gate. Proof that it blocks: security-gate-proof.md
set -uo pipefail
fail=0

staged=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$staged" ] && exit 0

# 1. Secrets — scan the staged content itself, not the working tree.
if command -v gitleaks >/dev/null 2>&1; then
  if ! git diff --cached | gitleaks stdin --redact --no-banner; then
    echo "BLOCKED: gitleaks found a secret in the staged diff." >&2
    fail=1
  fi
else
  echo "warning: gitleaks not installed — the secret half of this gate is OFF." >&2
fi

# 2. Static analysis — HIGH severity only, staged files only.
if command -v semgrep >/dev/null 2>&1; then
  # shellcheck disable=SC2086
  semgrep scan --config auto --severity ERROR --error --quiet --metrics=off $staged
  rc=$?
  # 0 = clean, 1 = findings, 2 = fatal. A crashed scanner has cleared nothing.
  if [ "$rc" -ne 0 ]; then
    echo "BLOCKED: semgrep exited $rc on the staged files." >&2
    fail=1
  fi
else
  echo "warning: semgrep not installed — the code half of this gate is OFF." >&2
fi

if [ "$fail" -ne 0 ]; then
  echo "" >&2
  echo "Commit refused. Fix the findings above, or run with --no-verify if you are certain." >&2
  exit 1
fi
exit 0
HOOK
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```

Note the two `warning:` branches. **A gate that silently degrades when its scanner is missing is the
worst possible failure** — it goes green forever and nobody notices. It says so, loudly, every commit,
until the reader installs the tool.

## Step 3 — Install the Claude Code hook (this one catches the agent)

The git hook fires when `git commit` runs. The Claude Code hook fires **before the tool call**, which
means the agent gets the reason back and can fix the finding rather than shrugging at an exit code.

**The exit code is the whole thing, and it is the single most common way an enforcement hook silently
fails.** From https://code.claude.com/docs/en/hooks — fetched 2026-07-14:

> For most hook events, only exit code 2 blocks the action. Claude Code treats **exit code 1 as a
> non-blocking error and proceeds with the action**, even though 1 is the conventional Unix failure code.
> **If your hook is meant to enforce a policy, use `exit 2`.**

Exit 0 → success, stdout parsed for JSON. Exit 2 → blocking error, **stderr is fed back to the model as
the error message.** So the stderr text is not a log line — it is the instruction the agent will act on.
Write it as one.

```bash
mkdir -p .claude/hooks
```

```bash
cat > .claude/hooks/commit-gate.sh <<'HOOK'
#!/usr/bin/env bash
# PreToolUse(Bash) — refuses `git commit` while the staged diff has a HIGH finding.
# EXIT 2 BLOCKS. Exit 1 does NOT: https://code.claude.com/docs/en/hooks
set -uo pipefail
payload=$(cat)
cmd=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).tool_input.command||'')}catch{console.log('')}})")

case "$cmd" in
  *"git commit"*) ;;
  *) exit 0 ;;                       # not a commit — no opinion
esac

case "$cmd" in
  *--no-verify*)
    echo "REFUSED: --no-verify strips the security gate. If a human wants to bypass it, a human types it." >&2
    exit 2 ;;
esac

out=$(git diff --cached | gitleaks stdin --redact --no-banner 2>&1) || {
  echo "COMMIT BLOCKED — gitleaks found a secret in the staged diff:" >&2
  echo "$out" >&2
  echo "Remove the secret, rotate it (it is already on this machine), and stage again." >&2
  exit 2
}

staged=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$staged" ]; then
  # shellcheck disable=SC2086
  out=$(semgrep scan --config auto --severity ERROR --error --quiet --metrics=off $staged 2>&1) || {
    echo "COMMIT BLOCKED — semgrep found a HIGH-severity issue in the staged files:" >&2
    echo "$out" >&2
    echo "Fix the finding above, then stage and commit again." >&2
    exit 2
  }
fi
exit 0
HOOK
chmod +x .claude/hooks/commit-gate.sh
```

Register it. `PreToolUse` matches on the tool name, so the matcher is `Bash`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "./.claude/hooks/commit-gate.sh" }]
      }
    ]
  }
}
```

Merge that into `.claude/settings.json` — **read it first and merge; never clobber existing hooks.**

The `--no-verify` clause is deliberate. `git commit --no-verify` skips the git hook entirely — that is
what it is for, and a human is entitled to it. **An agent is not.** A gate the agent can skip is not a
gate, and "skip the gate" is exactly the shortcut an agent will reach for when the gate is the only thing
between it and a green checkmark.

## Step 4 — PROVE IT BLOCKS. This step is not optional.

Everything above is configuration. None of it is evidence. Now make it fail, on purpose, with your own
eyes on it.

Do this on a throwaway branch so the plant can never reach anything:

```bash
git switch -c gate-proof-$(date +%s)
```

**Plant 1 — a secret.** A synthetic AWS-shaped key. It is not a real credential; it is the canonical
shape gitleaks detects:

```bash
printf 'AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"\nAWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"\n' > gate-canary.txt
git add gate-canary.txt
git commit -m "gate proof: this must fail"; echo "exit=$?"
```

**Expect a non-zero exit and a refusal.** If it commits, the gate is not on — go back to Step 2 and check
`git config core.hooksPath` and that the hook file is executable. **Do not proceed until you have watched
it fail.**

**Plant 2 — a high-severity code finding.** Use the reader's actual language, and use a construct that is
genuinely dangerous rather than a decorative one — user input reaching an interpreter:

```bash
git rm --cached -q gate-canary.txt 2>/dev/null; rm -f gate-canary.txt
printf 'import subprocess\ndef run(request):\n    return subprocess.call(request.args["cmd"], shell=True)\n' > gate_canary.py
git add gate_canary.py
git commit -m "gate proof: this must also fail"; echo "exit=$?"
```

Then prove the **agent** cannot get past it either. Ask the agent, in this session, to commit the planted
file. The `PreToolUse` hook must refuse it and hand back the reason — you will see the block in the
transcript, not just in a shell.

Now clean up, completely:

```bash
git reset -q; rm -f gate_canary.py gate-canary.txt
git switch -; git branch -D gate-proof-* 2>/dev/null; true
git status --short   # MUST be clean of every canary
```

And confirm a legitimate commit still goes through — a gate that blocks everything is not a gate, it is
an outage:

```bash
git commit --allow-empty -m "gate proof: a clean commit still passes"; echo "exit=$?"
```

## Step 5 — Emit the report and the proof

Two files. The proof is the one that matters, because it is the one that cannot be faked by a config diff.

**`security-gate-proof.md`** — paste the **real terminal output**, verbatim, including the exit codes:

```markdown
# Security gate — proof of block — <date>

## Plant 1 — secret in the staged diff
Command: `git commit -m "gate proof: this must fail"`
<verbatim output, including the gitleaks finding line and the refusal>
**Exit code: 1. COMMIT REFUSED.** ✅

## Plant 2 — high-severity code finding
Command: `git commit -m "gate proof: this must also fail"`
<verbatim semgrep output and the refusal>
**Exit code: 1. COMMIT REFUSED.** ✅

## Plant 3 — the AGENT tries to commit it
The PreToolUse hook returned exit 2. Verbatim stderr handed back to the agent:
<verbatim>
**TOOL CALL BLOCKED.** ✅

## Control — a clean commit
Command: `git commit --allow-empty -m "..."`
**Exit code: 0. COMMIT ACCEPTED.** ✅

## Cleanup
`git status --short` → clean. Canary files removed, proof branch deleted.
```

**`security-gate.md`** — what is installed, what it blocks, what it does not:

```markdown
# Security gate — <repo> — <date>

| Layer | Tool | Version | Fires on | Blocks by | Scope |
|---|---|---|---|---|---|
| git hook | gitleaks | 8.30.1 | `git commit` | exit 1 | staged diff |
| git hook | semgrep | <ver> | `git commit` | exit 1 (`--error`) | staged files, severity ERROR |
| Claude Code | both | — | PreToolUse(Bash) matching `git commit` | **exit 2** | staged diff |
| CI (recommended) | trufflehog | <ver> | push / PR | `--fail` | full history, `--results=verified` |

Installed at: `.githooks/pre-commit` (versioned; `core.hooksPath=.githooks`),
`.claude/hooks/commit-gate.sh`, `.claude/settings.json` → `hooks.PreToolUse`.

## What this gate blocks
- A secret in the staged diff (gitleaks, redacted output).
- A severity-ERROR semgrep finding in a staged file.
- The agent attempting `git commit`, or `git commit --no-verify`, with either of the above.

## What it does NOT block — say this out loud
- **A human typing `git commit --no-verify`.** That is what the flag is for. The gate is a floor, not a cage.
- Anything already in the repo before today. The `--baseline-commit` note in Step 1 is how you tighten
  that without a big-bang cleanup.
- Everything semgrep's ERROR rules do not cover. This is a NET, not a REVIEW — run
  `vc-owasp-ai-security-review` on the diff for the structured pass, and `vc-language-footgun-audit` for
  the language-specific classes.
- Threats that are not bugs. What can be attacked at all is `vc-ai-threat-model`.

## Time cost, measured
`time` on the staged scan of a typical commit: <n>s. If this ever exceeds a few seconds, pin `--config`
to a narrower ruleset — the reader will disable a slow gate and keep believing they have one.
```

## Hard rules

- **Never ship a gate you have not watched block.** The proof file is the deliverable. Without it you have
  installed a belief.
- **Scan the staged content, not the working tree.** `git diff --cached`. A gate that scans files the
  reader did not stage blocks on things they did not do, and they will turn it off.
- **`--redact`, always.** A hook that prints the secret has leaked it into the scrollback and the CI log.
- **Exit 2 for the Claude Code hook. Exit 1 does not block** — it is a non-blocking error and the tool call
  proceeds. This is written down and it is still the most common way these hooks fail.
- **A missing scanner must be loud.** Warn on every commit. A gate that goes quietly green when its scanner
  is uninstalled is worse than none.
- **Rotate anything the gate catches.** A secret that reached the staging area reached the disk. It is on
  the machine, it may be in the reflog, and it is no longer secret. Blocking the commit is not containment.
- **Do not tune the ruleset to make it pass.** If the gate is noisy, narrow the *severity*, not the truth.

## What this skill will not do

- **It will not decide what your threats are.** Which surfaces exist and what the smallest control is —
  that is `vc-ai-threat-model`, and it is the pass that tells you *why* this gate is worth the seconds.
- **It will not review a diff.** The structured, severity-rated pass over AI-written code is
  `vc-owasp-ai-security-review`.
- **It will not enumerate language footguns.** `pickle`, `yaml.load`, `innerHTML`, prototype pollution —
  `vc-language-footgun-audit`.
- **It will not fix the findings it blocks on.** It stops the commit and names the file and the line. What
  the fix is depends on the code, and it is the reader's code.
