diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d8b6fa5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Normalise text in the repository, let Git pick the working-copy ending. +* text=auto + +# Shell scripts and Git hooks MUST keep LF endings. A hook checked out with +# CRLF fails at exec time with "bad interpreter: /bin/sh^M", including under +# Git for Windows and WSL. +*.sh text eol=lf +hooks/pre-commit text eol=lf + +# Python stays LF for consistency across platforms. +*.py text eol=lf + +# Documentation. +*.md text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63d9ab8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Unit tests + run: python -m unittest discover -s tests -v + + - name: Hook must fail open without credentials + shell: bash + env: + ANTHROPIC_API_KEY: "" + run: | + # No key configured: the engine must allow the commit, not block it. + git config user.email ci@example.com + git config user.name CI + cp examples/transaction_fault_unsafe.py payment_service.py + git add payment_service.py + python sentinel.py + echo "exit=$? (0 required: fail open)" + + - name: Mock verdict drives both demo scenarios + shell: bash + env: + SENTINEL_MOCK: "1" + run: | + git reset + cp examples/transaction_fault_unsafe.py payment_service.py + git add payment_service.py + if python sentinel.py; then + echo "::error::unsafe diff was not blocked"; exit 1 + fi + git reset + cp examples/transaction_fault_safe.py payment_service.py + git add payment_service.py + python sentinel.py || { echo "::error::safe diff was blocked"; exit 1; } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8549ee2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Virtual environments +venv/ +.venv/ +env/ + +# Secrets - never commit an API key +.env +.env.* +!.env.example + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0235534 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SentinelCommit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7e58606..5f7a83f 100644 --- a/README.md +++ b/README.md @@ -1 +1,443 @@ -# SentinelCommit \ No newline at end of file +# SentinelCommit + +**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 +==================================================================== +``` + +--- + +## What it detects + +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/`](examples/). + +--- + +## How it works + +``` +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. + +--- + +## See it work + +The same function, committed twice. The only difference is whether the durable +write and the network call share an atomic outcome. + +### With the bug — the commit is refused + +The balance is debited and **committed**, and only then is the payment gateway +called. Nothing reverses the debit if that call fails: + +```python +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 gone +``` + +`ruff`, `flake8`, `mypy` and `pylint` all pass this file. SentinelCommit does +not: + +![Commit blocked — TRANSACTION_FAULT detected](docs/images/commit-blocked.svg) + +No commit is created, and the terminal already contains the patch. + +### Without the bug — the commit goes through + +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: + +```python +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() + raise +``` + +![Commit passed — all semantic checks passed](docs/images/commit-passed.svg) + +Both files are in [`examples/`](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`](docs/DEMO.md). The images are +generated from that transcript by +[`docs/render_demo_svg.py`](docs/render_demo_svg.py). + +--- + +## Install + +Requires Python 3.10+ and Git 2.30+. + +```bash +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: + +```bash +./install.sh +``` + +Install it into a **different** repository: + +```bash +cd /path/to/your/project +/path/to/SentinelCommit/install.sh +``` + +The 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. + +--- + +## Using it day to day + +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. + +```bash +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) | + +### When it blocks you + +Read the reason, then pick one: + +```bash +# 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. + +### Checking before you commit + +Run the audit by hand against whatever is staged — useful mid-change, or to +see what the hook *would* say: + +```bash +git add -A +python sentinel.py; echo "exit=$?" # 0 = would allow, 1 = would block +``` + +### Trying it without spending anything + +`SENTINEL_MOCK=1` returns a cached verdict with no network call, so you can +learn the workflow, rehearse a demo, or work on a plane: + +```bash +SENTINEL_MOCK=1 git commit -m "Add order payment flow" +``` + +### Rolling it out to a team + +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: + +```bash +git config core.hooksPath hooks # committed, so everyone gets it on clone +``` + +Two things worth agreeing on before you do: +- Everyone needs `ANTHROPIC_API_KEY` set, 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. + +### Using it in CI instead of locally + +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: + +```bash +git diff origin/main...HEAD > /tmp/pr.diff +# stage the PR's changes, then: +python sentinel.py +``` + +Note 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. + +--- + +## Live demo + +Two scenarios, roughly sixty seconds. Works offline with `SENTINEL_MOCK=1`. + +**Scenario A — the commit is refused** + +```bash +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** + +```bash +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 add` stages, it does not *un*stage. The `git reset` between scenarios +> matters — without it the unsafe file is still in the index and the second +> commit is blocked too. + +--- + +## Configuration + +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. | +| `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. | + +--- + +## Dashboard + +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. + +```bash +python dashboard.py # http://127.0.0.1:8765, opens your browser +``` + +![SentinelCommit dashboard](docs/images/dashboard.png) + +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: + +```bash +python dashboard.py --port 9000 --limit 200 --no-browser +curl -s localhost:8765/api/ | jq .stats # same data as JSON +``` + +The 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. + +--- + +## Design notes + +**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. + +### Known limitations + +- **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_EFFORT` and diff size. Tier 1 commits are instant. +- **Cost.** One API call per code-bearing commit. Docs-only commits are free. + +--- + +## Repository layout + +``` +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) +``` + +--- + +## Event + +Built at **Claude Build Day**, Webkul, Noida. +Track: Everyday Developer Tooling / Breakthrough. +Model: `claude-opus-5` with adaptive extended thinking. + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..b74234e --- /dev/null +++ b/dashboard.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +""" +SentinelCommit dashboard - a single-page local view of the repository. + +Shows, on one page: + * headline stats (commits, audits, blocks, free Tier 1 skips) + * the commit DAG, drawn as a lane graph with branch and tag refs + * every commit, with the audit verdict that gated it + * the full SentinelCommit audit trail, including commits that never happened + +Standard library only - no Flask, no npm, no build step. + +Usage: + python dashboard.py # serve on http://127.0.0.1:8765 + python dashboard.py --port 9000 + python dashboard.py --no-browser +""" + +import argparse +import html +import json +import os +import subprocess +import sys +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer + +SEP = "\x1f" # unit separator - safe inside commit messages +REC = "\x1e" # record separator + +LANE_COLOURS = [ + "#58a6ff", "#3fb950", "#d29922", "#bc8cff", + "#f85149", "#39c5cf", "#db6d28", "#7ee787", +] + + +# -------------------------------------------------------------------------- +# Git +# -------------------------------------------------------------------------- + +def git(*args): + result = subprocess.run( + ["git"] + list(args), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + ) + if result.returncode != 0: + raise RuntimeError("git " + " ".join(args) + ": " + result.stderr.strip()) + return result.stdout + + +def repo_root(): + return git("rev-parse", "--show-toplevel").strip() + + +def current_branch(): + try: + return git("rev-parse", "--abbrev-ref", "HEAD").strip() + except Exception: + return "(detached)" + + +def load_commits(limit): + """Read the commit DAG. Returns newest-first.""" + fmt = SEP.join(["%H", "%h", "%P", "%an", "%ae", "%aI", "%s", "%D"]) + REC + try: + raw = git("log", "--all", "--date-order", "-n", str(limit), "--pretty=format:" + fmt) + except RuntimeError: + return [] # no commits yet + + commits = [] + for chunk in raw.split(REC): + chunk = chunk.strip("\n") + if not chunk: + continue + sha, short, parents, an, ae, when, subject, refs = chunk.split(SEP) + commits.append({ + "sha": sha, + "short": short, + "parents": parents.split() if parents else [], + "author": an, + "email": ae, + "date": when, + "subject": subject, + "refs": [r.strip() for r in refs.split(",") if r.strip()], + }) + + # Attach per-commit diff stats in one batch call rather than N calls. + for c in commits: + try: + out = git("show", "--stat=200", "--format=", c["sha"]) + tail = [l for l in out.strip().splitlines() if l.strip()] + c["stat"] = tail[-1].strip() if tail else "" + c["files"] = max(len(tail) - 1, 0) + except Exception: + c["stat"], c["files"] = "", 0 + return commits + + +def assign_lanes(commits): + """ + Lay the DAG out in vertical lanes, the way `git log --graph` does. + + Walking newest-first, each commit takes the leftmost lane already + reserved for it (by a child), or the leftmost free lane. Its first parent + inherits that lane so mainline stays straight; further parents (merges) + branch into new lanes. + """ + active = [] # active[i] = sha expected to appear in lane i, or None + for c in commits: + if c["sha"] in active: + lane = active.index(c["sha"]) + else: + lane = active.index(None) if None in active else len(active) + if lane == len(active): + active.append(None) + c["lane"] = lane + + # Lanes this commit connects down to. + active[lane] = None + outgoing = [] + for i, parent in enumerate(c["parents"]): + if parent in active: + plane = active.index(parent) + elif i == 0: + plane = lane + active[lane] = parent + else: + plane = active.index(None) if None in active else len(active) + if plane == len(active): + active.append(None) + active[plane] = parent + outgoing.append(plane) + c["outgoing"] = outgoing + + while active and active[-1] is None: + active.pop() + return commits + + +# -------------------------------------------------------------------------- +# Audit trail +# -------------------------------------------------------------------------- + +def load_audit(): + path = os.environ.get("SENTINEL_AUDIT_LOG", "") + if not path: + try: + path = os.path.join(git("rev-parse", "--absolute-git-dir").strip(), + "sentinel-audit.jsonl") + except Exception: + return [] + if not os.path.exists(path): + return [] + + events = [] + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue # tolerate a partially written final line + events.reverse() # newest first + return events + + +def summarise(commits, events): + outcomes = {} + for e in events: + outcomes[e.get("outcome", "?")] = outcomes.get(e.get("outcome", "?"), 0) + 1 + hazards = {} + for e in events: + if e.get("outcome") == "blocked": + h = e.get("hazard_type", "UNKNOWN") + hazards[h] = hazards.get(h, 0) + 1 + # Only a real verdict costs a call. Mock runs reach Tier 2 but never leave + # the machine, so counting them here would overstate spend. + api_calls = sum(1 for e in events + if e.get("outcome") in ("blocked", "passed") and not e.get("mock")) + mocked = sum(1 for e in events if e.get("mock")) + return { + "commits": len(commits), + "audits": len(events), + "blocked": outcomes.get("blocked", 0), + "passed": outcomes.get("passed", 0), + "skipped_docs": outcomes.get("skipped_docs", 0), + "failed_open": outcomes.get("failed_open", 0), + "api_calls": api_calls, + "mocked": mocked, + "hazards": hazards, + "branch": current_branch(), + } + + +# -------------------------------------------------------------------------- +# Rendering +# -------------------------------------------------------------------------- + +ROW_H = 56 +GRAPH_X0 = 22 +LANE_W = 20 + + +def e(text): + return html.escape(str(text if text is not None else "")) + + +def render_graph(commits): + """SVG DAG: one row per commit, edges drawn to each parent's row.""" + if not commits: + return '

