🛡️ Sentinel — Deployment Risk Analyzer
One question, one answer: is this change safe to deploy?
Sentinel answers from your repository's own bug history — not a language model's guess.
┌─────────── Sentinel — deployment risk ────────────────────────┐
│ Score: 67/100 ██████████████████░░░░░ HIGH RISK │
│ 3 file(s) changed — since origin/main │
│ Scored by: trained model (4,854 commits) │
│ │
│ Top reasons: │
│ • auth/session.py — fixed 14 times in the last 2 years │
│ • Author has never touched this file before │
│ • Change spans 6 folders — high blast radius │
│ │
│ Blast radius: 12 files directly affected, 38 transitively │
│ │
│ → Hold this, or ship it behind a flag with a rollback ready.│
└───────────────────────────────────────────────────────────────┘
📑 Table of Contents
- Why Sentinel Exists
- What Makes It Different
- Install
- Quickstart (60 seconds)
- Mental Model: Two Phases
- The CLI
- How the Score Is Computed
- Learning From History: SZZ + the Model
- Blast Radius
- The AI Layer — Explanation Only
- MCP Server — Use Sentinel from Claude / Cursor
- GitHub Action — Score Every PR
- JSON Output (for scripting & CI)
- Configuration Reference
- Exit Codes
- Architecture
- Non-Goals (Deliberate Simplicity)
- FAQ
- Troubleshooting
- Glossary
- Author & License
🔍 Why Sentinel Exists
Every team has files they are quietly afraid of — the ones that broke production twice last year, that only one person truly understands, that nothing has tests for. You can feel that instinct during code review, but you can't put a number on it.
Sentinel puts a number on it.
It reads your repository's full git history, finds which past commits later had to be fixed, traces the bug-inducing commits with a line-level blame algorithm (SZZ), and builds a lightweight gradient-boosted model trained entirely on your own data. It then scores any incoming change on a 0–100 risk scale — using that model, or a transparent rule engine when no model exists yet.
The score is always deterministic. A language model can optionally narrate the findings in plain English, but it structurally cannot affect the number. Remove the API key and the score is identical — only the paragraph disappears.
💡 What Makes It Different
| Tool | How it decides risk |
|---|---|
| Linters | Style rules — know nothing about history |
| Coverage tools | Tell you what's tested — not what has historically broken |
| PR-review bots | Usually an LLM is the judge — opaque and unrepeatable |
| Sentinel | Trains on your repo's own bug record. The score is a probability from your history, not an opinion. |
Design principles baked into the code:
- No lookahead in training. A historical commit's features are built only from data available before that commit. The running history tally is applied after featurising, never before.
- Relative thresholds. Eight past bug fixes is remarkable in a young service and unremarkable in a fifteen-year-old library. Sentinel uses your repo's own percentile distribution as thresholds, not hardcoded absolutes.
- Blast radius is separate from the score. The dependency graph tells you what else can break, but it is deliberately not fed to the model (doing so honestly would require re-parsing the whole tree at every historical commit).
- The LLM is additive only. The AI layer can read the score. It cannot write to it — and that's enforced by the type system, not a prompt.
📦 Install
Requirements: Python 3.11+, and git on your PATH.
pip install (recommended)
pip install sentinel-risk
Installs everything in one step:
- The
sentinelCLI —sentinel scan,sentinel diff,sentinel train,sentinel evaluate,sentinel explain - The
sentinel-mcpbinary — starts the MCP server for Claude Desktop, Cursor, VS Code, and any other MCP client - All runtime dependencies (
lightgbm,pydriller,GitPython,lizard,networkx,shap,rich,fastmcp, …)
No Node.js or npm required.
💡 Zero-Path Alternative (Runs Everywhere)
If your terminal does not have Python's scripts folder on your PATH environment variable, you can run them directly as Python modules:
- For the CLI:
python -m sentinel <command>(e.g.python -m sentinel scan) - For the MCP server:
python -m mcp_server
🚀 Quickstart (60 seconds)
# 1. Install
pip install sentinel-risk
# 2. Go to any git repo you work in
cd /path/to/your/project
# 3. Score committed changes vs the default branch
sentinel scan
# 4. Or score what you haven't committed yet
sentinel diff
That's it — no configuration needed for the core score. Want plain-English explanations too? Run sentinel configure once, then add --explain.
🧠 Mental Model: Two Phases
Sentinel runs in two clearly separated phases. Training is optional — the rule engine works out of the box; training simply makes the score sharper for your codebase.
Phase 1 — Train (learn from history) · optional, run once per repo
1. Read git log → all commits in the window (default: 1,500)
2. Detect bug-fix commits → keyword match on subjects ("fix", "hotfix", …)
3. SZZ blame → for each fix, blame the deleted lines against the
parent to find the commits that introduced the bug
4. Label commits → blame-identified = 1 (risky), everything else = 0
5. Build feature vectors → 20 features per commit, from info available *at commit time*
6. Train LightGBM + early stop → ≤ 300 rounds, stops when held-out AP stops improving
7. Save model + metadata → .sentinel/model.txt + .sentinel/model.meta.json
Phase 2 — Score (assess a change) · every scan / diff / MCP call
1. Identify changed files → git diff vs base (scan) or working tree (diff)
2. Read file histories → bug-fix counts, churn, per-author ownership
3. Static analysis → cyclomatic complexity via lizard
4. Build feature vector → the SAME 20-feature function used in training
5. Score → trained model if present; rule engine otherwise
6. Blast radius → networkx dependency graph → direct + transitive impact
7. Report → rich terminal table, or JSON for CI
8. Explain (optional) → one LLM call for a plain-English narrative
Phase 2 never touches the network. Steps 1–7 are fully offline. Only step 8 — opt-in, via
--explain— makes an API call.
🖥️ The CLI
| Command | What it does |
|---|---|
sentinel scan |
Score committed changes vs the default branch (or --since <ref>) |
sentinel diff |
Score uncommitted work in the working tree (staged + unstaged) |
sentinel explain |
Score and narrate in plain English (shorthand for scan + --explain) |
sentinel train |
Mine bug history (SZZ) and train the LightGBM model into .sentinel/ |
sentinel evaluate |
Time-based train/test split; compare the model to a baseline |
sentinel configure |
Interactive wizard to set up an LLM provider and write .env |
sentinel version |
Print the installed version |
sentinel scan — committed changes
sentinel scan # vs auto-detected default branch
sentinel scan --since origin/main # vs a specific ref
sentinel scan --since HEAD~10 # last 10 commits
sentinel scan --all # rank every tracked file by inherent risk
sentinel scan --explain # also request the LLM narrative
sentinel scan --json # machine-readable JSON
sentinel scan --repo /other/project # analyze a different repository
sentinel diff — uncommitted work
sentinel diff
sentinel diff --explain
sentinel diff --json
sentinel explain — score and explain
sentinel explain # explains committed changes
sentinel explain --diff # explains uncommitted changes
sentinel explain --since HEAD~5
sentinel explain --json
sentinel train — build a model on your history
sentinel train # mine up to 1,500 commits (default)
sentinel train --max-commits 5000 # deeper history (slower — SZZ blame is the slow half)
sentinel train --repo /other/project
After training, every scan / diff automatically uses the model. Re-run it periodically (e.g. on a schedule in CI) as history grows.
sentinel evaluate — is the model actually good?
sentinel evaluate
sentinel evaluate --max-commits 3000
Example output:
Training rows: 3,621 Positives: 312 (8.6%)
Test rows: 1,207 Positives: 104 (8.6%)
Rule / baseline PR-AUC: 0.271
LightGBM model PR-AUC: 0.438 ✓ beats baseline
The split is always by time — newest commits in the test set, never a random shuffle. See Honest, time-based evaluation.
📐 How the Score Is Computed
The rule engine (default)
With no trained model, Sentinel uses a transparent, points-based engine. Every rule is a small pure function that either stays silent or contributes points with its own evidence string. The report isn't a separate rendering of the score — it is the list of rules that fired.
File-level rules (evaluated per changed file):
| Rule | Trigger (default) | Points |
|---|---|---|
| Hot file (many past fixes) | ≥ 8 bug-fix commits | +35 |
| Hot file | ≥ 3 bug-fix commits | +20 |
| Large edit | ≥ 300 lines changed | +25 |
| Sizeable edit | ≥ 80 lines changed | +12 |
| Missing tests | no test file found | +20 |
| Stale tests | tests exist but weren't touched | +10 |
| Very high complexity | peak cyclomatic complexity ≥ 20 | +22 |
| High complexity | peak CCN ≥ 10 | +12 |
| Author new to file | 0 prior commits by this author | +15 |
| Low ownership | author made < 25% of past commits | +8 |
| Churn | ≥ 25 commits ever touched it | +10 |
Change-level rules (evaluated once for the whole change; skipped for --all):
| Rule | Trigger (default) | Points |
|---|---|---|
| Broad change (files) | ≥ 10 files touched | +10 |
| Broad change (lines) | ≥ 600 lines total | +10 |
| Weekend deploy | committed Sat/Sun | +10 |
| Outside working hours | committed ≥ 22:00 or < 06:00 | +12 |
Every threshold and point value lives in one place (config.py → RiskWeights) and is overridable via SENTINEL_RULES — so you can re-tune policy without editing scoring logic.
Relative thresholds (how Sentinel adapts to your repo)
Absolute counts don't travel between repositories. When your repo has enough history (≥ 20 files with history), Sentinel raises the hot-file and churn thresholds to your repo's own percentiles:
| Signal | Percentile used |
|---|---|
| "Very hot" file (bug fixes) | 90th |
| "Hot" file (bug fixes) | 75th |
| High churn | 90th |
The configured absolutes act as floors (max(config, percentile)), so a young repo whose 90th percentile is a single fix never hands out the top tier for one fix, and a threshold can never get looser than your written policy. Disable with SENTINEL_DISTRIBUTION='{"enabled": false}'.
Score bands
| Band | Score | Meaning |
|---|---|---|
| 🟢 low | 0 – 34 | Safe to deploy with a normal review. |
| 🟡 medium | 35 – 64 | Deployable, but review properly and watch it after release. |
| 🔴 high | 65 – 100 | Hold it, or ship behind a flag with a rollback ready. |
How the headline number is assembled
The headline is the riskiest file's score, plus change-level context penalties — not a sum across all files. Summing would make any large refactor hit 100 and say nothing. A change is dangerous mainly because of the worst thing in it:
score = min(100, riskiest_file_score + Σ change-level penalties)
scan --all is different: there's no "change", so it ranks every tracked file by inherent riskiness and reports the worst — change-level and timing rules are switched off, because "it's Saturday" describes the clock, not a deploy.
🧬 Learning From History: SZZ + the Model
This is what turns Sentinel from a linter into a predictor. The only honest source of "what a risky change looks like" is your repo's own record of what it later had to fix.
The SZZ pipeline:
- Find commits whose subject reads like a bug fix.
- For each, blame the lines that the fix deleted against the parent commit — the commits that last touched those lines are the ones that introduced the bug.
- Those commits are labelled bug-inducing (1); everything else is clean (0).
PyDriller's get_commits_last_modified_lines is exactly this line-level blame, already written and tested — which is why it earns its place in the stack alongside the faster git log --numstat walk used for aggregate counts.
Bug-fix detection matches these keywords on word boundaries (so fix never matches prefix):
fix · fixes · fixed · fixing · bug · bugs · bugfix ·
patch · hotfix · defect · regression · crash · broken
Issue references only count when a fixing verb is attached (closes #123, fixes ABC-45). A bare #1234 is usually just a squash-merge PR number — treating those as bug evidence mislabelled 51% of commits on the requests repo, versus 16% with the stricter pattern. Over-labelling here doesn't just add noise; it corrupts every downstream training label.
Two honest caveats (both designed around by the time-based evaluation):
- Keyword-matched fixes are a proxy: a fix that says "tidy up" is missed; a feature commit that says "fix" is a false positive.
- Recent commits look cleaner than they are — nobody's found their bugs yet. That's exactly why the newest commits belong in the test set, never the training set.
The 20-feature vector
Training and prediction call one vector() function, so the two can never drift apart. Feature order is part of the saved model's contract (append-only, never reordered).
| # | Feature | Reads as |
|---|---|---|
| 1 | lines_added |
Lines added |
| 2 | lines_deleted |
Lines deleted |
| 3 | lines_changed |
Size of the change |
| 4 | files_changed |
Number of files touched |
| 5 | folders_touched |
Spread across folders |
| 6 | max_file_commits |
Change history of the busiest file |
| 7 | mean_file_commits |
Avg change history of files touched |
| 8 | max_file_bugfixes |
Bug-fix history of the worst file |
| 9 | mean_file_bugfixes |
Avg bug-fix history of files touched |
| 10 | new_files |
Brand-new files |
| 11 | min_author_ownership |
Familiarity with the least-known file |
| 12 | mean_author_ownership |
Familiarity with these files |
| 13 | files_author_never_touched |
Files the author never changed |
| 14 | max_complexity |
Complexity of the most complex file |
| 15 | mean_complexity |
Avg complexity of files touched |
| 16 | code_files_changed |
Source files touched |
| 17 | test_files_changed |
Test files touched |
| 18 | hour_of_day |
Hour of day |
| 19 | day_of_week |
Day of week |
| 20 | is_weekend |
Weekend |
Every input is knowable at the moment the change was made — no future commits, no current file contents, no blast radius (see No data leakage).
How the model is trained
Capacity is deliberately small — a repo yields a few hundred labelled commits with a few dozen positives, and a wide model just memorises the training period.
| Setting | Default | Why |
|---|---|---|
num_leaves |
7 | Small tree — avoids memorising |
learning_rate |
0.05 | Gentle boosting |
num_rounds |
300 | Ceiling, not target — early stopping decides |
early_stopping_rounds |
30 | Stop when held-out score plateaus |
early_stopping_metric |
average_precision |
ROC-AUC is dominated by the huge true-negative pool |
min_data_in_leaf |
30 | Prevents tiny, overfit leaves |
train_fraction |
0.75 | Oldest 75% train, newest 25% test |
holdout_fraction |
0.85 | Slice used to fit before early stopping |
seed |
42 | Reproducible runs |
Bug-inducing commits are a small minority, so the positive class is weighted by negatives / positives. Unweighted, LightGBM maximises accuracy by predicting "clean" for everything — a great-looking, useless model.
Measured example (
requestsrepo): stopping on ROC-AUC halted at 17 rounds → 0.407 held-out PR-AUC; stopping on average precision ran to 43 rounds → 0.438. That's why the metric choice is a first-class setting.
The trained model is a plain text file plus a small JSON sidecar recording the exact feature list. Load a model built against a different feature set and the columns still line up numerically but the predictions are nonsense — so the sidecar lets Sentinel detect that, warn, and fall back to the rule engine instead of silently scoring garbage.
Honest, time-based evaluation
sentinel evaluate enforces two non-negotiable rules:
- Split by time, never at random. Train on older commits, test on newer. A random split lets the model peek at commits that came after the ones it's tested on — the single easiest way to fake a good result.
- Report rare-event metrics against a baseline. With positives at a few percent, plain accuracy is ~95% for a model that always says "clean". Sentinel reports ROC-AUC and PR-AUC next to a lines-changed-only baseline — a model that can't beat "big diffs are risky" hasn't earned its complexity.
Both metrics are implemented from scratch (ROC-AUC via the Mann-Whitney rank identity, with tie handling) and unit-tested against hand-computed values — no scikit-learn dependency, and you can explain exactly how your headline number was produced.
No data leakage: the guarantees
- Historical features are built from data available before the commit; the running history tally is applied after featurising.
- Blast radius is excluded from the model on purpose. Feeding it in honestly would need the import graph as it stood at each past commit — every source file re-parsed at every commit. Using today's graph for a 2015 commit would leak present-day structure into a past feature — exactly what the time-based split exists to catch.
- Nothing derived from the future (later commits, current file contents) ever enters a feature.
💥 Blast Radius
Sentinel builds an in-memory import graph (networkx) and walks it backwards from the changed files — an edge points from importer to imported, so a change's dependents are its ancestors in the graph. In memory on purpose: a graph database would be permanent infrastructure for something rebuilt from scratch in under a second.
Three real-world messes are handled deliberately:
- Cycles (
aimportsbimportsa) terminate via a visited set, and files sitting in a cycle are flagged. - Hubs — a settings/types module imported by everything. Reporting "affects all 400 files" is true and useless, so depth is capped, listings are truncated (full counts kept), and hubs are named as hubs (≥ 20 direct dependents).
- Direct vs transitive dependents are never merged — importing the changed file directly is a different proposition from being five hops away.
Languages parsed for the dependency graph: Python (via ast, so relative/aliased/multi-line imports are handled correctly) and Java. Adding a language means adding one LanguageParser — the graph and traversal code never changes.
| Blast setting | Default |
|---|---|
max_depth (hops followed) |
3 |
max_listed (names per list) |
12 |
hub_dependents (hub cutoff) |
20 |
max_analyzed_files (per-file impact) |
40 |
max_files (graph size guard) |
5,000 |
max_bytes_per_file |
400,000 |
🤖 The AI Layer — Explanation Only
The LLM's job is to translate a finished analysis into human guidance. It is not a judge.
Why it structurally cannot move the score:
explain()receives an already-frozenChangeRisk, computed before it ran.- It returns an
Explanationobject that has no field for a score, band, or reason. - The result is rebuilt immutably, so the analysis that was scored is the analysis that's reported.
That's a guarantee from the type system, not a promise in a prompt — because prompts aren't a security boundary. Remove the API key and the score is byte-for-byte identical; only the narrative disappears.
Graceful by design. No key, a timeout, a rate limit, a malformed response — every failure degrades to the deterministic report with a note. A risk tool that can't answer without a third-party API is worse than one with no AI at all.
Provider-agnostic (any OpenAI-compatible endpoint):
# NVIDIA NIM (default)
NVIDIA_API_KEY=nvapi-xxxx
SENTINEL_LLM_MODEL=meta/llama-3.3-70b-instruct
SENTINEL_LLM_BASE_URL=https://integrate.api.nvidia.com/v1
# OpenAI
OPENAI_API_KEY=sk-...
SENTINEL_LLM_BASE_URL=https://api.openai.com/v1
SENTINEL_LLM_MODEL=gpt-4o
# Local Ollama
SENTINEL_LLM_API_KEY=anything
SENTINEL_LLM_BASE_URL=http://localhost:11434/v1
SENTINEL_LLM_MODEL=llama3.2
# DeepSeek
SENTINEL_LLM_API_KEY=sk-...
SENTINEL_LLM_BASE_URL=https://api.deepseek.com/v1
SENTINEL_LLM_MODEL=deepseek-chat
Key resolution order: SENTINEL_LLM_API_KEY → NVIDIA_API_KEY → OPENAI_API_KEY. Set any one with sentinel configure (interactive) or in .env.
The narrative is returned as four grounded sections — summary, rollout, rollback trigger, monitoring — and the model is instructed to use only the facts it's given (real file names and numbers), so it can't invent history.
🔌 MCP Server — Use Sentinel from Claude / Cursor
Sentinel exposes one MCP tool, get_deployment_risk, over stdio (the transport Claude Desktop and Cursor use). Built on FastMCP, so it also speaks SSE/HTTP for remote use.
Tool signature:
get_deployment_risk(
repo_path: str, # absolute path to the git repo
scope: str = "scan", # "scan" | "diff" | "all"
since: str | None = None, # e.g. "main", "HEAD~20" (for scope="scan")
explain: bool = False, # add the LLM narrative (needs a key)
) -> dict # the full risk report as JSON
The score is computed from your repo's history — never by a language model.
explaindefaults tofalse, so the tool is offline unless you opt in.
Claude Desktop
Edit claude_desktop_config.json
(Windows: %APPDATA%\Claude\claude_desktop_config.json · macOS: ~/Library/Application Support/Claude/claude_desktop_config.json)
{
"mcpServers": {
"sentinel": {
"command": "sentinel-mcp",
"env": { "NVIDIA_API_KEY": "nvapi-your-key-here" }
}
}
}
Alternative (Runs everywhere without PATH issues):
{
"mcpServers": {
"sentinel": {
"command": "python",
"args": ["-m", "mcp_server"],
"env": { "NVIDIA_API_KEY": "nvapi-your-key-here" }
}
}
}
Tip: GUI apps (Claude Desktop, Cursor, VS Code) may not inherit your shell PATH. If
sentinel-mcpisn't found, you can use thepython -m mcp_serveralternative above, or use the absolute path:
- Windows:
C:\Users\<you>\AppData\Roaming\Python\Python313\Scripts\sentinel-mcp.exe(or wherever pip installed it)- macOS/Linux:
~/.local/bin/sentinel-mcp
Cursor
Settings → MCP → Add:
- Name:
sentinel - Type:
command - Command:
sentinel-mcp(orpython -m mcp_server)
Remote HTTP / SSE (teams)
fastmcp run mcp_server/server.py --transport sse --port 8000
{ "mcpServers": { "sentinel": { "url": "http://localhost:8000/sse" } } }
Test it with the Inspector
# Using the global command:
npx @modelcontextprotocol/inspector sentinel-mcp
# Or via python -m (no PATH setup required):
npx @modelcontextprotocol/inspector python -m mcp_server
Open the printed URL, pick the sentinel server, and call get_deployment_risk interactively.
⚙️ GitHub Action — Score Every PR
A composite action posts (and updates) a single sticky comment on each PR with the score, reasons, and blast radius.
# .github/workflows/deployment-risk.yml
name: Deployment risk
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write # needed to post the comment
jobs:
risk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # REQUIRED — the score comes from history
- uses: Nishka30/SentinalScan@main
with:
threshold: "65"
fail-over-threshold: "false" # comment first, gate later
# explain: "true"
# nvidia-api-key: ${{ secrets.NVIDIA_API_KEY }}
fetch-depth: 0is not tidiness — it's required. A shallow clone hides commit history, so every file looks brand new and every score collapses.
Inputs: threshold (default 65), fail-over-threshold (default false), base-ref, comment (default true), explain (default false), nvidia-api-key, llm-model, python-version (default 3.12), github-token.
Outputs: score, band, json-path.
Two upgrades worth knowing:
- Train a model in CI and cache it — the model beats the rule baseline. Do it on a schedule, not every PR:
- uses: actions/cache@v4 with: { path: .sentinel, key: sentinel-model-${{ github.ref_name }} } - run: sentinel train --max-commits 5000 if: steps.cache.outputs.cache-hit != 'true'
- Gate merges by setting
fail-over-threshold: "true"and adding the check to branch protection. Start with a high threshold and lower it as the team trusts it — a gate that fires on everything gets ignored, and an ignored gate is worse than none.
🧾 JSON Output (for scripting & CI)
--json (or the MCP tool) returns a structured payload:
{
"score": 67,
"band": "high",
"recommendation": "Hold this, or ship it behind a flag with a rollback ready. …",
"scope": { "mode": "scan", "author": "…", "files_analyzed": 3,
"base_ref": "origin/main", "commits_walked": 4854, "model": { … } },
"reasons": [ { "rule": "hot_file", "label": "…", "points": 35,
"detail": "14 past bug-fix commits", "path": "auth/session.py" } ],
"files": [ { "path": "…", "score": 62, "band": "high", "reasons": [ … ] } ],
"blast": { "direct_count": 12, "transitive_count": 38, "hubs": [ … ] },
"explanation": { "available": false } // populated only with --explain
}
Pipe it straight into a gate:
pip install sentinel-risk
sentinel scan --json > risk.json
SCORE=$(jq '.score' risk.json)
[ "$SCORE" -gt 80 ] && { echo "::error::Risk $SCORE exceeds 80"; exit 1; } || true
🎛️ Configuration Reference
Everything is read from environment variables or a .env file. No module other than config.py touches the environment, so "never hardcode secrets" is checkable by reading one file.
LLM
| Variable | Description | Default |
|---|---|---|
NVIDIA_API_KEY |
NVIDIA NIM key | — |
OPENAI_API_KEY |
OpenAI key | — |
SENTINEL_LLM_API_KEY |
Key for any other provider | — |
SENTINEL_LLM_BASE_URL |
OpenAI-compatible base URL | https://integrate.api.nvidia.com/v1 |
SENTINEL_LLM_MODEL |
Model for explanations | meta/llama-3.3-70b-instruct |
SENTINEL_LLM_TIMEOUT |
Seconds to wait for prose | 60 |
SENTINEL_LLM_MAX_RETRIES |
Retries (scan should fail fast) | 1 |
SENTINEL_LLM_MAX_TOKENS |
Max tokens in the narrative | 800 |
SENTINEL_LLM_TEMPERATURE |
Sampling temperature | 0.2 |
Scoring, distribution, blast, training
| Variable | Overrides | Example |
|---|---|---|
SENTINEL_RULES |
any RiskWeights field |
{"file_large_lines": 150, "file_large_points": 30} |
SENTINEL_DISTRIBUTION |
percentile settings | {"enabled": false} |
SENTINEL_BLAST |
blast-radius limits | {"max_depth": 2} |
SENTINEL_TRAINING |
any TrainingSettings field |
{"max_commits": 5000} |
SENTINEL_BUGFIX |
bug-fix keyword/issue patterns | — |
SENTINEL_REPORT_TOP_FILES |
how many files the report lists | 10 |
All values are JSON objects, validated by pydantic — a typo fails loudly instead of being silently ignored.
🚦 Exit Codes
| Code | Meaning |
|---|---|
0 |
Success (a score was produced, or there was nothing in scope) |
2 |
Repository error (not a git repo, bad ref, --all combined with --since) |
3 |
Model error (e.g. no bug-inducing commits found to train on) |
Stable codes make Sentinel safe to wire into a pipeline — a crash is never mistaken for a passing check.
🏗️ Architecture
The core library imports neither typer nor rich and never touches the network. Three callers share it — the CLI, the MCP server, and the GitHub Action — and only one has a terminal, so anything that must be said rather than returned comes back as AnalysisResult.warnings.
sentinalScan/
├── sentinel/ # Core library — no terminal, no network
│ ├── analysis.py # Orchestrator → AnalysisResult
│ ├── git_reader.py # Low-level git (GitPython); default-branch + merge-base
│ ├── commit_log.py # git log --numstat walk (+ rename tracking)
│ ├── history_mining.py # SZZ labelling (PyDriller blame)
│ ├── features.py # Data model + the 20-feature vector()
│ ├── risk_rules.py # Transparent points-based engine
│ ├── model.py # LightGBM: train / predict / SHAP explain
│ ├── blast_radius.py # networkx dependency graph + impact walk
│ ├── static_analysis.py # Cyclomatic complexity (lizard) + test detection
│ ├── explain.py # LLM narrative (OpenAI-compatible) — read-only
│ ├── evaluation.py # Time-based split, ROC-AUC / PR-AUC vs baseline
│ ├── report.py # Rich terminal rendering
│ ├── pr_comment.py # Renders the GitHub PR comment + threshold gate
│ ├── serialization.py # JSON for --json and MCP
│ ├── results.py # AnalysisResult / Scope / Explanation
│ └── config.py # Every setting + tunable number (only env reader)
├── mcp_server/server.py # FastMCP wrapper — one tool, delegates to sentinel/
├── github-action/ # Composite action + example workflow
├── tests/ # One test file per module
└── pyproject.toml # Package metadata + console-script entry points
Data flow (scan):
git diff → git_reader → FileChange[]
↓
git log → FileHistory (per file)
↓
lizard → ComplexityInfo (per file)
↓
features.vector() ← same fn used in training
↓
┌── model.predict() ──────────── .sentinel/model.txt exists?
│ yes ↗ no ↘
│ LightGBM score rule engine
│ ↘ ↗
└────────────── ChangeRisk (score, band, reasons)
↓
blast_radius.compute() ← networkx walk
↓
AnalysisResult
↓
report.render() / serialization.to_dict()
↓ (optional, opt-in)
explain.explain() → LLM API → narrative
🚫 Non-Goals (Deliberate Simplicity)
Sentinel is intentionally small. Things it does not use, and why:
| Not used | Why |
|---|---|
| Vector database | Nothing needs embedding or semantic search |
| Redis / cache | A local scan is fast enough to recompute |
| Neo4j (graph server) | The blast graph is built in memory in < 1s |
| "AI agents" | The pipeline steps are just functions |
| Many MCP servers | Sentinel exposes one server with one clear tool |
Each cut removes months of maintenance and zero real value. That restraint is a feature.
❓ FAQ
Does sentinel scan call an LLM?
No. scan and diff are fully offline — the score comes from the LightGBM model (if trained) or the rule engine, both pure math. The LLM is only ever contacted with --explain or sentinel explain. Run with no API key set and the score is identical.
Wait — so what's the "model" in sentinel scan?
The LightGBM model — a gradient-boosted tree trained on your repo's own bug history. That's deterministic math, not a language model. The only LLM in the project is the optional explanation layer.
Why not just ask an LLM "is this risky"? A language model doesn't know your repo's history, defect patterns, ownership, or dependency graph — and it isn't repeatable. Sentinel measures those signals directly and gives the same answer twice.
What if my repo has almost no history?
The rule engine handles it out of the box. Training needs at least some bug-fixing commits; with none, train exits with a clear message (code 3).
Does it support my language? Complexity and test detection cover many languages (Python, Java/Kotlin/Scala/Groovy, JS/TS, Go, Ruby, Rust, C#, PHP, Swift, C/C++, …). The dependency graph (blast radius) currently parses Python and Java; other languages still get history, complexity, and test signals.
Will it slow my CI?
Scoring is seconds. Training is minutes (SZZ blame is the slow half) — so train on a schedule and cache .sentinel/ rather than on every PR.
Can the AI ever change the number? No. It receives a frozen result and returns prose with no score field. It's a structural guarantee, not a prompt instruction.
🛠️ Troubleshooting
| Symptom | Fix |
|---|---|
| Every file scores as if brand new (in CI) | Add fetch-depth: 0 to actions/checkout — a shallow clone hides history |
warning: the trained model was built from a different feature set … |
Sentinel updated its features; re-run sentinel train |
error: no bug-inducing commits were found |
Increase --max-commits, or check that commit messages mention fixes |
Claude/Cursor can't find sentinel-mcp |
GUI apps may not inherit PATH — use the absolute path to the binary |
| Explanation missing but score present | No API key / timeout / rate limit — expected; the score is unaffected |
| LLM call is slow | Swap SENTINEL_LLM_MODEL to a smaller/faster model; don't just raise the timeout |
📖 Glossary
- SZZ — the algorithm that links a bug-fixing commit back to the commit that introduced the bug, via line-level blame.
- Blast radius — the set of files that (transitively) depend on what you changed.
- Hot file — a file with an unusually high count of past bug fixes for this repo.
- Churn — how often a file changes, independent of bugs.
- Ownership — an author's share of a file's past commits.
- Cyclomatic complexity (CCN) — the number of independent paths through a function; more paths, more places to hide a bug.
- PR-AUC — area under the precision-recall curve; the right metric when positives are rare.
- Band — the low/medium/high bucket a score falls into.
👩💻 Author & License
Nishka Shrimali — @Nishka30
Licensed under the MIT License — see LICENSE.
If Sentinel saves you from a bad deploy, leave a ⭐ on GitHub.
Release files for sentinel-risk 0.1.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sentinel_risk-0.1.3.tar.gz | 123.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sentinel_risk-0.1.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 205.6 kB
Release files / sentinel_risk-0.1.3.tar.gz
| Download URL | sentinel_risk-0.1.3.tar.gz |
|---|---|
| Size | 123.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
26ac6f865df2d40e80fbd3dfd0903e21fe1926c73ed69a410619810ccd8c6b9a
|
|
BLAKE2b-256 checksum How to use checksums |
5aff83ad04f914ba470712f3caba81a184f43099eac6c9685b14db2a5e20ff4a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.2
|
Release files / sentinel_risk-0.1.3-py3-none-any.whl
| Download URL | sentinel_risk-0.1.3-py3-none-any.whl |
|---|---|
| Size | 82.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
395ae2554848558b35fc58d8c4811335dac3e6a4f09068a262896f452e574dfe
|
|
BLAKE2b-256 checksum How to use checksums |
14dbc71b011844f73cc9ded505623a97d9168689136d26dc5949abfd186e2c17
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.2
|