A Git pre-commit hook that catches semantic bugs your linter structurally cannot.
Static analysis is very good at the questions it can answer from a syntax tree: undefined names, wrong types, unreachable branches, unused imports. It is blind, by construction, to the class of defect that actually takes production down — a database write made durable before an unguarded network call, a webhook handler that is not safe to deliver twice, a read-modify-write with a concurrency window in the middle.
Those bugs pass every linter, every type checker, and usually code review too, because each individual line is correct. Only the runtime behaviour is wrong.
SentinelCommit reads your staged diff on git commit, reasons about it with
Claude, and refuses the commit — with a ready-to-paste patch — when it finds
one.
====================================================================
[COMMIT REJECTED] Hazard: TRANSACTION_FAULT Severity: CRITICAL
====================================================================
File: payment_service.py
Why this is unsafe
The balance is debited and committed before the payment gateway is
called. If the gateway raises or times out the debit is already
durable and no compensating action runs, so the customer is debited
for an order that was never placed.
Suggested remediation
| try:
| with db.transaction():
| db.execute(
| "UPDATE users SET balance = balance - %s "
| "WHERE id = %s AND balance >= %s",
| (amount, user_id, amount),
| )
| payment_gateway.charge(user_id, amount)
| except PaymentError:
| db.rollback()
| raise
Fix and re-stage, or bypass with: git commit --no-verify
====================================================================
Three hazard classes, deliberately. A guardrail that flags everything gets uninstalled within a day, so the prompt is scoped tightly and instructed to prefer a false negative over a false positive.
| Hazard | What it means |
|---|---|
TRANSACTION_FAULT |
A durable write whose operation can fail partway through with no rollback or compensating action, leaving persisted state inconsistent. |
IDEMPOTENCY_RISK |
A handler that can legitimately be delivered more than once (webhook, payment capture, queue consumer, retried job) but applies its side effect unconditionally. |
RACE_CONDITION |
Unsynchronised access to shared mutable state, or a check-then-act (TOCTOU) window where the checked condition can change before it is acted on. |
Style, naming, formatting, missing tests and performance nits are explicitly not blocking. Your linter already owns those.
Worked unsafe/safe pairs for all three live in examples/.
git commit
│
├─ Tier 1 Deterministic gate ....................... 0 tokens
│ empty diff? docs/config only? lockfile only?
│ └─ yes ─> exit 0, no API call
│
├─ Tier 2 Semantic gate ............................ 1 API call
│ git diff --cached -> Claude (adaptive thinking)
│ └─ structured output, JSON-schema constrained
│
└─ Verdict
should_block = false ─> exit 0, commit proceeds
should_block = true ─> exit 1, commit refused + patch printed
any error / timeout ─> exit 0, warn (fail open)
Three properties worth calling out:
Tier 1 is free. A README tweak, a lockfile bump or a config change never reaches the API. Token spend tracks actual code changes, not commit count.
The verdict is schema-constrained, not parsed out of prose. The request
uses output_config.format with a JSON Schema, so the response is guaranteed
to be valid JSON matching the verdict shape. There is no regex, no
"BLOCK" in response, and no brittle text parsing anywhere in the decision
path.
It fails open, always. Missing API key, dead Wi-Fi, timeout, rate limit,
malformed response — every failure path exits 0 with a warning. A guardrail
that blocks your commit because its own dependency is down is worse than no
guardrail. Blocking is reserved for the one case where the model actively
asserts a hazard.
The same function, committed twice. The only difference is whether the durable write and the network call share an atomic outcome.
The balance is debited and committed, and only then is the payment gateway called. Nothing reverses the debit if that call fails:
def process_order(db, payment_gateway, user_id, amount):
db.execute(
"UPDATE users SET balance = balance - %s WHERE id = %s",
(amount, user_id),
)
db.commit() # durable, and irreversible
payment_gateway.charge(user_id, amount) # if this raises, money is goneruff, flake8, mypy and pylint all pass this file. SentinelCommit does
not:
No commit is created, and the terminal already contains the patch.
One transaction now covers the debit, the order row and the gateway call, so a gateway failure rolls the debit back instead of stranding it:
def process_order(db, payment_gateway, user_id, amount):
try:
with db.transaction():
rows = db.execute(
"UPDATE users SET balance = balance - %s "
"WHERE id = %s AND balance >= %s",
(amount, user_id, amount),
).rowcount
if rows == 0:
raise InsufficientFunds(user_id)
payment_gateway.charge(user_id, amount)
except Exception:
db.rollback()
raiseBoth files are in examples/ and are byte-for-byte what produced
the output above. The verbatim transcript — plus the two real bugs this testing
caught in the tool itself — is in docs/DEMO.md. The images are
generated from that transcript by
docs/render_demo_svg.py.
Requires Python 3.10+ and Git 2.30+.
git clone https://github.com/Dripar/SentinelCommit.git
cd SentinelCommit
pip install -r requirements.txt
export ANTHROPIC_API_KEY="sk-ant-..."Install the hook into this repository:
./install.shInstall it into a different repository:
cd /path/to/your/project
/path/to/SentinelCommit/install.shThe installer copies hooks/pre-commit into the target repo's hooks directory
and refuses to overwrite a pre-commit hook it did not write.
Windows: run the installer from Git Bash. The hook itself is POSIX
sh, which Git for Windows executes with its bundled shell, so it works from PowerShell, CMD and GUI Git clients too.
The hook fails open when it cannot find a working Python — which means a
misconfigured PATH silently stops auditing rather than announcing itself. On
Windows this is easy to hit: WindowsApps usually precedes a real install on
PATH, so python3 resolves to a Microsoft Store stub that exits non-zero.
Pin it once, per repository, and the hook stops depending on the environment:
git config sentinel.python "$(command -v python3)" # macOS / Linux
git config sentinel.python "$LOCALAPPDATA/Programs/Python/Python312/python.exe"This is stored in .git/config, so it works from GUI clients, editors and
scheduled jobs that do not inherit your shell. SENTINEL_PYTHON takes
precedence if it is set; a venv/ or .venv/ at the repo root is used next.
To confirm the hook is actually running rather than quietly skipping:
git add -A && python sentinel.py; echo "exit=$?"Once installed there is nothing to run and nothing to remember. You commit as you always have, and the hook decides whether to get involved.
git add payment_service.py
git commit -m "Add order payment flow"What happens next depends on what you staged:
| What you staged | What the tool does | Cost | Commit |
|---|---|---|---|
| Nothing | Exits immediately | free | git's own "nothing to commit" |
| Only docs, config, images, lockfiles | Tier 1 skips it, no API call | free | proceeds |
| Code with no hazard found | Tier 2 audits, prints one green line | 1 call | proceeds |
Code with a HIGH/CRITICAL hazard |
Tier 2 prints the banner and patch | 1 call | refused |
| Anything, but the API is unreachable | Prints a warning | free | proceeds (fail open) |
Read the reason, then pick one:
# 1. It is right. Apply the printed patch, re-stage, commit again.
git add payment_service.py && git commit -m "Add order payment flow"
# 2. It is wrong, or you are mid-spike and do not care yet.
git commit --no-verify -m "WIP: spike, not production path"--no-verify is a supported answer, not a defeat. The tool is tuned to block
rarely; if it blocks often on your codebase, that is a bug in the prompt, not
a reason to fight the hook.
Run the audit by hand against whatever is staged — useful mid-change, or to see what the hook would say:
git add -A
python sentinel.py; echo "exit=$?" # 0 = would allow, 1 = would blockSENTINEL_MOCK=1 returns a cached verdict with no network call, so you can
learn the workflow, rehearse a demo, or work on a plane:
SENTINEL_MOCK=1 git commit -m "Add order payment flow"The hook lives in .git/hooks/, which Git does not clone. Each developer runs
install.sh once. To make that automatic, point the repo at a tracked hooks
directory instead:
git config core.hooksPath hooks # committed, so everyone gets it on cloneTwo things worth agreeing on before you do:
- Everyone needs
ANTHROPIC_API_KEYset, or the hook silently fails open and audits nothing. - It adds a few seconds to commits that touch code. Teams that commit in very small increments feel this more than teams that don't.
The engine is just a script with an exit code, so the same check works as a PR gate. Diff against the base branch rather than the index:
git diff origin/main...HEAD > /tmp/pr.diff
# stage the PR's changes, then:
python sentinel.pyNote that in CI the fail-open behaviour is usually wrong — a missing key there means the gate silently passes everything. Assert the key is present before invoking it.
Two scenarios, roughly sixty seconds. Works offline with SENTINEL_MOCK=1.
Scenario A — the commit is refused
cp examples/transaction_fault_unsafe.py payment_service.py
git add payment_service.py
git commit -m "Add payment deduction logic"The hook prints a red [COMMIT REJECTED] banner naming the hazard, explains
the exact failure sequence, prints a remediation patch, and exits non-zero.
No commit is created.
Scenario B — the remediated commit passes
git reset # clear scenario A from the index
cp examples/transaction_fault_safe.py payment_service.py
git add payment_service.py
git commit -m "Add payment deduction logic with transactional rollback"The hook prints a green [SentinelCommit] All semantic checks passed. and the
commit is created.
git addstages, it does not unstage. Thegit resetbetween scenarios matters — without it the unsafe file is still in the index and the second commit is blocked too.
Every setting is an environment variable, so it can be set per-shell, per-repository or in CI without editing code.
| Variable | Default | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
— | API credentials. Unset ⇒ the hook fails open. |
ANTHROPIC_AUTH_TOKEN |
— | Accepted as an alternative to the API key (OAuth token). |
SENTINEL_MOCK |
unset | 1 returns a cached verdict with no network call. For demos and offline work. |
SENTINEL_PYTHON |
unset | Interpreter the hook should use. Set this if auto-detection picks the wrong Python. |
(git config) sentinel.python |
unset | Repo-local interpreter pin, checked when SENTINEL_PYTHON is unset. Survives environments that do not inherit your shell. |
SENTINEL_AUDIT_LOG |
.git/sentinel-audit.jsonl |
Where verdicts are appended for the dashboard. Set to off to disable. |
SENTINEL_MODEL |
claude-opus-5 |
Model id. |
SENTINEL_EFFORT |
medium |
low | medium | high | xhigh | max. Higher catches more subtle defects, costs more latency. |
SENTINEL_TIMEOUT |
45 |
Per-request timeout, seconds. On timeout the hook fails open. |
SENTINEL_MAX_DIFF |
60000 |
Diff characters sent before truncation. |
SENTINEL_FORCE_COLOR |
unset | 1 forces ANSI colour when stdout is not a TTY. |
NO_COLOR |
unset | Standard opt-out; disables all colour. |
Every verdict is appended to .git/sentinel-audit.jsonl, which gives you
something a plain git log cannot: the commits that never happened. A
blocked commit leaves no trace in git history, so without this the tool's most
valuable moments are invisible.
python dashboard.py # http://127.0.0.1:8765, opens your browserA real recording: three unsafe commits are refused, then the same three are remediated and pass. The counters, the hazard bars and the audit trail all update as it happens. (full-size still · recorder)
A 72-second video covering the problem, a refused commit, the fix, and the dashboard:
▶ sentinel-demo.mp4 (1.5 MB, 1280×720, with narration) — regenerate with docs/record_demo_video.py.
One page, four sections:
- Stats — commits, blocks, passes, Tier 1 skips (the free ones), API calls actually made, and how often it failed open.
- Hazards blocked — which of the three classes are actually biting you.
- Commit graph — the DAG in lanes, with branch, tag and remote refs. Merges render as hollow nodes and lanes are colour-coded.
- Audit trail — every verdict, newest first, with the hazard, severity, files, the model's reasoning, and the suggested patch behind a disclosure. Blocked entries are the ones with no corresponding commit.
Standard library only — http.server, no Flask, no npm, no build step. Options:
python dashboard.py --port 9000 --limit 200 --no-browser
curl -s localhost:8765/api/ | jq .stats # same data as JSONThe page auto-refreshes every 10 seconds, so you can leave it open on a second
monitor while you work. It binds to 127.0.0.1 and is read-only — it shells
out to git log and reads the audit file, and has no write path.
Why a pre-commit hook and not CI? The feedback has to arrive while the
change is still in your working memory. A CI failure fifteen minutes later
costs a context switch; a rejected git commit costs four seconds. And a
commit that never existed needs no revert.
Why fail open? A guardrail is adopted only if it is never the reason work
stops. The cost asymmetry is stark: a missed detection leaves you exactly where
you were without the tool, while a false block on a flaky network trains the
whole team to run --no-verify reflexively — which disables the tool
permanently. Every ambiguous path therefore resolves to "allow".
Why only three hazard classes? Precision is the product. A tool that
comments on naming conventions gets muted; a tool that has only ever stopped
real outages gets trusted. The system prompt names the three classes
explicitly, requires HIGH or CRITICAL severity to block, and instructs the
model not to block when uncertain.
Why structured outputs rather than a forced tool call? Both constrain the shape of the response. Structured outputs compose cleanly with adaptive thinking, which materially helps on exactly this task — reasoning about failure interleavings is where the extra thinking budget pays for itself.
- Single-diff context. The model sees the staged diff, not the whole repository. A hazard that exists only in the interaction between the diff and an unmodified file elsewhere will be missed.
- Non-determinism. The same diff can occasionally produce a different verdict. This is a guardrail, not a proof; treat a pass as "nothing obvious found", not as a correctness guarantee.
- Latency. Roughly 3–10s per commit that reaches Tier 2, depending on
SENTINEL_EFFORTand diff size. Tier 1 commits are instant. - Cost. One API call per code-bearing commit. Docs-only commits are free.
sentinel.py Core engine: diff extraction, both gates, reporting
dashboard.py Local single-page dashboard (stdlib http.server)
hooks/pre-commit POSIX sh wrapper; resolves repo root and interpreter
install.sh Idempotent hook installer
examples/ Unsafe/safe pairs, one per hazard class
tests/ Unit tests for the deterministic gate and mock verdicts
docs/ Demo transcript, generated images, image generator
requirements.txt anthropic (+ colorama on Windows)
Built at Claude Build Day, Webkul, Noida.
Track: Everyday Developer Tooling / Breakthrough.
Model: claude-opus-5 with adaptive extended thinking.
MIT — see LICENSE.