No commits yet.

' + + row_of = {c["sha"]: i for i, c in enumerate(commits)} + lanes = max((c["lane"] for c in commits), default=0) + 1 + width = GRAPH_X0 + lanes * LANE_W + 10 + height = len(commits) * ROW_H + 20 + + def cx(lane): + return GRAPH_X0 + lane * LANE_W + + def cy(row): + return row * ROW_H + ROW_H // 2 + + parts = [f'") + return "".join(parts) + + +def render_ref(ref): + cls = "ref-branch" + if ref.startswith("HEAD"): + cls = "ref-head" + elif ref.startswith("tag:"): + cls = "ref-tag" + elif ref.startswith("origin/"): + cls = "ref-remote" + return f'{e(ref)}' + + +def render_rows(commits, events): + """Commit rows, aligned 1:1 with the SVG graph rows.""" + # Match audits to commits by subject where we can - the audit fires before + # the commit exists, so there is no sha to join on. + passed_by_subject = {} + for ev in reversed(events): + if ev.get("outcome") in ("passed", "skipped_docs"): + passed_by_subject.setdefault(ev.get("detail", ""), ev) + + out = [] + for c in commits: + refs = "".join(render_ref(r) for r in c["refs"]) + out.append( + f'
  • ' + f'
    ' + f'{e(c["short"])}' + f'{e(c["subject"])}' + f'{refs}' + f'
    ' + f'
    ' + f'{e(c["author"])}' + f'·' + f'' + f'{"·" + e(c["stat"]) + "" if c["stat"] else ""}' + f'
    ' + f'
  • ' + ) + return "".join(out) + + +OUTCOME_META = { + "blocked": ("blocked", "COMMIT BLOCKED"), + "passed": ("passed", "passed"), + "skipped_docs": ("skipped", "Tier 1 skip - no API call"), + "failed_open": ("failopen", "failed open"), +} + + +def render_audit(events): + if not events: + return ('

    No audits recorded yet. Make a commit with the ' + 'hook installed, then refresh.

    ') + out = [] + for ev in events: + cls, label = OUTCOME_META.get(ev.get("outcome", ""), ("skipped", ev.get("outcome", "?"))) + hazard = ev.get("hazard_type") + badge = (f'{e(hazard)}' + if hazard and hazard != "NONE" else "") + sev = (f'' + f'{e(ev.get("severity"))}' if ev.get("severity") else "") + mock = 'MOCK' if ev.get("mock") else "" + files = ev.get("files") or [] + filelist = ("".join(f'{e(f)}' for f in files[:6]) + + (f'+{len(files) - 6}' if len(files) > 6 else "")) + reason = f'

    {e(ev.get("reason"))}

    ' if ev.get("reason") else "" + detail = (f'

    {e(ev.get("detail"))}

    ' + if ev.get("detail") and not ev.get("reason") else "") + fix = ev.get("suggested_fix") + fixblock = (f'
    Suggested remediation' + f'
    {e(fix)}
    ') if fix else "" + out.append( + f'
  • ' + f'
    ' + f'{e(label)}{badge}{sev}{mock}' + f'' + f'
    ' + f'
    {filelist}
    ' + f'{reason}{detail}{fixblock}' + f'
  • ' + ) + return "".join(out) + + +def render_hazards(stats): + if not stats["hazards"]: + return "" + total = sum(stats["hazards"].values()) + bars = [] + for hazard, n in sorted(stats["hazards"].items(), key=lambda kv: -kv[1]): + pct = n * 100 // max(total, 1) + bars.append( + f'
    {e(hazard)}' + f'' + f'{n}
    ' + ) + return f'

    Hazards blocked

    {"".join(bars)}
    ' + + +def page(commits, events, stats, root): + saved = stats["skipped_docs"] + return f""" + + + +SentinelCommit · {e(os.path.basename(root))} + +
    +

    SentinelCommit

    + {e(root)} + {e(stats["branch"])} +
    +
    +
    +
    {stats["commits"]}commits
    +
    {stats["blocked"]}commits blocked
    +
    {stats["passed"]}audits passed
    +
    {saved}Tier 1 skips (free)
    +
    {stats["api_calls"]}API calls made{ + ' · ' + str(stats["mocked"]) + ' mocked' if stats["mocked"] else ''}
    +
    {stats["failed_open"]}failed open
    +
    + + {render_hazards(stats)} + +
    +

    Commit graph

    +
    + {render_graph(commits)} +
      {render_rows(commits, events)}
    +
    +
    + +
    +

    Audit trail

    +
      {render_audit(events)}
    +
    +
    + + + +""" + + +# -------------------------------------------------------------------------- +# Server +# -------------------------------------------------------------------------- + +class Handler(BaseHTTPRequestHandler): + limit = 80 + + def do_GET(self): + if self.path.startswith("/api/"): + return self._json() + if self.path not in ("/", "/index.html"): + self.send_error(404) + return + try: + commits = assign_lanes(load_commits(self.limit)) + events = load_audit() + body = page(commits, events, summarise(commits, events), repo_root()) + except Exception as exc: # surface errors in the page, not the console + body = (f"" + f"" + f"

    dashboard error

    {html.escape(str(exc))}
    ") + self._send(body.encode("utf-8"), "text/html; charset=utf-8") + + def _json(self): + try: + commits = assign_lanes(load_commits(self.limit)) + events = load_audit() + payload = {"commits": commits, "audit": events, + "stats": summarise(commits, events)} + self._send(json.dumps(payload, indent=2).encode("utf-8"), + "application/json; charset=utf-8") + except Exception as exc: + self._send(json.dumps({"error": str(exc)}).encode("utf-8"), + "application/json; charset=utf-8", status=500) + + def _send(self, body, ctype, status=200): + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass # keep the terminal clean + + +def main(): + ap = argparse.ArgumentParser(description="Serve the SentinelCommit dashboard.") + ap.add_argument("--port", type=int, default=8765) + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--limit", type=int, default=80, help="commits to load") + ap.add_argument("--no-browser", action="store_true") + args = ap.parse_args() + + try: + root = repo_root() + except Exception: + print("error: not inside a git repository", file=sys.stderr) + return 1 + + Handler.limit = args.limit + try: + server = HTTPServer((args.host, args.port), Handler) + except OSError as exc: + print(f"error: cannot bind {args.host}:{args.port} ({exc})", file=sys.stderr) + print("hint: pass --port to pick another.", file=sys.stderr) + return 1 + + url = f"http://{args.host}:{args.port}/" + print(f"SentinelCommit dashboard -> {url}") + print(f"repository: {root}") + print("Ctrl+C to stop.") + if not args.no_browser: + threading.Timer(0.4, lambda: webbrowser.open(url)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nstopped.") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..1e4ebc4 --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,193 @@ +# Demo transcript and screenshot guide + +Verbatim terminal output from a real run on Windows 11 (Git Bash, Git 2.47.1, +Python 3.12.10, `anthropic` 1.7.0), with ANSI colour codes stripped. Use these +as the reference for what the screenshots in the main README should show. + +Everything below was produced with `SENTINEL_MOCK=1`, which is the offline demo +path — no network, no API key, identical output shape to a live run. + +--- + +## Tier 1 — deterministic gate (zero tokens) + +Nothing staged: + +```console +$ git commit -m "x" +[SentinelCommit] No staged code changes - nothing to audit. +``` + +Docs and config only — no API call is made at all: + +```console +$ git add README.md LICENSE .gitignore .gitattributes +$ git commit -m "docs: add README, licence, gitignore and gitattributes" +[SentinelCommit] Docs/config only (4 file(s)) - no API call made. +[feat/semantic-pre-commit-guardrail ce350f0] docs: add README, licence, gitignore and gitattributes + 4 files changed, 348 insertions(+), 1 deletion(-) +``` + +--- + +## Scenario A — unsafe commit is refused + +**→ This is SCREENSHOT_1.** + +```console +$ cp examples/transaction_fault_unsafe.py payment_service.py +$ git add payment_service.py +$ git commit -m "Add payment deduction logic" + +[SentinelCommit] Auditing 1 staged file(s) via cached verdict [MOCK]... + +==================================================================== + [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 +==================================================================== +``` + +Exit status `1`, and `git log` confirms no commit was created. + +--- + +## Scenario B — remediated commit passes + +**→ This is SCREENSHOT_2.** + +```console +$ git reset +$ cp examples/transaction_fault_safe.py payment_service.py +$ git add payment_service.py +$ git commit -m "Add payment deduction logic with transactional rollback" + +[SentinelCommit] Auditing 1 staged file(s) via cached verdict [MOCK]... +[SentinelCommit] All semantic checks passed. +[feat/semantic-pre-commit-guardrail 545feca] Add payment deduction logic with transactional rollback + 1 file changed, 46 insertions(+) + create mode 100644 payment_service.py +``` + +--- + +## Resilience — fail open with no credentials + +An unsafe diff, with `ANTHROPIC_API_KEY` unset. The hazard is real, but the +tool cannot reach the model, so it must **allow** the commit rather than block +on its own unavailability: + +```console +$ unset ANTHROPIC_API_KEY +$ git add payment_service.py +$ python sentinel.py +[SentinelCommit] ANTHROPIC_API_KEY not set - skipping audit, commit allowed. +$ echo $? +0 +``` + +--- + +## Unit tests + +```console +$ python -m unittest discover -s tests -v +test_case_is_insensitive (test_sentinel.TestDocsOnlyGate) ... ok +test_dotfiles_are_docs (test_sentinel.TestDocsOnlyGate) ... ok +test_empty_list_is_vacuously_docs (test_sentinel.TestDocsOnlyGate) ... ok +test_extensionless_licence_is_docs (test_sentinel.TestDocsOnlyGate) ... ok +test_markdown_only_is_docs (test_sentinel.TestDocsOnlyGate) ... ok +test_nested_paths_match_on_basename (test_sentinel.TestDocsOnlyGate) ... ok +test_one_code_file_taints_the_batch (test_sentinel.TestDocsOnlyGate) ... ok +test_python_is_code (test_sentinel.TestDocsOnlyGate) ... ok +test_unknown_extensionless_file_is_treated_as_code (test_sentinel.TestDocsOnlyGate) ... ok +test_only_added_lines_are_considered (test_sentinel.TestMockVerdict) ... ok +test_transactional_write_passes (test_sentinel.TestMockVerdict) ... ok +test_unguarded_write_blocks (test_sentinel.TestMockVerdict) ... ok +test_verdict_matches_the_declared_schema (test_sentinel.TestMockVerdict) ... ok +test_never_returns_empty_list (test_sentinel.TestTextWrapping) ... ok +test_preserves_every_word (test_sentinel.TestTextWrapping) ... ok +test_respects_width (test_sentinel.TestTextWrapping) ... ok + +---------------------------------------------------------------------- +Ran 16 tests in 0.003s + +OK +``` + +--- + +## Bugs this exercise actually caught + +Both were found by running the scenarios above, not by reading the code — worth +mentioning if a judge asks how it was tested. + +1. **The hook blocked every commit on Windows.** Interpreter selection used + `command -v python3`, which on Windows resolves to the Microsoft Store stub + `python3.exe`. That stub exists, is executable, prints "Python was not + found" and exits non-zero — so Git treated it as a hook failure and refused + the commit. Fixed by *probing* each candidate (`python -c "import sys"`) + instead of trusting the lookup, and by treating any exit status other than + `0`/`1` as fail-open. + +2. **Docs-only commits were billed for an API call.** + `os.path.splitext("LICENSE")` and `os.path.splitext(".gitignore")` both + return an empty extension, so extensionless files and dotfiles fell straight + through the zero-token gate. Fixed with a filename allowlist checked before + the extension lookup. `tests/test_sentinel.py` covers both cases. + +--- + +## Images + +The README embeds `images/commit-blocked.svg` and `images/commit-passed.svg`, +which are generated from the transcript above by +[`render_demo_svg.py`](render_demo_svg.py): + +```bash +python docs/render_demo_svg.py +``` + +SVG rather than PNG on purpose: it stays sharp at any zoom, renders inline on +GitHub, and shows up as a readable diff in review instead of an opaque binary +blob. Edit the `BLOCKED` / `PASSED` line lists in the generator and re-run to +update them. + +### If you want real photographic screenshots instead + +The rendered images reproduce output captured with `SENTINEL_MOCK=1`. To +capture a genuine live-API run: + +1. Set a real key so the verdict comes from the model, not the cache: + ```bash + export ANTHROPIC_API_KEY="sk-ant-..." + unset SENTINEL_MOCK + ``` +2. Raise the terminal font to 18pt or more — projectors flatten anything + smaller. +3. Run Scenario A, capture the red banner in full; run Scenario B, capture the + green pass line and the resulting commit. +4. Save them under `images/` and point the two `![...]` lines in the main + [README](../README.md) at your files instead of the generated SVGs. diff --git a/docs/images/commit-blocked.svg b/docs/images/commit-blocked.svg new file mode 100644 index 0000000..c51b3a4 --- /dev/null +++ b/docs/images/commit-blocked.svg @@ -0,0 +1,56 @@ + + + + + + + +SentinelCommit - commit blocked + +$ +cp examples/transaction_fault_unsafe.py payment_service.py +$ +git add payment_service.py +$ +git commit -m "Add payment deduction logic" +[SentinelCommit] Auditing 1 staged file(s) via Claude (claude-opus-5)... +==================================================================== + [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 +==================================================================== +$ +git log --oneline -1 +710d81e docs: touch examples readme + <- no commit was created + diff --git a/docs/images/commit-passed.svg b/docs/images/commit-passed.svg new file mode 100644 index 0000000..0841ec5 --- /dev/null +++ b/docs/images/commit-passed.svg @@ -0,0 +1,25 @@ + + + + + + + +SentinelCommit - commit passed + +$ +cp examples/transaction_fault_safe.py payment_service.py +$ +git add payment_service.py +$ +git commit -m "Add payment deduction logic with transactional rollback" +[SentinelCommit] Auditing 1 staged file(s) via Claude (claude-opus-5)... +[SentinelCommit] All semantic checks passed. +[feat/semantic-pre-commit-guardrail 545feca] Add payment deduction logic with transactional rollback + 1 file changed, 46 insertions(+) + create mode 100644 payment_service.py +$ +git log --oneline -1 +545feca Add payment deduction logic with transactional rollback + <- committed + diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..768b4d7 Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/render_demo_svg.py b/docs/render_demo_svg.py new file mode 100644 index 0000000..82545f7 --- /dev/null +++ b/docs/render_demo_svg.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Render the demo terminal sessions in docs/DEMO.md to SVG. + +GitHub renders SVG inline in Markdown, so these stay crisp at any zoom and +remain diffable in review, unlike a PNG screenshot. The content is a faithful +transcription of the real output captured in docs/DEMO.md. + +Usage: python docs/render_demo_svg.py +Writes: docs/images/commit-blocked.svg, docs/images/commit-passed.svg +""" + +import os + +# GitHub dark palette, so the images sit naturally in a README. +BG = "#0d1117" +FG = "#c9d1d9" +DIM = "#8b949e" +RED = "#f85149" +GREEN = "#3fb950" +YELLOW = "#d29922" +CYAN = "#39c5cf" +PROMPT = "#7ee787" + +CHAR_W = 8.4 +LINE_H = 20 +FONT_SIZE = 14 +PAD_X = 20 +PAD_Y = 34 + +# A line is a list of (text, colour, bold) segments. () means a blank line. +BLOCKED = [ + [("$ ", PROMPT, 0), ("cp examples/transaction_fault_unsafe.py payment_service.py", FG, 0)], + [("$ ", PROMPT, 0), ("git add payment_service.py", FG, 0)], + [("$ ", PROMPT, 0), ('git commit -m "Add payment deduction logic"', FG, 0)], + [], + [("[SentinelCommit] Auditing 1 staged file(s) via Claude (claude-opus-5)...", CYAN, 1)], + [], + [("=" * 68, RED, 1)], + [(" [COMMIT REJECTED] Hazard: TRANSACTION_FAULT Severity: CRITICAL", RED, 1)], + [("=" * 68, RED, 1)], + [], + [(" File: ", DIM, 0), ("payment_service.py", CYAN, 1)], + [], + [(" Why this is unsafe", YELLOW, 1)], + [(" The balance is debited and committed before the payment gateway", FG, 0)], + [(" is called. If the gateway raises or times out, the debit is", FG, 0)], + [(" already durable and no compensating action runs, so the customer", FG, 0)], + [(" is debited for an order that was never placed.", FG, 0)], + [], + [(" Suggested remediation", GREEN, 1)], + [(" | ", DIM, 0), ("try:", FG, 0)], + [(" | ", DIM, 0), (" with db.transaction():", FG, 0)], + [(" | ", DIM, 0), (" db.execute(", FG, 0)], + [(" | ", DIM, 0), (' "UPDATE users SET balance = balance - %s "', FG, 0)], + [(" | ", DIM, 0), (' "WHERE id = %s AND balance >= %s",', FG, 0)], + [(" | ", DIM, 0), (" (amount, user_id, amount),", FG, 0)], + [(" | ", DIM, 0), (" )", FG, 0)], + [(" | ", DIM, 0), (" payment_gateway.charge(user_id, amount)", FG, 0)], + [(" | ", DIM, 0), ("except PaymentError:", FG, 0)], + [(" | ", DIM, 0), (" db.rollback()", FG, 0)], + [(" | ", DIM, 0), (" raise", FG, 0)], + [], + [(" Fix and re-stage, or bypass with: git commit --no-verify", DIM, 0)], + [("=" * 68, RED, 1)], + [], + [("$ ", PROMPT, 0), ("git log --oneline -1", FG, 0)], + [("710d81e docs: touch examples readme", FG, 0), + (" <- no commit was created", DIM, 0)], +] + +PASSED = [ + [("$ ", PROMPT, 0), ("cp examples/transaction_fault_safe.py payment_service.py", FG, 0)], + [("$ ", PROMPT, 0), ("git add payment_service.py", FG, 0)], + [("$ ", PROMPT, 0), + ('git commit -m "Add payment deduction logic with transactional rollback"', FG, 0)], + [], + [("[SentinelCommit] Auditing 1 staged file(s) via Claude (claude-opus-5)...", CYAN, 1)], + [("[SentinelCommit] All semantic checks passed.", GREEN, 1)], + [("[feat/semantic-pre-commit-guardrail 545feca] Add payment deduction logic " + "with transactional rollback", FG, 0)], + [(" 1 file changed, 46 insertions(+)", FG, 0)], + [(" create mode 100644 payment_service.py", FG, 0)], + [], + [("$ ", PROMPT, 0), ("git log --oneline -1", FG, 0)], + [("545feca Add payment deduction logic with transactional rollback", FG, 0), + (" <- committed", DIM, 0)], +] + + +def escape(text): + return (text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + +def render(lines, title, accent): + widest = max((sum(len(t) for t, _, _ in line) for line in lines if line), default=0) + width = int(widest * CHAR_W) + PAD_X * 2 + 8 # +8 so the last glyph never touches the edge + height = len(lines) * LINE_H + PAD_Y + PAD_Y // 2 + + out = [ + f'', + f'', + f'', + f'', + '', + '', + '', + f'{escape(title)}', + '', + ] + + for row, line in enumerate(lines): + y = PAD_Y + row * LINE_H + x = PAD_X + for text, colour, bold in line: + if not text: + continue + weight = ' font-weight="bold"' if bold else "" + run_w = len(text) * CHAR_W + # Pin every run to its computed width. Without textLength the + # layout depends on whichever monospace font the viewer resolves, + # and anything wider than CHAR_W overflows the viewBox and clips. + out.append( + f'{escape(text)}' + ) + x += run_w + + out.append("") + return "\n".join(out) + "\n" + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + out_dir = os.path.join(here, "images") + os.makedirs(out_dir, exist_ok=True) + + targets = [ + ("commit-blocked.svg", BLOCKED, "SentinelCommit - commit blocked", RED), + ("commit-passed.svg", PASSED, "SentinelCommit - commit passed", GREEN), + ] + for name, lines, title, accent in targets: + path = os.path.join(out_dir, name) + with open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(render(lines, title, accent)) + print("wrote", os.path.relpath(path, os.path.dirname(here))) + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..58cd8bd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,76 @@ +# Example use cases + +Each hazard class the engine detects has a matching `*_unsafe.py` / `*_safe.py` +pair. Every pair is written so that **both files pass `ruff`, `flake8`, `mypy` +and `pylint` cleanly** — the defect is in runtime behaviour, not in syntax or +types, which is precisely the gap SentinelCommit exists to cover. + +| Hazard | Unsafe | Safe | Scenario | +| --- | --- | --- | --- | +| `TRANSACTION_FAULT` | [`transaction_fault_unsafe.py`](transaction_fault_unsafe.py) | [`transaction_fault_safe.py`](transaction_fault_safe.py) | Balance debited and committed *before* the payment gateway is called | +| `IDEMPOTENCY_RISK` | [`idempotency_unsafe.py`](idempotency_unsafe.py) | [`idempotency_safe.py`](idempotency_safe.py) | At-least-once payment webhook credits a wallet with no de-duplication key | +| `RACE_CONDITION` | [`race_condition_unsafe.py`](race_condition_unsafe.py) | [`race_condition_safe.py`](race_condition_safe.py) | Check-then-act seat reservation oversells; non-atomic counter duplicates references | + +## Try one + +From the repository root, with the hook installed: + +```bash +cp examples/transaction_fault_unsafe.py payment_service.py +git add payment_service.py +git commit -m "Add order payment flow" +``` + +Expected — the commit is refused and a remediation patch is printed: + +``` +==================================================================== + [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(): + ... + + Fix and re-stage, or bypass with: git commit --no-verify +==================================================================== +``` + +Now apply the fix and retry: + +```bash +cp examples/transaction_fault_safe.py payment_service.py +git add payment_service.py +git commit -m "Add order payment flow" +``` + +``` +[SentinelCommit] All semantic checks passed. +[feat/... 1a2b3c4] Add order payment flow +``` + +> **Note:** `git add` stages, it does not *un*stage. If you experiment with +> several examples in a row, run `git reset` between them so a previously +> staged unsafe file is not still part of the diff being audited. + +## Why a linter cannot find these + +| | Linter / type checker | SentinelCommit | +| --- | --- | --- | +| Undefined name, bad type, unused import | ✅ | not its job | +| Durable write before an unguarded network call | ❌ | ✅ | +| Handler that is not safe to deliver twice | ❌ | ✅ | +| Check-then-act window between a read and a write | ❌ | ✅ | + +A linter reasons about one file's syntax tree. Each defect above needs a model +of what happens *at runtime, under partial failure or concurrent execution* — +which is a semantic question, not a syntactic one. diff --git a/examples/idempotency_safe.py b/examples/idempotency_safe.py new file mode 100644 index 0000000..7f4fa21 --- /dev/null +++ b/examples/idempotency_safe.py @@ -0,0 +1,51 @@ +""" +Hazard class 2/3: IDEMPOTENCY_RISK -- expected verdict: PASS + +Idempotency is enforced by the database, not by application logic: + + * `processed_events.event_id` carries a PRIMARY KEY (or UNIQUE) constraint. + * The INSERT of the provider's event id happens *first*, inside the same + transaction as the side effects. `ON CONFLICT DO NOTHING` makes the second + delivery a no-op rather than an error. + * Because the claim and the effects share one transaction, two concurrent + deliveries cannot both win -- the loser's transaction sees the conflict. + +A prior `SELECT ... WHERE event_id = ?` check would NOT be sufficient: two +concurrent deliveries could both read "not processed" before either writes. +The uniqueness constraint is what makes this correct under concurrency. + +The email is sent only after the transaction commits, so a rollback cannot +leave a customer notified about a credit that was reversed. +""" + +from decimal import Decimal + + +def handle_payment_succeeded(db, mailer, event: dict) -> None: + """Credit a customer's wallet exactly once, however often we are called.""" + payload = event["data"]["object"] + user_id = payload["metadata"]["user_id"] + amount = Decimal(payload["amount_received"]) / 100 + + with db.transaction(): + # Claim the event. A duplicate delivery inserts zero rows and exits. + claimed = db.execute( + "INSERT INTO processed_events (event_id, received_at) " + "VALUES (%s, NOW()) ON CONFLICT (event_id) DO NOTHING", + (event["id"],), + ).rowcount + if claimed == 0: + return # Already handled; this is a retry. + + db.execute( + "UPDATE wallets SET balance = balance + %s WHERE user_id = %s", + (amount, user_id), + ) + db.execute( + "INSERT INTO ledger (user_id, amount, kind, idempotency_key) " + "VALUES (%s, %s, 'topup', %s)", + (user_id, amount, event["id"]), + ) + + # Only notify once the credit is durably committed. + mailer.send(user_id, template="topup_confirmed", amount=amount) diff --git a/examples/idempotency_unsafe.py b/examples/idempotency_unsafe.py new file mode 100644 index 0000000..df41bdb --- /dev/null +++ b/examples/idempotency_unsafe.py @@ -0,0 +1,37 @@ +""" +Hazard class 2/3: IDEMPOTENCY_RISK -- expected verdict: BLOCK (HIGH) + +Every payment provider -- Stripe, Adyen, Razorpay, PayPal -- documents at-least +once webhook delivery. A retry after a timeout, a network blip, or an operator +replaying an event from the dashboard all deliver the *same* event twice. + +This handler applies its side effects unconditionally. There is no +de-duplication key, no uniqueness constraint, and no already-processed check, +so a duplicate delivery credits the wallet twice and emails the customer twice. + +The bug is invisible in testing, because tests deliver each event exactly once. +It only appears in production, under exactly the conditions the provider +documents as normal. +""" + +from decimal import Decimal + + +def handle_payment_succeeded(db, mailer, event: dict) -> None: + """Credit a customer's wallet when a payment settles.""" + payload = event["data"]["object"] + user_id = payload["metadata"]["user_id"] + amount = Decimal(payload["amount_received"]) / 100 + + # No check for whether event["id"] has already been processed. + db.execute( + "UPDATE wallets SET balance = balance + %s WHERE user_id = %s", + (amount, user_id), + ) + db.execute( + "INSERT INTO ledger (user_id, amount, kind) VALUES (%s, %s, 'topup')", + (user_id, amount), + ) + db.commit() + + mailer.send(user_id, template="topup_confirmed", amount=amount) diff --git a/examples/race_condition_safe.py b/examples/race_condition_safe.py new file mode 100644 index 0000000..d936236 --- /dev/null +++ b/examples/race_condition_safe.py @@ -0,0 +1,49 @@ +""" +Hazard class 3/3: RACE_CONDITION -- expected verdict: PASS + +Both check-then-act windows are closed by making the check and the act a single +atomic operation rather than two steps: + +1. Overselling -- the seat guard moves into the UPDATE's WHERE clause + (`AND seats_remaining > 0`). The database evaluates the condition and + performs the decrement in one statement, under a row lock it takes itself. + A zero `rowcount` means another request won the race, and we surface that + as "sold out" instead of writing a negative count. `SELECT ... FOR UPDATE` + would be the equivalent fix where the guard cannot be expressed inline. + +2. Duplicate references -- the in-process counter is replaced by a database + sequence, which is transactional and concurrency-safe by construction. + A `threading.Lock` would only have fixed this within one process; a + sequence is also correct across the multiple workers that any real + deployment runs. +""" + + +class SoldOut(Exception): + """Raised when no seats remain for the event.""" + + +def reserve_seat(db, event_id: int, user_id: int) -> str: + """Reserve one seat for an event, safely under concurrency.""" + with db.transaction(): + # Check and act in a single atomic statement, guarded by the database. + claimed = db.execute( + "UPDATE events SET seats_remaining = seats_remaining - 1 " + "WHERE id = %s AND seats_remaining > 0", + (event_id,), + ).rowcount + if claimed == 0: + raise SoldOut(f"event {event_id} is sold out") + + # A sequence is atomic across threads *and* across worker processes. + reference = db.execute( + "SELECT 'BK-' || LPAD(nextval('booking_reference_seq')::text, 8, '0')" + ).scalar() + + db.execute( + "INSERT INTO bookings (event_id, user_id, reference) " + "VALUES (%s, %s, %s)", + (event_id, user_id, reference), + ) + + return reference diff --git a/examples/race_condition_unsafe.py b/examples/race_condition_unsafe.py new file mode 100644 index 0000000..075917c --- /dev/null +++ b/examples/race_condition_unsafe.py @@ -0,0 +1,51 @@ +""" +Hazard class 3/3: RACE_CONDITION -- expected verdict: BLOCK (HIGH) + +Two independent check-then-act (TOCTOU) windows, both invisible to a linter +because each line is correct in isolation and only their *interleaving* is +wrong. + +1. `reserve_seat` reads the remaining seat count, decides, then writes. Two + concurrent requests can both read `remaining = 1`, both pass the check, and + both write -- overselling the event. The read and the write are separate + statements with no lock between them. + +2. `_next_reference` increments a module-level counter without a lock. CPython's + GIL does not make `+= 1` atomic: it compiles to a load, an add, and a store, + and a thread switch between the load and the store loses an increment, + handing two bookings the same reference. +""" + +_reference_counter = 0 + + +def _next_reference() -> str: + """Allocate a booking reference.""" + global _reference_counter + _reference_counter += 1 # load, add, store -- not atomic + return f"BK-{_reference_counter:08d}" + + +def reserve_seat(db, event_id: int, user_id: int) -> str: + """Reserve one seat for an event.""" + remaining = db.execute( + "SELECT seats_remaining FROM events WHERE id = %s", + (event_id,), + ).scalar() + + # Between this check and the write below, another request can do the same. + if remaining < 1: + raise ValueError("sold out") + + db.execute( + "UPDATE events SET seats_remaining = seats_remaining - 1 WHERE id = %s", + (event_id,), + ) + + reference = _next_reference() + db.execute( + "INSERT INTO bookings (event_id, user_id, reference) VALUES (%s, %s, %s)", + (event_id, user_id, reference), + ) + db.commit() + return reference diff --git a/examples/transaction_fault_safe.py b/examples/transaction_fault_safe.py new file mode 100644 index 0000000..080c8d2 --- /dev/null +++ b/examples/transaction_fault_safe.py @@ -0,0 +1,46 @@ +""" +Hazard class 1/3: TRANSACTION_FAULT -- expected verdict: PASS + +The remediation is not "add a try/except" -- it is to make the durable write +and the external call share a single atomic outcome: + + * One transaction wraps the debit, the gateway call, and the order row, so + no partial state can survive. + * The balance guard (`AND balance >= %s`) is enforced by the database rather + than by a prior read, which also removes the check-then-act window. + * A gateway failure rolls the debit back before it is ever observable. + * The gateway call is the last durable action, so the blast radius of a + timeout is a rollback rather than a silent inconsistency. +""" + +from decimal import Decimal + + +class InsufficientFunds(Exception): + """Raised when the debit would take the account below zero.""" + + +def process_order(db, payment_gateway, user_id: int, amount: Decimal) -> None: + """Debit the customer's balance and charge their card, atomically.""" + try: + with db.transaction(): + # The database enforces the guard, so no check-then-act window exists. + 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(f"user {user_id} cannot cover {amount}") + + db.execute( + "INSERT INTO orders (user_id, amount, status) " + "VALUES (%s, %s, 'paid')", + (user_id, amount), + ) + + # Inside the transaction: a failure here rolls the debit back. + payment_gateway.charge(user_id, amount) + except Exception: + db.rollback() + raise diff --git a/examples/transaction_fault_unsafe.py b/examples/transaction_fault_unsafe.py new file mode 100644 index 0000000..8e926d8 --- /dev/null +++ b/examples/transaction_fault_unsafe.py @@ -0,0 +1,35 @@ +""" +Hazard class 1/3: TRANSACTION_FAULT -- expected verdict: BLOCK (CRITICAL) + +A linter sees nothing wrong here. Every name resolves, every type checks, and +there is no unreachable code. The defect is entirely in the *ordering of +durable effects*: + + 1. The balance is debited and the transaction is committed. + 2. Only then is the payment gateway called. + +If step 2 raises -- a timeout, a 5xx, a declined card, a dropped connection -- +step 1 has already been made durable and nothing reverses it. The customer is +debited for an order that was never placed. The failure is silent, it only +manifests under partial failure, and it corrupts financial state. +""" + +from decimal import Decimal + + +def process_order(db, payment_gateway, user_id: int, amount: Decimal) -> None: + """Debit the customer's balance and charge their card.""" + db.execute( + "UPDATE users SET balance = balance - %s WHERE id = %s", + (amount, user_id), + ) + db.commit() # <-- durable, and now irreversible + + # If this raises, the debit above has already been committed. + payment_gateway.charge(user_id, amount) + + db.execute( + "INSERT INTO orders (user_id, amount, status) VALUES (%s, %s, 'paid')", + (user_id, amount), + ) + db.commit() diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100644 index 0000000..37e0b48 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,56 @@ +#!/bin/sh +# SentinelCommit - pre-commit execution wrapper. +# +# Resolves the repo root so the hook works from any subdirectory, finds a +# Python interpreter that actually runs, and fails OPEN if it cannot: a broken +# guardrail must never be the reason a commit is refused. + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +SENTINEL="$REPO_ROOT/sentinel.py" + +if [ ! -f "$SENTINEL" ]; then + echo "[SentinelCommit] sentinel.py not found - skipping audit." + exit 0 +fi + +# Probe each candidate rather than trusting `command -v`. On Windows, PATH +# normally contains a Microsoft Store stub named python3.exe that exists, is +# executable, prints "Python was not found" and exits non-zero. Trusting the +# lookup hands that stub the commit decision and blocks every commit. +usable() { + [ -n "$1" ] || return 1 + "$1" -c "import sys; sys.exit(0)" >/dev/null 2>&1 +} + +PY="" +for candidate in \ + "$REPO_ROOT/venv/Scripts/python.exe" \ + "$REPO_ROOT/venv/bin/python" \ + "$REPO_ROOT/.venv/Scripts/python.exe" \ + "$REPO_ROOT/.venv/bin/python" \ + "$SENTINEL_PYTHON" \ + py python3 python +do + if usable "$candidate"; then + PY="$candidate" + break + fi +done + +if [ -z "$PY" ]; then + echo "[SentinelCommit] No working Python interpreter found - skipping audit." + echo " Set SENTINEL_PYTHON to an interpreter path to enable it." + exit 0 +fi + +SENTINEL_FORCE_COLOR=1 "$PY" "$SENTINEL" +STATUS=$? + +# Exit 1 is the engine's deliberate "block". Anything else - a crash, a signal, +# an interpreter failure - is our problem, not the developer's: fail open. +if [ "$STATUS" -eq 0 ] || [ "$STATUS" -eq 1 ]; then + exit "$STATUS" +fi + +echo "[SentinelCommit] Audit exited unexpectedly (status $STATUS) - commit allowed." +exit 0 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..138a84e --- /dev/null +++ b/install.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# SentinelCommit installer. +# +# Installs the pre-commit hook into the current repository: +# curl -sSL .../install.sh | sh (from a checkout: ./install.sh) +# +# Safe to re-run. Refuses to clobber an unrelated existing hook. + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "error: not inside a git repository" >&2 + exit 1 +} + +SRC_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +# `git rev-parse --git-path` returns a path relative to the repo root for a +# normal checkout, but an absolute one inside a linked worktree or when +# GIT_DIR is set. Only prefix the root when it is actually relative. +HOOK_DIR=$(git rev-parse --git-path hooks) +case "$HOOK_DIR" in + /* | [A-Za-z]:[/\\]*) ;; + *) HOOK_DIR="$REPO_ROOT/$HOOK_DIR" ;; +esac +HOOK="$HOOK_DIR/pre-commit" + +mkdir -p "$HOOK_DIR" + +if [ -e "$HOOK" ] && ! grep -q "SentinelCommit" "$HOOK" 2>/dev/null; then + echo "error: $HOOK already exists and was not installed by SentinelCommit." >&2 + echo " Move it aside, or chain it from the SentinelCommit hook." >&2 + exit 1 +fi + +cp "$SRC_DIR/hooks/pre-commit" "$HOOK" +chmod +x "$HOOK" + +echo "SentinelCommit installed -> $HOOK" + +if [ -z "$ANTHROPIC_API_KEY" ]; then + echo + echo "note: ANTHROPIC_API_KEY is not set. Until it is, the hook fails open" + echo " (commits are allowed unaudited). Set it with:" + echo " export ANTHROPIC_API_KEY=sk-ant-..." +fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4539aa6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +anthropic>=1.7.0 + +# Optional: ANSI colour support on legacy Windows consoles (conhost). +# Windows Terminal, Git Bash, macOS and Linux do not need this. +colorama>=0.4.6; sys_platform == "win32" diff --git a/sentinel.py b/sentinel.py new file mode 100644 index 0000000..d27f619 --- /dev/null +++ b/sentinel.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +""" +SentinelCommit - AI-powered semantic pre-commit guardrail. + +Two-tier gate on `git commit`: + 1. Deterministic gate (zero tokens): skip empty diffs and docs-only changes. + 2. Semantic gate (Claude): analyse the staged diff for high-severity runtime + hazards that linters and type checkers structurally cannot see. + +Exit codes: + 0 commit allowed (safe, skipped, or fail-open on infrastructure error) + 1 commit blocked (semantic hazard detected) +""" + +import datetime +import json +import os +import subprocess +import sys + +MODEL = os.environ.get("SENTINEL_MODEL", "claude-opus-5") +EFFORT = os.environ.get("SENTINEL_EFFORT", "medium") +TIMEOUT_S = float(os.environ.get("SENTINEL_TIMEOUT", "45")) +MAX_DIFF_CHARS = int(os.environ.get("SENTINEL_MAX_DIFF", "60000")) + +# Extensions that cannot contain a runtime hazard -> never worth a token. +DOC_EXTENSIONS = { + ".md", ".txt", ".rst", ".adoc", ".markdown", + ".json", ".lock", ".cfg", ".ini", ".toml", ".yaml", ".yml", + ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", ".pdf", +} + +# Extensionless files and dotfiles never match DOC_EXTENSIONS, because +# os.path.splitext("LICENSE") and splitext(".gitignore") both return an empty +# extension. Those are matched by name instead. Anything not listed here is +# treated as code and audited, which is the safe default. +DOC_FILENAMES = { + "license", "licence", "copying", "notice", "authors", "contributors", + "changelog", "codeowners", "readme", + ".gitignore", ".gitattributes", ".editorconfig", ".dockerignore", + ".env.example", ".npmrc", ".nvmrc", ".python-version", +} + + +# -------------------------------------------------------------------------- +# Terminal formatting +# -------------------------------------------------------------------------- + +def _supports_color(): + if os.environ.get("NO_COLOR"): + return False + # Git may hand a hook a non-tty stdout; the hook sets this so the demo + # banners still render in colour. + if os.environ.get("SENTINEL_FORCE_COLOR") == "1": + try: + import colorama + colorama.just_fix_windows_console() + except Exception: + pass + return True + if not sys.stdout.isatty(): + return False + if sys.platform == "win32": + try: + import colorama + colorama.just_fix_windows_console() + return True + except Exception: + # Windows Terminal and Git Bash handle ANSI natively; legacy conhost won't. + return os.environ.get("WT_SESSION") is not None or "TERM" in os.environ + return True + + +_COLOR = _supports_color() + + +def _c(code, text): + return "\033[" + code + "m" + text + "\033[0m" if _COLOR else text + + +def red(t): + return _c("1;31", t) + + +def green(t): + return _c("1;32", t) + + +def yellow(t): + return _c("1;33", t) + + +def cyan(t): + return _c("1;36", t) + + +def dim(t): + return _c("2", t) + + +# -------------------------------------------------------------------------- +# Tier 1: deterministic gate +# -------------------------------------------------------------------------- + +def _git(*args): + result = subprocess.run( + ["git"] + list(args), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + raise RuntimeError("git " + " ".join(args) + " failed: " + result.stderr.strip()) + return result.stdout + + +def get_staged_files(): + return [line for line in _git("diff", "--cached", "--name-only").splitlines() if line] + + +def get_staged_diff(): + # Exclude lockfiles and vendored trees so a dependency bump doesn't burn tokens. + return _git( + "diff", "--cached", "--no-color", "--unified=8", + "--", ".", + ":(exclude)*.lock", + ":(exclude)package-lock.json", + ":(exclude)node_modules/**", + ":(exclude)venv/**", + ).strip() + + +def is_docs_only(files): + """True when nothing staged can plausibly contain a runtime hazard.""" + for path in files: + name = os.path.basename(path).lower() + if name in DOC_FILENAMES: + continue + if os.path.splitext(name)[1] in DOC_EXTENSIONS: + continue + return False + return True + + +# -------------------------------------------------------------------------- +# Tier 2: semantic gate +# -------------------------------------------------------------------------- + +SYSTEM_PROMPT = """You are a staff engineer performing a pre-commit review of a git diff. + +You hunt for exactly three classes of high-severity defect that linters and type +checkers cannot detect, because each is a property of runtime behaviour rather +than syntax: + +1. TRANSACTION_FAULT - a database write (or other durable state mutation) whose + enclosing operation can fail partway through without a rollback or + compensating action, leaving persisted state inconsistent. The classic shape + is a durable local write followed by an unguarded network or third-party call. + +2. IDEMPOTENCY_RISK - a handler that can legitimately be delivered more than + once (webhook, payment capture, message-queue consumer, retried job) but + applies its side effect unconditionally, with no de-duplication key, + uniqueness constraint, or already-processed check. + +3. RACE_CONDITION - unsynchronised access to shared mutable state, or a + check-then-act (TOCTOU) sequence where the checked condition can change + before it is acted upon. A read-modify-write on a shared counter or balance + without a lock, an atomic operation, or a row-level SELECT ... FOR UPDATE counts. + +Rules that keep you useful rather than annoying: +- Judge only the ADDED lines (prefixed +). Surrounding context is background. +- Set should_block=true ONLY for a concrete, demonstrable risk of data + corruption, financial loss, or a production outage. Severity must be HIGH or + CRITICAL to justify blocking. +- Style, naming, formatting, missing docstrings, absent tests, performance nits + and general "could be better" observations are NOT blocking. If that is all + you find, return should_block=false with hazard_type NONE. +- Code that lives under a tests/ or spec/ directory, or whose body is purely + assertions and mock setup, is not a production path - do not block it for + hazards that only matter in production. Judge this by the code itself, not + by the filename. +- If uncertain, do not block. A false positive that stops a developer mid-flow + is worse than a missed low-confidence finding. + +When you do block, suggested_fix must be a concrete, ready-to-paste code snippet +in the same language as the diff that actually remediates the defect - not prose +describing what to do.""" + +VERDICT_SCHEMA = { + "type": "object", + "properties": { + "should_block": { + "type": "boolean", + "description": "True only for a HIGH or CRITICAL runtime hazard.", + }, + "severity": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + }, + "hazard_type": { + "type": "string", + "enum": [ + "TRANSACTION_FAULT", + "IDEMPOTENCY_RISK", + "RACE_CONDITION", + "NONE", + ], + }, + "file": { + "type": "string", + "description": "Path of the offending file, or empty string if none.", + }, + "reason": { + "type": "string", + "description": "Two or three sentences naming the exact failure sequence.", + }, + "suggested_fix": { + "type": "string", + "description": "Ready-to-paste remediated code, or empty string if none.", + }, + }, + "required": [ + "should_block", "severity", "hazard_type", "file", "reason", "suggested_fix", + ], + "additionalProperties": False, +} + +# Keyword rules backing SENTINEL_MOCK. Each entry is: +# (hazard, severity, signals, remediations, reason, fix) +# `signals` selects the rule; `remediations` clears it. Order matters - the +# first rule whose signals appear wins, with TRANSACTION_FAULT as the default. +# +# This is a demo aid, not a detector. It exists so the offline path produces a +# plausible verdict for each hazard class without a network call; the real +# analysis is always the model. +MOCK_RULES = [ + ( + "RACE_CONDITION", "HIGH", + ("global ", "seats_remaining", "_counter", "reserve_seat", "threading", + "sold out"), + ("nextval", "for update", "with lock", "lock()", "> 0"), + "The remaining count is read, checked, and only then written, so two " + "concurrent requests can both pass the check and both decrement - overselling " + "the event. The shared counter is also incremented without a lock, which is " + "not atomic in CPython and can hand two bookings the same reference.", + """with db.transaction(): + claimed = db.execute( + "UPDATE events SET seats_remaining = seats_remaining - 1 " + "WHERE id = %s AND seats_remaining > 0", + (event_id,), + ).rowcount + if claimed == 0: + raise SoldOut(event_id) + + reference = db.execute( + "SELECT nextval('booking_reference_seq')" + ).scalar()""", + ), + ( + # `event_id` is deliberately NOT a signal here: it is a common parameter + # name in code that has nothing to do with webhook delivery, and it made + # the race-condition example match this rule instead of its own. + "IDEMPOTENCY_RISK", "HIGH", + ("webhook", "event[", "handle_payment", "amount_received", + "queue", "consumer"), + ("processed_events", "idempotency_key", "on conflict", "already processed", + "do nothing"), + "This handler applies its side effects unconditionally, but the provider " + "documents at-least-once delivery. A retry after a timeout replays the same " + "event, crediting the wallet and emailing the customer twice. There is no " + "de-duplication key or uniqueness constraint to make the replay a no-op.", + """with db.transaction(): + claimed = db.execute( + "INSERT INTO processed_events (event_id, received_at) " + "VALUES (%s, NOW()) ON CONFLICT (event_id) DO NOTHING", + (event["id"],), + ).rowcount + if claimed == 0: + return # already handled; this is a retry + + db.execute( + "UPDATE wallets SET balance = balance + %s WHERE user_id = %s", + (amount, user_id), + )""", + ), + ( + "TRANSACTION_FAULT", "CRITICAL", + (), # empty signals = always matches, so this is the fallback + ("rollback", "db.transaction()", "with transaction"), + "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.", + """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""", + ), +] + +CLEAR_REASON = { + "IDEMPOTENCY_RISK": "The provider event id is claimed under a uniqueness " + "constraint inside the same transaction as the side effects, " + "so a replayed delivery is a no-op.", + "RACE_CONDITION": "The check and the act are a single atomic statement guarded by " + "the database, and the counter is a transactional sequence.", + "TRANSACTION_FAULT": "Durable writes are wrapped in an explicit transaction with a " + "rollback on the failure path. No unguarded side effects found.", +} + + +def _first_changed_path(diff_text): + """Pull the first file path out of a unified diff, for reporting.""" + for line in diff_text.splitlines(): + if line.startswith("+++ b/"): + return line[len("+++ b/"):].strip() + return "" + + +def mock_verdict(diff_text): + """Offline verdict for demos on unreliable venue Wi-Fi (SENTINEL_MOCK=1).""" + # Skip the `+++ b/path` header: it starts with '+' but is not added code, + # and a filename containing a marker word would otherwise skew the verdict. + added = "\n".join( + l for l in diff_text.splitlines() + if l.startswith("+") and not l.startswith("+++") + ).lower() + + for hazard, severity, signals, remediations, reason, fix in MOCK_RULES: + if signals and not any(s in added for s in signals): + continue + if any(r in added for r in remediations): + return { + "should_block": False, + "severity": "LOW", + "hazard_type": "NONE", + "file": "", + "reason": CLEAR_REASON[hazard], + "suggested_fix": "", + } + return { + "should_block": True, + "severity": severity, + "hazard_type": hazard, + "file": _first_changed_path(diff_text), + "reason": reason, + "suggested_fix": fix, + } + + +def analyze_diff(diff_text): + import anthropic + + client = anthropic.Anthropic(timeout=TIMEOUT_S, max_retries=1) + + if len(diff_text) > MAX_DIFF_CHARS: + diff_text = diff_text[:MAX_DIFF_CHARS] + "\n\n[diff truncated by SentinelCommit]" + + response = client.messages.create( + model=MODEL, + max_tokens=16000, + system=SYSTEM_PROMPT, + thinking={"type": "adaptive"}, + output_config={ + "effort": EFFORT, + "format": {"type": "json_schema", "schema": VERDICT_SCHEMA}, + }, + messages=[{ + "role": "user", + "content": "Review this staged git diff:\n\n```diff\n" + diff_text + "\n```", + }], + ) + + if response.stop_reason == "refusal": + raise RuntimeError("model declined to review this diff") + + # output_config.format guarantees a text block holding schema-valid JSON. + text = next(b.text for b in response.content if b.type == "text") + return json.loads(text) + + +# -------------------------------------------------------------------------- +# Reporting +# -------------------------------------------------------------------------- + +BAR = "=" * 68 + + +def _wrap(text, width): + words, lines, cur = text.split(), [], "" + for w in words: + if len(cur) + len(w) + 1 > width: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + lines.append(cur) + return lines or [""] + + +def report_blocked(v): + print() + print(red(BAR)) + print(red(" [COMMIT REJECTED] Hazard: " + str(v.get("hazard_type")) + + " Severity: " + str(v.get("severity")))) + print(red(BAR)) + if v.get("file"): + print("\n " + dim("File:") + " " + cyan(v["file"])) + print("\n " + yellow("Why this is unsafe")) + for line in _wrap(v.get("reason", ""), 64): + print(" " + line) + fix = (v.get("suggested_fix") or "").strip() + if fix: + print("\n " + green("Suggested remediation")) + for line in fix.splitlines(): + print(" " + dim("|") + " " + line) + print("\n " + dim("Fix and re-stage, or bypass with: git commit --no-verify")) + print(red(BAR)) + print() + + +def report_passed(v): + detail = "" + if v.get("hazard_type") != "NONE" and v.get("severity") in ("LOW", "MEDIUM"): + detail = dim(" (noted, non-blocking: " + str(v.get("hazard_type")) + ")") + print(green("[SentinelCommit] All semantic checks passed.") + detail) + + +# -------------------------------------------------------------------------- +# Audit log - one JSON object per line, consumed by dashboard.py +# -------------------------------------------------------------------------- + +def audit_log_path(): + """Resolve the audit log location, or None when logging is disabled.""" + configured = os.environ.get("SENTINEL_AUDIT_LOG", "") + if configured.lower() == "off": + return None + if configured: + return configured + try: + git_dir = _git("rev-parse", "--absolute-git-dir").strip() + except Exception: + return None + return os.path.join(git_dir, "sentinel-audit.jsonl") + + +def record(outcome, files=None, verdict=None, detail="", mock=False): + """Append one audit event. Never let logging break a commit.""" + try: + path = audit_log_path() + if not path: + return + entry = { + "ts": datetime.datetime.now(datetime.timezone.utc) + .replace(microsecond=0).isoformat(), + "outcome": outcome, + "files": files or [], + "detail": detail, + "mock": bool(mock), + "model": None if mock else MODEL, + } + if verdict: + entry.update({ + "hazard_type": verdict.get("hazard_type"), + "severity": verdict.get("severity"), + "file": verdict.get("file"), + "reason": verdict.get("reason"), + "suggested_fix": verdict.get("suggested_fix"), + }) + with open(path, "a", encoding="utf-8", newline="\n") as fh: + fh.write(json.dumps(entry) + "\n") + except Exception: + # An unwritable log must never be the reason a commit fails. + pass + + +def allow(message, files=None, detail=""): + """Fail open: never hold a developer hostage to our own infrastructure.""" + record("failed_open", files=files, detail=detail or message) + print(yellow("[SentinelCommit] " + message + " - skipping audit, commit allowed.")) + sys.exit(0) + + +# -------------------------------------------------------------------------- + +def main(): + try: + files = get_staged_files() + diff = get_staged_diff() + except Exception as exc: + allow("could not read staged changes (" + str(exc) + ")") + return + + # --- Tier 1: deterministic gate, zero tokens --------------------------- + if not files or not diff: + print(dim("[SentinelCommit] No staged code changes - nothing to audit.")) + sys.exit(0) + + if is_docs_only(files): + record("skipped_docs", files=files, + detail=str(len(files)) + " docs/config file(s), no API call") + print(dim("[SentinelCommit] Docs/config only (" + + str(len(files)) + " file(s)) - no API call made.")) + sys.exit(0) + + mock = os.environ.get("SENTINEL_MOCK") == "1" + has_creds = bool(os.environ.get("ANTHROPIC_API_KEY") + or os.environ.get("ANTHROPIC_AUTH_TOKEN")) + + if not mock and not has_creds: + allow("ANTHROPIC_API_KEY not set", files=files) + return + + # --- Tier 2: semantic gate -------------------------------------------- + label = "cached verdict [MOCK]" if mock else "Claude (" + MODEL + ")" + print(cyan("[SentinelCommit] Auditing " + str(len(files)) + + " staged file(s) via " + label + "...")) + + try: + verdict = mock_verdict(diff) if mock else analyze_diff(diff) + except Exception as exc: + allow("audit unavailable (" + type(exc).__name__ + ": " + str(exc) + ")", + files=files) + return + + if verdict.get("should_block"): + record("blocked", files=files, verdict=verdict, mock=mock) + report_blocked(verdict) + sys.exit(1) + + record("passed", files=files, verdict=verdict, mock=mock) + report_passed(verdict) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py new file mode 100644 index 0000000..4c51546 --- /dev/null +++ b/tests/test_sentinel.py @@ -0,0 +1,198 @@ +""" +Unit tests for the deterministic (Tier 1) gate and the offline mock verdict. + +The Tier 2 gate is not covered here: it calls a live model, so it is exercised +by the scenarios in examples/README.md rather than in CI. + +Run with: python -m unittest discover -s tests -v +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import sentinel # noqa: E402 + + +class TestDocsOnlyGate(unittest.TestCase): + """Tier 1 decides whether a diff is worth spending a token on.""" + + def test_markdown_only_is_docs(self): + self.assertTrue(sentinel.is_docs_only(["README.md", "docs/guide.md"])) + + def test_extensionless_licence_is_docs(self): + # os.path.splitext("LICENSE") -> ("LICENSE", ""), so an extension + # lookup alone would misclassify this as code and bill for it. + self.assertTrue(sentinel.is_docs_only(["LICENSE"])) + + def test_dotfiles_are_docs(self): + # splitext(".gitignore") -> (".gitignore", "") - same trap. + self.assertTrue(sentinel.is_docs_only([".gitignore", ".gitattributes"])) + + def test_nested_paths_match_on_basename(self): + self.assertTrue(sentinel.is_docs_only(["a/b/c/CHANGELOG", "x/y/.editorconfig"])) + + def test_case_is_insensitive(self): + self.assertTrue(sentinel.is_docs_only(["Licence", "ReadMe.MD"])) + + def test_python_is_code(self): + self.assertFalse(sentinel.is_docs_only(["payment_service.py"])) + + def test_one_code_file_taints_the_batch(self): + self.assertFalse(sentinel.is_docs_only(["README.md", "LICENSE", "app.py"])) + + def test_unknown_extensionless_file_is_treated_as_code(self): + # Safe default: audit anything we do not positively recognise. + self.assertFalse(sentinel.is_docs_only(["Dockerfile"])) + self.assertFalse(sentinel.is_docs_only(["scripts/deploy"])) + + def test_empty_list_is_vacuously_docs(self): + self.assertTrue(sentinel.is_docs_only([])) + + +class TestMockVerdict(unittest.TestCase): + """SENTINEL_MOCK=1 must drive both demo scenarios with no network.""" + + def test_unguarded_write_blocks(self): + diff = ( + "+def process_order(db, gateway, user_id, amount):\n" + "+ db.execute('UPDATE users SET balance = balance - %s', (amount,))\n" + "+ db.commit()\n" + "+ gateway.charge(user_id, amount)\n" + ) + verdict = sentinel.mock_verdict(diff) + self.assertTrue(verdict["should_block"]) + self.assertEqual(verdict["hazard_type"], "TRANSACTION_FAULT") + self.assertTrue(verdict["suggested_fix"].strip()) + + def test_transactional_write_passes(self): + diff = ( + "+def process_order(db, gateway, user_id, amount):\n" + "+ try:\n" + "+ with db.transaction():\n" + "+ db.execute('UPDATE users SET balance = balance - %s', (amount,))\n" + "+ gateway.charge(user_id, amount)\n" + "+ except Exception:\n" + "+ db.rollback()\n" + "+ raise\n" + ) + verdict = sentinel.mock_verdict(diff) + self.assertFalse(verdict["should_block"]) + self.assertEqual(verdict["hazard_type"], "NONE") + + def test_only_added_lines_are_considered(self): + # A rollback that is being REMOVED must not count as remediation. + diff = ( + "- db.rollback()\n" + "+ pass\n" + ) + self.assertTrue(sentinel.mock_verdict(diff)["should_block"]) + + def test_verdict_matches_the_declared_schema(self): + required = set(sentinel.VERDICT_SCHEMA["required"]) + for diff in ("+db.commit()", "+with db.transaction(): pass"): + verdict = sentinel.mock_verdict(diff) + self.assertEqual(set(verdict), required) + self.assertIn( + verdict["hazard_type"], + sentinel.VERDICT_SCHEMA["properties"]["hazard_type"]["enum"], + ) + self.assertIn( + verdict["severity"], + sentinel.VERDICT_SCHEMA["properties"]["severity"]["enum"], + ) + + +class TestMockClassifiesEveryExample(unittest.TestCase): + """ + Each examples/*_unsafe.py must map to its own hazard class, and each + *_safe.py must clear. This caught a real ordering bug: `event_id` is a + parameter in the race-condition example, so it matched the idempotency + rule first and reported the wrong hazard. + """ + + EXAMPLES = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "examples") + + CASES = [ + ("transaction_fault", "TRANSACTION_FAULT"), + ("idempotency", "IDEMPOTENCY_RISK"), + ("race_condition", "RACE_CONDITION"), + ] + + def _diff_for(self, name): + path = os.path.join(self.EXAMPLES, name + ".py") + with open(path, encoding="utf-8") as fh: + body = fh.read() + header = "+++ b/examples/" + name + ".py\n" + return header + "\n".join("+" + l for l in body.splitlines()) + + def test_unsafe_examples_block_with_the_right_hazard(self): + for stem, hazard in self.CASES: + with self.subTest(example=stem): + verdict = sentinel.mock_verdict(self._diff_for(stem + "_unsafe")) + self.assertTrue(verdict["should_block"]) + self.assertEqual(verdict["hazard_type"], hazard) + self.assertTrue(verdict["suggested_fix"].strip()) + + def test_safe_examples_clear(self): + for stem, _ in self.CASES: + with self.subTest(example=stem): + verdict = sentinel.mock_verdict(self._diff_for(stem + "_safe")) + self.assertFalse(verdict["should_block"]) + self.assertEqual(verdict["hazard_type"], "NONE") + + def test_blocked_verdicts_name_the_file(self): + verdict = sentinel.mock_verdict(self._diff_for("transaction_fault_unsafe")) + self.assertEqual(verdict["file"], "examples/transaction_fault_unsafe.py") + + def test_fallback_rule_has_no_signals(self): + # The last rule must match unconditionally, or some diffs get no verdict. + self.assertEqual(sentinel.MOCK_RULES[-1][2], ()) + self.assertEqual(sentinel.MOCK_RULES[-1][0], "TRANSACTION_FAULT") + + def test_every_rule_has_a_clear_reason(self): + for rule in sentinel.MOCK_RULES: + self.assertIn(rule[0], sentinel.CLEAR_REASON) + + +class TestAuditLog(unittest.TestCase): + def test_logging_can_be_disabled(self): + os.environ["SENTINEL_AUDIT_LOG"] = "off" + try: + self.assertIsNone(sentinel.audit_log_path()) + finally: + del os.environ["SENTINEL_AUDIT_LOG"] + + def test_explicit_path_is_honoured(self): + os.environ["SENTINEL_AUDIT_LOG"] = "/tmp/custom-audit.jsonl" + try: + self.assertEqual(sentinel.audit_log_path(), "/tmp/custom-audit.jsonl") + finally: + del os.environ["SENTINEL_AUDIT_LOG"] + + def test_record_never_raises_on_an_unwritable_path(self): + os.environ["SENTINEL_AUDIT_LOG"] = "/nonexistent-dir/nope/audit.jsonl" + try: + sentinel.record("passed", files=["a.py"]) # must not raise + finally: + del os.environ["SENTINEL_AUDIT_LOG"] + + +class TestTextWrapping(unittest.TestCase): + def test_never_returns_empty_list(self): + self.assertEqual(sentinel._wrap("", 40), [""]) + + def test_respects_width(self): + text = "the balance is debited before the gateway is called and never reversed" + self.assertTrue(all(len(line) <= 30 for line in sentinel._wrap(text, 30))) + + def test_preserves_every_word(self): + text = "one two three four five six seven" + self.assertEqual(" ".join(sentinel._wrap(text, 9)), text) + + +if __name__ == "__main__": + unittest.main()