DEV Community

Alexey Polyakov
Alexey Polyakov

Posted on

GSC: A Self-Learning SAST That Proves Vulnerabilities, Fixes Them, and Learns From Your Feedback

TL;DR — GSC (Git Security Checker) is a self-learning AppSec platform that doesn't stop at detecting a vulnerability. It proves it with a sandboxed exploit, fixes it with a minimal patch, verifies the fix by re-running the exploit, and heals the repo with an automated PR — then learns from your TP/FP verdicts to cut false positives over time. This post is a technical tour of the whole system: the detection engine, the proof/fix/verify loop, the adversarial AI-judging layer, and the closed-source/core split that ships it as a GitHub Action and an MCP server.


The problem with most SAST

A typical static analyzer hands you a snapshot: a flat list of findings that you then triage, verify, and fix by hand. Three pains follow immediately:

  1. No proof. "Possible SQL injection" is a hypothesis, not evidence. You still have to write the exploit yourself to know if it's real.
  2. False-positive fatigue. When every run surfaces hundreds of medium/low findings, developers learn to ignore the tool entirely.
  3. No loop closure. The scanner never learns which of its findings were right, so its precision never improves for your codebase.

GSC is built around closing exactly this loop. The pipeline is detect → prove → fix → verify → heal → learn, and every stage has a concrete mechanism behind it.


Try it in 10 minutes

Three entry points, depending on how you want to run it.

Local CLI:

git clone https://github.com/poliakarmai/gsc.git && cd gsc
pip install -e .
gsc external-scan https://github.com/user/repo --profile audit
Enter fullscreen mode Exit fullscreen mode

With exploit proof and verified fixes:

gsc external-scan ./repo --profile audit --with-poc --with-chains
Enter fullscreen mode Exit fullscreen mode

No LLM key? It still runs — GSC degrades to regex-only detection (finds vulnerabilities, just without confirmed verdicts). Add DEEPSEEK_API_KEY (or OPENROUTER_API_KEY, or LLM_BASE_URL for any OpenAI-compatible endpoint including a local Ollama) to switch on the adversarial judge.

GitHub Action — drop into any repo's CI:

- uses: poliakarmai/gsc@v1
  with:
    deep_scan: true
    with_poc: true
    fail_on_critical: true
Enter fullscreen mode Exit fullscreen mode

Scans every PR/push, posts findings as a PR comment, shows a score badge, blocks merge on CRITICAL.

MCP server — let your AI agent (Claude Code, Cursor) drive it:

{
  "mcpServers": {
    "gsc": {
      "command": "python3",
      "args": ["gsc_mcp_server.py"],
      "cwd": "/path/to/gsc"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The agent then calls scan_repo, list_findings, verify_finding inside its own context — scan, verify, fix, re-scan, without you parsing CLI output.


The closed loop, stage by stage

1. Detect — 65 detectors across four engines

Detection is a real engine, not an LLM wrapper. 65 detectors break down as 61 registry rules + 4 standalone engines (Secrets, SCA, IaC, Invariants). Coverage spans Python, JS/TS, Go, Java, Rust and more, with an AST/taint layer under the regex surface.

On top of raw detection sit the "surface" capabilities:

Layer What it covers
SAST 65 detectors, adversarial AI-judged
SCA dependency CVEs via OSV.dev with lock-file resolution (package-lock.json, yarn.lock, go.sum)
Secrets fingerprinting with cross-repo correlation and rotation detection — stores hashes only, never values
Honeytokens GSC_HONEYTOKEN_* canaries that emit a CRITICAL finding if they leak into any scanned repo
IaC Terraform, Kubernetes, Dockerfile misconfigurations
Supply chain SBOM (CycloneDX/SPDX), VEX, signature verification
Compliance CWE / OWASP / PCI DSS mapping on every finding

2. Prove — a working exploit, not a guess

The finding you see is not "might be exploitable" — GSC generates a working PoC for it and runs it in an isolated sandbox. A finding becomes evidence.

There's a safe-exploitation gate: PoC generation is blocked from emitting destructive commands (rm -rf, DROP TABLE, fork-bombs). To be precise — this is a denylist filter, not a sandbox guarantee: PoC execution is sandboxed at the process level (timeouts, resource limits), not full VM isolation. It reduces blast radius; it is not a substitute for running in an ephemeral container if you point GSC at code you don't trust.

3. Fix + Verify — Proof-of-Fix

Proof-of-Fix is the mechanism that separates "a patch was written" from "a patch that actually works":

PoF_verified = (exploit succeeds BEFORE) ∧ (exploit fails AFTER) ∧ (patch is minimal)
Enter fullscreen mode Exit fullscreen mode

The flow is deterministic: generate the exploit → produce a minimal patch → re-run the same exploit against the patched code in the sandbox. Only a fix that defeats its own exploit counts as verified. Two honest caveats: "minimal patch" is a diff-size heuristic, not a formal machine-checkable criterion, and a sandbox-verified fix is not the same as "safe to merge" — it proves the exploit is defeated, not that the patch is regression-free. That last part is still human review.

4. Heal — Self-Healing CI

A verified fix doesn't sit in a report — GSC can open a pull request with the patch automatically. The detect → prove → fix → verify → heal chain means the tool can carry a finding all the way to a mergeable PR without a human touching it.

5. Learn — your TP/FP verdicts tune the engine

This is the part that compounds. The Ground-Truth Trainer revalidates findings deterministically (zero LLM) against a calibration network of 16 projects (9 clean + 7 vulnerable), and auto-deactivates noisy patterns that fall below a 30% true-positive threshold. On top of that, federated learning shares DP-noised TP/FP signals across tenants to tune detectors globally. The mechanism is Laplace noise (sensitivity 1, ε=1.0 default) with per-tenant privacy-budget accounting (warn at ε=5.0, hard stop at ε=10.0). Only {tenant_hash, rule_id, tp, fp} ever leaves your environment — and tenant_hash is pseudonymous, not anonymous.


A finding, end to end

Concrete, not schematic. A user_id interpolated into an f-string SQL query:

# vulnerable.py
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
Enter fullscreen mode Exit fullscreen mode
  1. Detect — the SQLi rule flags it: SQL injection risk: f-string in query, severity HIGH.
  2. Prove — GSC generates a PoC (user_id = "1 OR 1=1 --") and runs it in the sandbox → the injection is exploitable.
  3. Judge — the adversarial debate returns REAL, confidence 95 (the Red advocate produces the exploit path; the Blue advocate finds no mitigation).
  4. Fix — a minimal patch parameterizes the query:
query = "SELECT * FROM users WHERE id = %s"
cursor.execute(query, (user_id,))
Enter fullscreen mode Exit fullscreen mode
  1. Verify — the same PoC is re-run against the patched code and now fails → verified.
  2. Heal — the patch opens as a PR you can review and merge.

The point isn't the SQLi (trivial) — it's that steps 2–5 happen automatically, and step 5 is the machine-checkable part: the fix defeats its own exploit.


Adversarial AI judging — killing hallucinated CVEs

Revalidating a finding with a single LLM call is how you get confident-looking false positives and made-up CVE/CWE identifiers. GSC uses an adversarial consensus instead:

Red Advocate   — argues this is a true-positive (exploit path, impact)
Blue Advocate  — argues this is a false-positive (mitigation, no taint)
Judge          — reads the transcript → {REAL | FALSE_POSITIVE | VERIFY_MANUALLY}
Enter fullscreen mode Exit fullscreen mode

Two details make this trustworthy rather than theatrical:

  • Grounded RAG. The judge receives real CVE/CWE references from a local FTS5 knowledge base instead of whatever it might hallucinate.
  • Cost-gating. A deterministic pre-filter assigns fp_probability (LOW/MEDIUM/HIGH) — confident cases skip the debate entirely, so expensive LLM calls fire only on genuinely ambiguous findings.

There's a BYO-LLM invariant underneath all of this: GSC never depends on its own model key. You bring your own (DeepSeek, OpenRouter, OpenAI-compatible, Ollama), or you bring no key at all — in which case GSC degrades honestly to regex-only: still finds vulnerabilities, just without confirmed verdicts. No key, no crash.


Prioritization that reflects real-world risk

Raw CVSS ranks a finding by its worst theoretical severity. GSC ranks by exploitability:

  • EPSS (probability of exploitation in the wild)
  • CISA KEV (known exploited vulnerabilities)
  • ExploitDB (public exploit availability)
  • FSTEC BDU cross-reference (Russian national vulnerability database)

The blocking engine then gates on severity ≥ HIGH ∧ confidence ≥ 80% — so a merge is blocked by findings that are both severe and provable, not by a flood of noise.


The intelligence layer

Beyond the detect→heal loop, a few capabilities round GSC out as a vulnerability-management platform rather than a scanner:

  • Security Archaeology — who introduced a vulnerability, when, and how long it lived before it was found (git blame on findings).
  • Predictive Forecasting — risk heatmaps of where the next vulnerability is likely to appear.
  • Exploit chains — compose individual findings into real attack paths.
  • MAESTRO threat model — a 7-level threat model for agentic systems (CSA-aligned), plus auto-generated architectural threat models.
  • Threat intel — export findings as STIX 2.1 / TAXII 2.1 (MISP, OpenCTI) and reconcile code against IoC feeds.
  • Red-team + agent-eval — 15 adversarial prompt-injection payloads stress-test the injection detectors; agent metrics (tool-selection recall/precision, retrieval accuracy@k/MRR/NDCG, catastrophic-forgetting, coherence) keep the AI-judging layer honest.

Privacy & threat model

What leaves your machine, and what doesn't:

  • Secrets are never stored as values — only a fingerprint (hash), and finding values are redacted in reports.
  • LLM revalidation sends finding snippets (rule, file path, the relevant code fragment) to your BYO model. It's your key, so traffic goes to your provider. Run fully local via LLM_BASE_URL → Ollama; you lose judge sharpness, not detection.
  • Federated learning uploads only {tenant_hash, rule_id, tp, fp} with Laplace noise (ε=1.0) and a per-tenant privacy budget (warn 5.0, stop 10.0). tenant_hash is pseudonymous, not anonymous.
  • Closed-source Docker image — the engine ships as ghcr.io/poliakarmai/gsc-scanner. We don't yet publish a signed SBOM / reproducible build for the scanner itself (admittedly ironic for a tool that checks your supply chain — it's on the roadmap).

Under the hood: one scan pipeline

repo (local / git / PR)
  ├─ inventory (multi-lang discovery, dir exclusions)
  ├─ detect (61 registry + 4 engines) ──► raw findings
  ├─ filter (disabled rules, noise tier, framework-aware)
  ├─ dedupe (finding_key = sha256(rule+file+snippet)[:12])
  ├─ LLM revalidate (BYO-LLM) ──► adversarial debate + RAG
  │      └─ verdict: REAL | FALSE_POSITIVE | VERIFY_MANUALLY + confidence
  ├─ V3 scoring (llm_confidence + tp/fp signals + file context)
  │      └─ confirmed ≥.80 | likely ≥.55 | uncertain ≥.35 | else FP
  ├─ attack-chain composer
  ├─ blocking engine (severity ≥ HIGH ∧ confidence ≥ 80%)
  └─ report (json / markdown / sarif / html) + PR comment + badge
Enter fullscreen mode Exit fullscreen mode

Three scan modes map to three budgets: quick (CI, regex-only), standard (daily, LLM), deep (full audit with exploit chains). Four profiles: developer-review, pr-gate, audit, candidate-review.


The architecture: public facade, closed core

This is the part most people ask about. GSC is deliberately split:

Part What it is License
Public repo poliakarmai/gsc action.yml (the GitHub Action recipe), documentation, MCP protocol Apache 2.0
Core engine gsc_core (detectors) + gsc_cli (scanners) + gsc_cloud (SaaS API) BUSL-1.1
Distributed image ghcr.io/poliakarmai/gsc-scanner — closed-source Docker image BUSL-1.1

The engine ships as a closed-source Docker image; the public repo carries only the action.yml that runs it. Licensing is BUSL-1.1 (source-available, not open source): scanning your own repositories is always allowed; operating a competing hosted SAST service requires a commercial license; and it converts to Apache 2.0 on the Change Date. Fair, and explicit about it.

Three distribution channels hang off the same core:

  1. GitHub Action — uses: poliakarmai/gsc@v1, scans every PR/push, posts findings, shows a score badge, blocks merge on CRITICAL.
  2. MCP server — scan_repo, scan_diff, list_findings, verify_finding, get_finding, list_detectors, so Claude Code / Cursor / any MCP-capable agent scans and verifies code inside its own context. Read-only with respect to your filesystem (it never edits your repo), but verify_finding does execute a PoC in the sandbox — so "read-only" means "no write access to your code", not "no code execution".
  3. SaaS API — multi-tenant FastAPI with SSO, workers, webhook (code ready, deployment in progress).

One more use worth spelling out: because the MCP surface is small and read-only, GSC also plugs into other tools as a SAST add-on — not just as a standalone scanner. A DAST-only setup or an autonomous pentest agent (e.g. ARTEX) does recon and dynamic testing but has a static-analysis blind spot; point it at GSC's MCP and it gains scan_repo → verify_finding → SAST findings to feed its exploit phase. GSC closes the gap most offensive/DAST tooling leaves open.


Current state (live numbers, pulled from the codebase at write time)

Metric Value
Version 1.4.1
Detectors 65 (61 registry + 4 engines)
Modules 258
DB schema 34
Tests 3227
Calibration network 16 projects (9 clean + 7 vulnerable)
Languages Python, JS/TS, Go, Java, Rust, and more

We don't yet publish our own precision/recall on public repositories — the calibration network is small (16 projects) and synthetic-benchmark transfer (OWASP Benchmark v1.2 is Java-only) doesn't reflect multilingual real-world code. Related work on the multi-agent judging approach exists (QASecClaw reports F1 78.4% → 90.9% on OWASP Benchmark v1.2), but we do not claim those numbers for GSC.


Limitations (stated honestly)

No SAST post should skip this section, so:

  • BUSL, not OSS. If your definition of "open source" requires OSI approval, GSC is source-available, not open source. It converts to Apache 2.0 on the Change Date.
  • Precision depends on your BYO-LLM. Without a key, GSC degrades to regex-only — honest, but coarser. With a weak model, the adversarial judge is only as sharp as the judge.
  • Proof ≠ every language. PoC auto-generation is strongest where the exploit sandbox has solid support; some languages fall back to detect + manual verify.
  • No published precision/recall yet. The 16-project calibration network tunes thresholds, but we haven't published reproducible precision/recall/F1 on public repositories — that's the strongest open question about the tool, and the first thing we owe a skeptical reader.
  • Performance & cost. deep mode with the adversarial judge is LLM-bound — slow on large monorepos, and BYO-LLM means your tokens and rate limits. quick mode (regex-only) is fast but noisier.
  • SaaS is code-ready, not deployed. The multi-tenant API exists but needs a server; today the practical paths are the Action and MCP.

What's next

On the near-term roadmap: deeper JS/XSS detection, expanding the MCP toolset (patch preview, SBOM), an IAST runtime validator for PoC/PoF, and the SaaS deployment (multi-tenant + OIDC). The self-learning loop — grounding on the calibration network, federated TP/FP tuning — is the axis the whole project keeps sharpening.


Who it's for

Good fit: GitHub-first teams who want proof + auto-fix, not just a list of findings; teams willing to spend their own LLM tokens on verification; bug-bounty and security-archaeology work; multi-agent setups (MCP).

Poor fit: air-gapped networks with no LLM (you get regex-only detection); non-GitHub CI without Docker; tiny projects where setup outweighs the value; teams that require OSI-licensed tooling; languages without PoC support (you get detect + manual verify).

Compared to Semgrep / CodeQL / Snyk: those are mature, open, and excellent at breadth and ecosystem coverage. GSC's differentiator is the closed loop — prove, fix, verify, learn — not raw detection breadth. The honest answer is usually: run Semgrep/CodeQL for coverage and GSC for the findings you actually need to close.

Glossary

  • SAST — static analysis (reads source, doesn't run it). SCA — software-composition analysis (dependencies). IaC — infra-as-code scanning.
  • PoC — proof-of-concept exploit. PoF — proof-of-fix (re-run the exploit against the patch).
  • TP / FP — true / false positive. EPSS — probability a CVE is exploited in the wild. KEV — CISA's Known Exploited Vulnerabilities.
  • SARIF — standard finding format (GitHub code scanning). MCP — Model Context Protocol (how agents call tools).

Conclusion

The difference between a scanner and a vulnerability-management platform is the loop. GSC's bet is that a tool should prove what it reports, fix what it proves, verify what it fixes, and learn from whether it was right. For a security engineer tired of triaging unverified findings by hand, that loop — not any single detector — is the feature worth looking at.

If you'd rather poke at it than read about it: the Action is poliakarmai/gsc, the MCP server is documented in the repo, and the scan is gsc external-scan <repo> --profile audit --with-poc --with-chains.

Top comments (8)

Collapse
 
peterbuildssecure profile image
Peter •

The limitations section is the part I'd trust most. On Proof-of-Fix: "exploit fails after the patch" can be satisfied by a patch that only breaks that one payload. An input filter that rejects the literal 1 OR 1=1 -- defeats the PoC and leaves the injection intact under a different encoding or context.

Two additions would tighten the definition. Re-run the exploit as a set of variants (encodings, case, a different injection point) and require all of them to fail. Also require a control request that should still succeed to still succeed after the patch, otherwise a patch that 500s on all input, or deletes the route, verifies perfectly. Both are cheap, and they turn "the exploit stopped" into "the vulnerability stopped and the feature still works".

Collapse
 
alexey_polyakov_cfe2095e3 profile image
Alexey Polyakov •

You're right, and it's the strongest critique of the piece.

The current verify_fix does exactly what you describe as insufficient: it runs one PoC, once. Looking at our sandbox code, the acceptance condition is literally "PoC triggers before the patch, does not trigger after" — reason="PoC triggers before fix, fails after fix". Nothing in that pipeline defends against a patch that only kills the specific payload we happened to generate. Your filter example (1 OR 1=1 -- blocked literally, injection intact under another encoding) would pass our own gate today. That's a real soundness hole in the proof, not a nitpick.

Your two additions map cleanly onto the existing stages, which is why I think they're the right fix and not extra machinery:

  1. PoC variants. We already have the concept in the codebase — gsc_pof_fuzzer.py holds a _MUTATIONS table (bitflip, null-byte, unicode junk, brace nesting, truncation) used for parser hardening. The same mutation set applied to the payload rather than the parser input gives us the variant family: encodings, case, alternative injection points. Acceptance becomes "all variants fail" instead of "the variant fails."

  2. Positive control. This is the half we're missing entirely. Our verifier has no notion of "the feature still works" — it only checks that the finding disappeared from the rescan and that the repo's tests pass, and tests are a weak proxy precisely because a patch that 500s on all input, or removes the route, can keep a thin test suite green while destroying the behaviour. A control request that must still succeed is the cheap, direct version of that check, and it's strictly stronger than "tests passed."

Both changes tighten the definition from "the exploit stopped" to "the vulnerability stopped and the feature still works," which is the only version of Proof-of-Fix worth the name. I'd rather ship the honest label than keep calling the weaker check a proof.

Concretely: variants as a mandatory gate (all must fail), control as a mandatory gate (must pass), and if either is unavailable for a given finding, the PoF should report verified=partial rather than verified=true. Marking a partial verification as full is how the category gets a bad reputation.

Filed as the next iteration on the verifier. Thanks for taking the time — this is the feedback that's actually useful.

Collapse
 
peterbuildssecure profile image
Peter •

"verified=partial" is the right label. Two more things to wire in. The mutation table gives you syntactic variants, but a patch can also close that payload's path while leaving another sink reachable from the same source. After the patch, re-run the taint query: does any source-to-sink path remain, regardless of payload? That's a static check alongside the dynamic one.

For the control request, record the full pre-patch response (status plus body shape) and compare after the patch instead of just checking for 2xx, otherwise a patch that returns an empty 200 passes.

Thread Thread
 
alexey_polyakov_cfe2095e3 profile image
Alexey Polyakov •

Hey — both points are in. Thanks for the review, honestly it was worth more than a dozen "looks good to me".

Control request: the gate now compares actual responses, not just "no error". It grabs the pre- and post-patch response and diffs (status, body_shape) — digits masked, so {"id": 42} vs {"id": 99} is still the same shape, but an empty 200 no longer sneaks through. Yeah, that was a real hole.

Taint-recheck: re-running the query is easy, the tricky part is what to do with the answer. "Is a sink still reachable?" flags almost every honest patch, since plenty of good fixes sanitize at the boundary and pass the value along. So I compare against the pre-patch baseline and only report paths the patch added:

python
residual = sorted(set(analyze(patched)) - set(analyze(vulnerable)))

That matches your exact case — patch closes os.system, but subprocess.run(y, shell=True) is still reachable from the same source. Nothing new → clean. New route → flagged.

Two caveats, since you'd spot them anyway: the analyzer is function-level (module-level code gets no baseline, so it skips rather than guesses), and it runs advisory — it never flips verified, just records taint_residual. A hard gate would fail correct patches, and a useful signal beats a wrong red light.

On verified=partial — didn't add it. proven already carries that: verified=True, proven=False means exactly "PoC stopped but gates didn't run", and it's in the exit JSON. A third boolean next to two siblings is just one more thing to misread at 3am.

Cheers 🙌

Thread Thread
 
peterbuildssecure profile image
Peter •

Diffing against the baseline is the right call. Two things would keep it honest. Key the paths by (source, sink kind) rather than function names, or a refactor that renames functions will either flag everything or hide a real new path.

And for the proven flag, put which gates ran in the exit JSON as a list rather than leaving it implicit. A consumer who sees proven=false can't tell "variants failed" from "variants never ran", and the list removes that guess.

Thread Thread
 
alexey_polyakov_cfe2095e3 profile image
Alexey Polyakov •

Ha, you got me twice in one comment. Let me not pretend I planned this.
The line-keying thing. Good news first: the residual isn't keyed by function names — it's a list of line numbers, so a rename can't break it. Bad news, and this is the one that matters: a rename is the easy case anyway. Move the file down three lines and the whole comparison starts lying in both directions — a genuinely new sink path disappears into the diff, or a sink that was always there suddenly gets reported as the patch's fault. Line numbers felt stable because they're numbers. They're not stable at all, they're just precise about their instability. (source, sink kind) is the right key and the analyzer already has both at the moment it matches — it just throws them away and keeps lineno. I'll fix it. Not today, but it's on the list and it's a real bug, not a nice-to-have.
The exit JSON thing you're right about, and I want to be careful here because I half-agree. The flags are already in there — variants_run, variants_applicable, feature_gate_run, taint_recheck_ran — but they sit next to proven like siblings and leave the reader to work out the family tree. Including the short-circuit: variants_applicable=false means the variants never ran, which is not the same as ran-and-passed, and today you'd only know that if you read the property's source. So yes, a gates: [{name, ran, passed}] list is going in. Additive only — everything that parses the old flags keeps working.
One thing I care about in the shape: a skipped gate has to show up with ran: false, never just be left out. Missing entry reads as "not applicable", false reads as "didn't happen", and for a proof tool those are different facts. Easy to get wrong when you build the list by appending as you go.
Thanks again — this is the fourth round and none of them were "looks good" rounds.

Thread Thread
 
peterbuildssecure profile image
Peter •

Two cheap additions. For the key change, add a metamorphic test: shift the file down a few lines and rename a function, and assert the residual is identical. It stays green for the right reason and fails if someone reintroduces line numbers.

For the gates list, give a skipped gate a reason, such as "no_variants_applicable", so a consumer can tell "not applicable" from "failed to start" without reading the code.

Thread Thread
 
alexey_polyakov_cfe2095e3 profile image
Alexey Polyakov •

Both changes are landed in commit a96d41ef.

On the metamorphic test:

Doing it exactly as originally phrased didn't quite work. Since the residual contains line numbers from the patched file, shifting the file naturally shifts those numbers. Instead, the assertion checks the (variable, sink call) pairs: their set must remain identical after a shift and rename. Right next to that, we explicitly assert that the raw line numbers must differ, ensuring the test can't silently pass on a file that never actually moved.

And it really does catch issues: I temporarily restored line-keyed diffing in a throwaway worktree, and it immediately failed with "clean patch flagged after shift/rename: (True, [6])".

On the gate reason:

All four gates now carry a reason, not just the variants gate. The codes are: poc_not_executed; no_variants_applicable / variants_not_run; the cause _feature_gate() already returned; and for taint: non_python_language / no_taint_rule / unparseable_source / no_baseline_taint.

There’s a strict invariant here: reason is "" if and only if ran is true. This is enforced directly inside the gates property, making it impossible to hand-build a result where a gate both ran and explains why it didn't. I did have to split one collapsed condition ("non-python or no rule") into two distinct reasons to clarify exactly what failed. The old two-tuple function was kept as a thin wrapper, so no call sites or existing tests needed to change.

One edge case worth flagging:

If a feature gate ran but failed, it carries no reason in its own entry. The failure text lives in the top-level reason, not inside the gate object itself. Let me know if you'd prefer it duplicated into the gate entry and I can move it.