Security Regression Gate — block the rise, not the presence
How Sentinel decides which merge requests get stopped in CI: not "this file can run shell commands" (that's been true for months), but "this change just gave the file a power it did not have yesterday".
Where the numbers come from. Everything here was measured on 2026-10-12, on the
masterbranch of the Sentinel repository, using Node 24.19.0. I used three sources and I name the source at each number:
- Unit tests, run for real with
node --test. I did not counttest(lines with grep.- A replay of today's gate over all 118 merge requests ever merged into Sentinel's
master. This is deterministic, offline and takes one command (see Captured output).- An earlier evaluation on 16 public GitHub repositories and 5 GitLab ones, written up in the repo's design-partner notes. That one was produced by a throwaway script that lives outside the repo, so nobody has reproduced it independently yet. I say this again wherever I use those numbers.
There are no CI pipeline statistics in this article (how often the job actually failed on real MRs). I didn't have read access to the pipeline API while writing, so the article doesn't claim any.
Part 1 — For humans
The smoke detector that screams at candles
Imagine a smoke detector that goes off every time it sees a candle. Not smoke. Not fire. A candle that's been on the same table for three years.
The first day, you jump. The second day, you sigh. By the end of the week you've taken the battery out, and now you have no smoke detector at all.
Most "security scanners" in code review work this way. They read a file, see something dangerous-sounding ("this file can run other programs!"), and raise a flag. They raise it again the next time someone fixes a typo in that file, and the time after that. The file was always able to run other programs. The flag carries no new information, so people learn to ignore it. Once a team ignores the warnings, the scanner has stopped doing anything useful.
Sentinel's merge-request gate is built around the opposite question. It doesn't ask "is this file dangerous?" It asks:
"Did this change make the file more dangerous than it was before?"
That's the whole idea of this article. The rest is detail: how "more dangerous" is measured, what happens when the answer is yes, and the cases where the gate still doesn't see the problem. I list those too.
What Sentinel is, in one paragraph
Sentinel is an autonomous agent that watches a JavaScript codebase (its own, mostly) and decides, file by file, whether to leave it alone or propose a change. An earlier article in this series covered how it avoids re-thinking the same thought (the Novelty Gate). This one leaves the agent's head and goes to the front door of the codebase: the merge request. That's the moment a human (or an agent) asks "please add my change to the main branch". The gate lives in CI, the automated checklist that runs on every merge request. If the gate says no, the merge button stays grey.
The gate uses no AI. No language model is asked whether the code "looks risky". It's a plain program that reads code structure, and it gives the same answer every time you run it on the same input. That matters for a reason I'll come back to: you can argue with a deterministic gate, and you can reproduce its behaviour. You can't reproduce a mood.
A ladder with four rungs
To say "more dangerous", you need a scale. Sentinel uses four levels, decided purely by what the code can do:
| level | in plain words | examples |
|---|---|---|
| low | only computes, or only reads | math, formatting, reading a config file |
| medium | changes something outside itself | writing files, talking to the network, handling passwords or crypto |
| high | can destroy things | deleting files |
| critical | can do anything | running other programs, or running text as code |
That last row is the interesting one. A file that can start another program (a shell command, git, ffmpeg) or turn a string into running code (eval) has no meaningful upper limit on what it can do. So the ladder stops there. You can't be "more critical than critical".
Each changed file gets two readings: one before the merge request, one after. The gate compares the two.
The rule, in plain words
A merge request gets blocked when one of its files ends up at critical and at least one of these is true:
- It climbed. It was low, medium or high before, and now it's critical. "This file couldn't run programs yesterday. Today it can."
- It was already critical and gained a new kind of power. Say it could already run programs, and now it can also turn text into code. Same rung on the ladder, new trick. "This file could already run programs. Now it can also execute arbitrary strings."
A merge request does not get blocked when:
- a critical file is edited without gaining any new dangerous power (fixing a typo, renaming a variable, adding a log line),
- a dangerous power is removed (going down the ladder is always fine),
- a file moves up the ladder but stays below critical. This one is a deliberate choice, explained under The honest part.
Put another way, the gate reacts to the candle being lit. It doesn't react to a candle that has been sitting on the table for three years.
The permission slip
Some files should run programs. Sentinel's own report script has to ask git for the old and new version of each file, and there's no way to do that without running git. Blocking that file forever would be silly.
For these files the repository keeps a small permission-slip file called .sentinel-gate.json. Each entry says which file, why, and exactly which powers are accepted:
{
"acknowledged": {
"scripts/mr-report.js": {
"reason": "Reads git objects through execFileSync('git', ...) — the report has no other way to see both revisions of a file, and the arguments are never user input.",
"facts": ["child_process", "process_spawn"]
}
}
}
That's the real file from the repository (one of its two entries). It says: this file may import the process-spawning module and may spawn processes. It does not say "this file may do anything". If someone later adds eval to that file, the slip doesn't cover it and the gate blocks again.
The slip also sits in the repository, next to the code. Someone has to write the reason down, a reviewer can read it, and the version history shows who added it and when.
The honest part: what the gate does not see
A gate is only as useful as its known weaknesses are known. These are written down in the repo, and each one is pinned by a test that passes (more on that in Part 3):
1. Climbing slowly below the top rung. If one merge request takes a file from low to medium (it starts writing files), and the next takes it from medium to high (it starts deleting files), a gate set to "critical" says nothing. Neither step reached the top. The report still shows both climbs, and the team can set the gate stricter (high instead of critical) to catch exactly this. It's a dial, not a hidden bug. The CI job in Sentinel's own repo is set to critical, so in that repo deleting files is reported but not blocked.
2. Two files working together. The gate reads each file on its own. If file A gets a function that runs a program (blocked, or explicitly allowed once), and a later merge request makes file B call that function, file B looks harmless. File B didn't change its own powers, it just borrowed A's. Seeing that needs a map of who calls whom across the whole codebase. That's a different, bigger tool, and the project's own planning document explicitly lists it under "what not to build yet".
3. The old-style permission slip. Older slips just say "this file is fine, here's why", without a list of powers. They still work, so old setups don't suddenly break. The catch is that a new power on such a file is accepted too. The gate now prints that acceptance in the merge request comment, along with the power it absorbed, so nobody can say they weren't told. Sentinel's own slips use the precise form.
4. Code the parser can't read. If a file uses syntax the JavaScript parser doesn't understand (TypeScript-style type annotations in a .js file, for example), the report says "not analysed" for that file. It doesn't guess. The flip side is that an unreadable file is never blocked. I checked while writing this: a file that gains both child_process and a type annotation comes out as parsed: false and doesn't trip the gate. It is clearly marked in the report, but it isn't stopped.
There used to be two more items on this list. An audit in September found that (a) adding a second dangerous power to an already-critical file slipped through, because "critical → critical" counted as "unchanged". It also found that (b) a permission slip for "runs programs" quietly covered a later eval too. Both were turned into passing tests that documented the hole, and then both were fixed in a follow-up merge request. That's the rule from point 2 above ("already critical and gained a new kind of power") and the precise permission slip. That order — first prove the hole with a test, then close it — is how this project likes to work.
Does it actually catch anything?
Three different measurements, in increasing order of "you can check this yourself":
Other people's code (not independently reproduced yet). The detector was run over the last 15 merged pull requests of 16 well-known public JavaScript projects (express, fastify, axios, Ghost, strapi, and others). That's 140 pull requests with JavaScript changes. On the first run, it would have blocked 10 of them, and when someone read all 10 by hand, only 3 were real. The other 7 were false alarms. Precision: 30%. That was embarrassing, and it was useful. Every one of the false alarms had the same cause, which I'll explain in Part 2. After fixing that cause, the same 140 pull requests produced 3 blocks (2%), all 3 genuine, and every changed file that really runs commands was flagged: 28 of 28. On five GitLab-hosted projects it blocked 5 merge requests, all 5 genuine when read by hand.
Sentinel's own history (reproducible with one command). I replayed today's gate over all 118 merge requests ever merged into Sentinel's master. 93 of them changed JavaScript. The gate would have blocked 3. All three are real "this file can now run programs" moments: a capture tool that shells out to an external binary, a benchmark sample that spawns workers, and a test helper that runs a child process. Two more raises were waived by the permission slip (the report script itself and a CLI test). Not one of the 118 merge requests had the "already critical, gained a new power" pattern, so the fix from the audit has never fired on real history. It's pinned by tests, not by luck.
Live production. The gate runs in CI, not inside the running agent, so the production dashboard doesn't count gate blocks at all. I won't invent a number for it.
The other gate with the same name
The name of this article is a little unfair to a module that already exists. In Sentinel's source code, the class actually called SecurityRegressionGate isn't the CI gate. It's a sibling that lives inside the agent. When Sentinel proposes a change to its own code (say, "make this login check faster"), that sibling compares the old and new version and refuses the change if a security safeguard got weaker. Examples: a constant-time comparison disappeared, there are fewer input checks than before, or a "remember the answer" shortcut now skips the real password check.
It's the same philosophy from the other side. The CI gate blocks when danger goes up. The runtime gate blocks when protection goes down. Neither one cares that danger or protection exists. Both compare a before with an after. I cover it briefly in Part 2, because it shares the idea but not the code.
The 30-second version
- The gate compares each changed file before vs after the merge request.
- It blocks when a file reaches critical (can run programs or execute text as code), or gains a new dangerous power while already critical.
- Editing a dangerous file without making it more dangerous is free. Removing danger is always free.
- Intended risk is accepted per file and per power, with a written reason, in the repo.
- Known blind spots, all written down and tested: slow climbing below the threshold, two files cooperating, old-style slips, and files the parser can't read.
- No AI is involved and the result is deterministic: on Sentinel's own 118 merge requests it would have blocked 3, all genuine.
Part 2 — For developers
Where the gate sits
flowchart TD
MR["Merge request pipeline (GitLab CI, job: mr_report)"] --> FETCH["git fetch origin target branch (GIT_DEPTH: 0)"]
FETCH --> CHANGED["changedJsFiles(base, head): git merge-base + git diff --name-only --diff-filter=ACMRD"]
CHANGED --> BLOBS["blob(mergeBase, file) and blob(head, file) - missing revision = empty string"]
BLOBS --> EXTRACT["AstFacts.extract(before) / AstFacts.extract(after) - tree-sitter, per file"]
EXTRACT --> DIFF["diffFacts: set difference per fact family + riskDelta(riskOf(before), riskOf(after))"]
DIFF --> REPORT["report(): findings, factCount, riskRaised, highestRisk"]
REPORT --> GATED{"parsed AND risk.after >= level AND (direction == raised OR dangerous.added non-empty)?"}
GATED -- "no" --> PASS["not a candidate"]
GATED -- "yes" --> ACK{"covered by .sentinel-gate.json entry for this path?"}
ACK -- "facts list includes every added family, or legacy string" --> WAIVED["waived[] - printed in the MR comment"]
ACK -- "no entry, or a family outside facts" --> OFFENDER["offenders[]"]
OFFENDER --> EXIT["exit code 1 (unless MR_REPORT_GATE=off) - pipeline fails, allow_failure: false"]
PASS --> MD["render(): markdown report, tee mr-report.md artifact"]
WAIVED --> MD
EXIT --> MD
Two properties are worth stating before the code:
-
The pure layer (
libs/mr-report/index.js) does no I/O. No network, no LLM, no filesystem, no git. The caller hands it both revisions of every file as strings. There's a test that enforces this (Part 3). -
The only side effect of the CLI (
scripts/mr-report.js) is reading git objects and the optional acknowledgement file. It needs no credentials, so it runs on any plain checkout.
Step 1 — what changed, against what
// scripts/mr-report.js:78-88
function changedJsFiles(baseRev, headRev) {
const mergeBase = git(["merge-base", baseRev, headRev]).trim();
const names = git(["diff", "--name-only", "--diff-filter=ACMRD", mergeBase, headRev])
.split("\n")
.map(name => name.trim())
.filter(Boolean)
.filter(name => name.endsWith(".js"))
.filter(name => !name.startsWith("node_modules/"));
return { mergeBase, files: names.map(file => ({ path: file, before: blob(mergeBase, file), after: blob(headRev, file) })) };
}
Three details are doing real work here:
-
git merge-base, not the target branch tip. Ifmastermoved on after the branch was cut, diffing against the tip would credit (or blame) the merge request for other people's commits. The merge base is "what this branch actually started from". -
--diff-filter=ACMRDmeans Added, Copied, Modified, Renamed, Deleted. Deleted files go through the diff too, so removing a dangerous file shows up as a lowered risk instead of vanishing silently. - A missing revision is the empty string:
// scripts/mr-report.js:70-76
function blob(rev, file) {
try {
return git(["show", `${rev}:${file}`], "ignore");
} catch {
return "";
}
}
An empty source parses to empty facts, so an added file is simply "low → whatever it is now". That's the right baseline: a brand-new file that spawns processes did raise the risk of the repository.
Only .js is in scope. .ts, .mjs and .cjs aren't analysed today, and that's a deliberate, stated scope, not an oversight.
Step 2 — facts from syntax, not from substrings
Each revision goes through AstFacts.extract, a tree-sitter walk that records operations (booleans like processSpawn, dynamicEval, diskDelete) and derives two things from them:
// libs/core/ast-facts.js:553-561
function dangerousOf(operations) {
const dangerous = [];
if (operations.dynamicEval) dangerous.push("eval");
if (operations.childProcess) dangerous.push("child_process");
if (operations.processSpawn) dangerous.push("process_spawn");
if (operations.processExit) dangerous.push("process.exit");
if (operations.diskDelete) dangerous.push("destructive_fs");
return dangerous;
}
// libs/core/ast-facts.js:570-579
function riskOf(facts) {
const operations = facts?.operations || {};
if (operations.dynamicEval || operations.processSpawn || operations.childProcess) return "critical";
if (operations.diskDelete) return "high";
if (operations.diskWrite) return "medium";
if (operations.outboundHttp || operations.inboundHttp) return "medium";
if (operations.cryptoUse || operations.secretComparison) return "medium";
return "low";
}
dangerous is the list of fact families: eval, child_process, process_spawn, process.exit, destructive_fs. Acknowledgements use exactly these names, and they're what the gate compares when a file is already critical.
Note the third condition in the critical line: childProcess. It's set by the import alone, before anything is called:
// libs/core/ast-facts.js:348-362 (visitCall)
// require("child_process") / import("fs") / require("fs") -> imports
const importer = !object && (property === "require" || property === "import");
if (importer) {
const source = stringLiteral(callArgs[0]);
if (source) {
imports.add(source);
if (CHILD_PROCESS_MODULES.has(source)) {
const evidence = `${property}(child_process)`;
facts.operations.childProcess = true;
calls.process.add(evidence);
signal("child_process_import", evidence, node);
}
}
return;
}
with CHILD_PROCESS_MODULES = new Set(["child_process", "node:child_process"]). A bare const { exec } = require("child_process") is critical by itself. This is what makes the "import in one MR, call in the next" trick fail on the first MR (scenario 1a in the tests).
The bug that made precision 30%
The first version decided processSpawn by the callee's name. exec, spawn, fork → spawning. Then it met real code:
-
schemaAndHostRegExp.exec(url):RegExp.prototype.exec, probably one of the most-called methods in JavaScript, -
cluster.fork(), - a locally defined
function exec(script, stds)in pm2 that only wires up logging, - bundled editor code full of regex
.exec.
Seven out of ten blocks on public repositories were regular expressions and local helpers. If you've ever been paged because a regex was "executing", you know the feeling.
The fix was binding resolution: a call is process spawning when it's bound to a spawning module, not when it happens to share a name with one.
// libs/core/ast-facts.js:48-60
const SPAWN_FUNCTIONS = new Set([
"exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "fork", "execFileAsync"
]);
// `regex.exec(input)` and `cluster.fork()` share their names with the
// child_process API, so these two are only process spawning when the callee is
// bound to a spawning module. The rest of SPAWN_FUNCTIONS is unambiguous enough
// to flag on the bare name.
const SPAWN_ONLY_WHEN_BOUND = new Set(["exec", "fork"]);
const SPAWN_MODULES = new Set([
"child_process", "node:child_process", "cross-spawn", "execa", "shelljs", "zx"
]);
// libs/core/ast-facts.js:326-341
function isSpawnCall({ object, property, raw }, host, spawnBindings) {
if (!property) return false;
// Anything imported from a spawning module spawns, whatever it is called.
if (object ? spawnBindings.modules.has(host) : spawnBindings.functions.has(property)) return true;
if (!SPAWN_FUNCTIONS.has(property)) return false;
if (object) {
// require("child_process").exec(...), childProcess.spawn(...)
if (/child_process/.test(raw)) return true;
return !SPAWN_ONLY_WHEN_BOUND.has(property) && /^(child_?process|cp)$/i.test(host);
}
return !SPAWN_ONLY_WHEN_BOUND.has(property);
}
spawnBindings is collected in a pre-pass over require / destructuring (including renames like const { exec: run } = require("child_process")) / import statements. After the fix the same 140 public PRs blocked 3 instead of 10, and the "really executes commands" recall check (a file whose text both imports a spawning module and calls one of its functions) stayed at 28/28. Again: those numbers come from the design-partner write-up, produced by a script outside the repo, and haven't been reproduced independently.
Step 3 — diff the facts, then the risk
// libs/mr-report/index.js:62-69
function riskDelta(before, after) {
const direction = RISK_ORDER.indexOf(after) - RISK_ORDER.indexOf(before);
return {
before,
after,
direction: direction > 0 ? "raised" : direction < 0 ? "lowered" : "unchanged"
};
}
with RISK_ORDER = ["low", "medium", "high", "critical"]. Every fact family is diffed as a set (added = after \ before, removed = before \ after), never as a text diff. That's why a formatting-only change reports nothing: same sets on both sides, factCount: 0.
// libs/mr-report/index.js:76-91 (abridged)
function diffFacts({ path, before = "", after = "" }) {
const factsBefore = AstFacts.extract(before);
const factsAfter = AstFacts.extract(after);
const parsed = Boolean(factsBefore.ast && factsAfter.ast);
const finding = {
path,
parsed,
// routes, env, exports, imports: { added, removed } ...
dangerous: { added: added(factsBefore.dangerous, factsAfter.dangerous), removed: added(factsAfter.dangerous, factsBefore.dangerous) },
operations: operationDelta(factsBefore.operations, factsAfter.operations),
risk: riskDelta(AstFacts.riskOf(factsBefore), AstFacts.riskOf(factsAfter))
};
// ...
}
Notice that risk alone saturates: once a file is critical, direction can only be unchanged or lowered. That's exactly why the September audit found a hole, and why dangerous.added had to join the trigger.
Step 4 — the decision
// libs/mr-report/index.js:144-150
function gated(result, threshold) {
return result.findings.filter(f =>
f.parsed
&& RISK_ORDER.indexOf(f.risk.after) >= threshold
&& (f.risk.direction === "raised" || f.dangerous.added.length > 0)
);
}
Read it as a truth table, at --gate critical:
| before → after | dangerous.added |
candidate? | why |
|---|---|---|---|
| low → critical | child_process |
yes | raised to the threshold |
| critical → critical | [] |
no | level alone never triggers |
| critical → critical | process_spawn |
yes | new family at the threshold |
| critical → low | [] |
no | lowered |
| medium → high | destructive_fs |
no | below the threshold (--gate high: yes) |
anything, parsed: false
|
— | no | not analysed, not guessed |
Then acknowledgements:
// libs/mr-report/index.js:152-182
function acknowledgement(entry) {
if (typeof entry === "string") return { reason: entry, facts: null };
if (entry && typeof entry === "object" && typeof entry.reason === "string") {
return { reason: entry.reason, facts: Array.isArray(entry.facts) ? entry.facts : null };
}
return null;
}
function covers(ack, finding) {
if (!ack) return false;
if (ack.facts === null) return true;
return finding.dangerous.added.every(fact => ack.facts.includes(fact));
}
function gate(result, level = "critical", acknowledged = {}) {
const threshold = RISK_ORDER.indexOf(level);
const candidates = gated(result, threshold);
const offenders = [];
const waived = [];
for (const f of candidates) {
const ack = acknowledgement(acknowledged[f.path]);
if (covers(ack, f)) {
waived.push({ path: f.path, risk: f.risk, reason: ack.reason, facts: f.dangerous.added });
} else {
offenders.push(f);
}
}
return { level, offenders, waived, blocked: offenders.length > 0 };
}
Things worth noticing:
-
coversisevery, notsome. An ack forchild_processdoesn't cover a finding that addschild_processandeval. -
facts: nullmeans legacy. A bare string waives the whole path. That's the "old-style permission slip" from Part 1. It isn't silent:waived[]carries the absorbedfacts, andrenderGateprints every waiver. -
A malformed
.sentinel-gate.jsonmeans nothing is acknowledged, not everything:
// scripts/mr-report.js:49-57
function acknowledgedRisks() {
try {
const raw = fs.readFileSync(path.join(REPO_ROOT, ".sentinel-gate.json"), "utf8");
const parsed = JSON.parse(raw).acknowledged;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
A typo in the permission file fails closed: you get blocked, not waved through. That's the right direction for a security gate, and the annoying direction for whoever made the typo.
-
The ack is read from the checkout under analysis, so it's the merge request's own version of
.sentinel-gate.jsonthat counts. Adding an entry to that file is part of the diff and shows up in review. It's not a hidden config somewhere else. (It also means a merge request can acknowledge its own risk. The defence against that is a human reading the JSON in the diff. The gate makes the decision visible, but it doesn't take it away from people.)
What a block looks like
This is the real output of today's gate replayed on Sentinel's merge request !44 (the Orbit A/B capture tool), trimmed to the relevant lines. The exit code was 1:
## Sentinel MR report
33 fact(s) across 7 changed JavaScript file(s); highest risk after the change: `critical`.
Risk raised in 2 file(s): `libs/orbit-ab/orbit-capture.js`, `libs/orbit-ab/runner.js`.
### libs/orbit-ab/orbit-capture.js
- Risk raised: `low` → `critical`
- Dangerous operations introduced: `child_process`, `process_spawn`, `destructive_fs`
- Operations now present: `process spawning`, `child_process usage`, `destructive filesystem operation`, `filesystem write`, `filesystem read`
- Environment values newly read: `ORBIT_BIN`
- Exports added: `CAPTURE_FILE`, `LIMITATIONS`, `capture`, `load`, `save`
Blocked: 1 file(s) raised to `critical` risk or above, or gained a new dangerous operation there.
- `libs/orbit-ab/orbit-capture.js`: `low` → `critical` (child_process, process_spawn, destructive_fs)
If the risk is intended, record it in `.sentinel-gate.json` with a reason and the accepted `facts` (or set `MR_REPORT_GATE=off` for a one-off pipeline).
runner.js was also raised in that MR, but only low → medium (filesystem write, outbound HTTP), so it's reported and not blocked. For a file that was already critical and gained a family, the offender line reads stays `critical`, new (process_spawn) instead of a before → after arrow.
Wiring it into CI
# .gitlab-ci.yml:90-107
mr_report:
stage: benchmark
variables:
# merge-base needs real history; the default shallow clone has none.
GIT_DEPTH: 0
script:
- git fetch --quiet origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
# `set -o pipefail` — without it `tee` would swallow the gate's exit code.
- set -o pipefail
- npm run mr-report -- --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --gate critical | tee mr-report.md
artifacts:
when: always
expire_in: 1 month
paths:
- mr-report.md
# Blocks a merge request that raises a file to critical risk (dynamic eval,
# process spawn, child_process). Intended change: set MR_REPORT_GATE=off.
allow_failure: false
Two lines in there each cost a debugging session:
-
GIT_DEPTH: 0. GitLab's default shallow clone has no history, sogit merge-basehas nothing to find a common ancestor in. -
set -o pipefail.cmd | tee filereturnstee's exit code, andteeis always happy. Withoutpipefail, the gate would block, print "Blocked:", and then report success. That's a security gate that only works as a decoration.
MR_REPORT_GATE=off turns the exit code off for one pipeline but keeps the report. It prints Gate disabled by MR_REPORT_GATE=off — reporting only. so the bypass shows up in the log.
Small honesty note: the YAML comment above (and the CLI header's exit 1 if risk was raised there) describes the original August behaviour. The code now also blocks a new family on an already-critical file, as described in Step 4. The comments are behind the code, and the code is what runs.
The audit: proving the holes before closing them
In September a reviewer asked the right question: does the gate still catch a risky capability if it's introduced gradually across separate merge requests? The answer was produced in two merge requests, in a deliberate order.
!104 — pin current behaviour, change nothing. Four scenarios became tests in tests/unit/mr-report-gradual.test.js, plus a write-up in docs/GATE-GAPS.md. The tests asserted what the gate actually did, including the bad parts. Two of them were real holes:
-
1b. File imports
child_process(MR1, blocked or acked), then gainsexec(cmd)(MR2).critical → critical,direction: "unchanged", not blocked, not even listed as waived. -
2. Path acknowledged for spawning, later MR adds
eval. The ack was per path, so it absorbed the new family silently:blocked: false, waived: [].
!105 — close exactly those two. gated() gained || f.dangerous.added.length > 0, and acknowledgements gained the facts list. The tests for 1b and 2 were flipped from "pins the gap" to "pins the fix". Nothing else changed.
Today's table (every row is pinned by a passing test):
| # | scenario | caught today? | status |
|---|---|---|---|
| 1a | import-only MR | yes | works |
| 1b | new spawn call on an already-critical file | yes (was: no) | fixed in !105 |
| 2 | acked path gains a new family (eval) |
yes with a facts ack (was: no); legacy string ack waives visibly
|
fixed in !105 |
| 3 | low → medium → high under --gate critical
|
no, by threshold | documented; use --gate high
|
| 4 | B calls a spawning function in A | no | declared out of scope |
Scenario 4 deserves a sentence of its own. diffFacts is strictly per file. File B's require("./a") is present before and after, and calling a.build() adds no dangerous operation to B. So factCount: 0, low → low. The fix would be a cross-file call graph, which the project's monetization notes list under "What not to build yet": an existing tool (Orbit Local) already does it well, so the plan is to integrate rather than reimplement. The gate's job description is "per-file set differences", and it does exactly that job.
Probes I ran while writing this (not pinned by tests)
To be concrete about the edges, I ran a few one-off inputs through report() + gate("critical") with a clean before. These were not committed as tests, so treat them as observations from 2026-10-12, not guarantees:
after contains |
parsed | risk | added | blocked |
|---|---|---|---|---|
require("child_process") + exec() + a : number type annotation |
false | low → low | — | no |
require(m) with m built at runtime, then cp.spawn("ls")
|
true | low → critical | process_spawn |
yes |
new Function("return 1") |
true | low → critical | eval |
yes |
globalThis["ev" + "al"]("1+1") |
true | low → low | — | no |
(0, eval)("1+1") |
true | low → low | — | no |
import { execFile } from "node:child_process" + call |
true | low → critical | process_spawn |
yes |
fs.rmSync(...) |
true | low → high | destructive_fs |
no (by threshold) |
The require(m) case is caught only because spawn is an unambiguous name. If that line had used exec, binding resolution would have nothing to bind to. Computed-property eval and indirect eval aren't detected. If someone is deliberately hiding eval from a syntax scanner, a syntax scanner won't catch them. That's not a surprise, and I'm saying it outright so nobody reads "deterministic AST facts" as "can't be fooled".
Replaying the gate over Sentinel's own history
Same gate, same code as today, applied to every first-parent merge on master (base = merge^1, head = merge^2). Acknowledgements come from today's .sentinel-gate.json, so this is "today's gate on yesterday's MRs", not what CI said at the time:
| value | |
|---|---|
| merge requests replayed (!1 … !123, 2026-07-16 → 2026-10-11) | 118 |
…that changed at least one .js file |
93 |
| …with at least one fact | 57 |
| median facts per JS-changing MR | 2 |
| median facts per fact-bearing MR | 9 |
| MRs where some file's risk was raised | 26 |
would be blocked at --gate critical
|
3 (!33, !41, !44) |
| waived by an acknowledgement | 2 (!48, !58) |
| "already critical, new family" (scenario 1b) occurrences | 0 |
| unparsable files | 0 |
The three blocks:
-
!33
tests/unit/test-isolation.test.js: low → critical (child_process,process_spawn) -
!41
ir-benchmarks/samples/report-worker.js: low → critical (child_process,process_spawn,process.exit,destructive_fs) -
!44
libs/orbit-ab/orbit-capture.js: low → critical (child_process,process_spawn,destructive_fs)
All three are genuine. These files really do run child processes, which is the gate's own definition. Whether a human should have been stopped is a separate judgement. Here the honest answer is "an acknowledgement with a reason would have been the right outcome", which is exactly the workflow the gate is designed around. All three predate the gate itself, which was introduced in !49.
One discrepancy I want to flag rather than smooth over: the design-partner notes say Sentinel's own MRs had a median of 13 facts. Today's full-history replay gives 2 (all JS-changing MRs) or 9 (fact-bearing MRs only). The 13 came from the earlier outside-repo script, and its window and method aren't recorded in the repo. I'm reporting the number I can reproduce.
The runtime sibling: SecurityRegressionGate
Inside the agent, on the EVOLVE path (after the syntactic ShadowGuard, before simulation and governance), a separate module asks the inverse question: did this patch weaken a protection that was there before?
// libs/core/security-regression-gate.js:73-100 (abridged)
evaluate({ oldCode = "", newCode = "", envelope = {} } = {}) {
if (!newCode) return { regression: false, reason: "no-new-code" };
if (!oldCode) return { regression: false, reason: "no-baseline" };
const keepSecurity = isSecuritySensitive(envelope);
// 1. A security primitive present in the baseline is dropped/reduced.
for (const primitive of SECURITY_PRIMITIVES) {
const before = countOccurrences(oldCode, primitive);
const after = countOccurrences(newCode, primitive);
if (before > after) {
const tag = primitive.replace(/[^a-zA-Z0-9]/g, "");
return { regression: true, reason: `security_regression:weakened-${tag}`, keepSecurity };
}
}
// 2. Input validation / error handling removed.
if (countThrows(oldCode) > countThrows(newCode)) {
return { regression: true, reason: "security_regression:removed-validation", keepSecurity };
}
// 3. (keep_security targets only) a new `cache[...]` + `return true` shortcut -> verification-bypass
// ...
}
SECURITY_PRIMITIVES is a list of names like timingSafeEqual, pbkdf2, scrypt, randomBytes, createHmac, bcrypt, argon2, sanitize, escapeHtml, csrf, helmet. The executor turns a regression into a hard local REJECT, records a SECURITY_REGRESSION trauma and rollback, and attributes the rejection to "security-regression-gate" in analytics.
Same shape as the CI gate: before vs after, react to the delta. Different instrument: this one uses substring and throw counts, not an AST. It's coarse on purpose, and it fails in the safe direction. A false REJECT only postpones an auto-evolution, and the module's own header says so ("a false REJECT only defers an auto-evolution (safe direction)"). Two adversarial benchmark cases (adv-conflicting-intent-keep-security, adv-security-regression-gap) exercise it end to end in the decision benchmark.
What I can say from production: GET /analytics on 2026-10-12 10:05 UTC (instance 029d5693) reports 10 rejections in total, and provenance.rejectionsByGate is {"unknown": 10}. None of the retained rejections is attributed to security-regression-gate. That's not evidence the gate works and not evidence it doesn't. Per-gate attribution was added in !106, and these 10 rows carry no gate name. The evidence for this module is its 6 unit tests and the two benchmark cases.
What's proposed and not built
-
A
--gate highdefault for repositories where deleting files matters. It's a one-word change in CI, but it's a policy decision for each team, not a code fix. -
Gating unparsed files. Today
parsed: falseis never a candidate. Failing closed on "I couldn't read the new revision of a file that changed" would close probe row 1, at the cost of blocking every Flow-annotated file in a codebase. Not decided, not implemented. - A cross-file call graph. Explicitly not planned: integrate an existing tool rather than build one.
Part 3 — Tests
All counts below come from node --test --test-reporter=tap on 2026-10-12, Node 24.19.0.
tests/unit/mr-report-gradual.test.js — 7 tests, 7 pass
The September audit file. Each test pins one scenario from the table above:
ok 1 - gradual 1a: an import-only MR is already critical and trips the gate
ok 2 - gradual 1b: adding the exec() call to an already-critical file blocks unless that fact is acknowledged
ok 3 - gradual 2: a path acknowledged for process_spawn does not absorb a later eval
ok 4 - gradual 2 (legacy): a bare reason string still accepts the whole path, but the waiver is visible
ok 5 - gradual 2 (control): acknowledgement waives the *first* raise and records the reason and facts
ok 6 - gradual 3 (by design): low→medium then medium→high never trips --gate critical
ok 7 - gradual 4 (declared boundary): the caller of a spawning module is invisible to per-file diffFacts
# tests 7
# pass 7
# fail 0
(Seven, not six. The audit in !104 started with six, and !105 split scenario 2 into the fact-listed ack and the legacy-string ack.)
The core of 1b, the case that used to slip through:
const finding = MrReport.diffFacts({ path: 'runner.js', before: IMPORT_ONLY, after: IMPORT_AND_CALL });
assert.deepEqual(finding.dangerous.added, ['process_spawn']);
// The risk level cannot go above critical, so direction is "unchanged"...
assert.deepEqual(finding.risk, { before: 'critical', after: 'critical', direction: 'unchanged' });
// ...but the gate triggers on the new dangerous fact, not only on direction.
const result = MrReport.report([{ path: 'runner.js', before: IMPORT_ONLY, after: IMPORT_AND_CALL }]);
assert.equal(result.riskRaised.length, 0);
const verdict = MrReport.gate(result, 'critical');
assert.equal(verdict.blocked, true);
assert.ok(MrReport.renderGate(verdict).includes('stays `critical`, new (process_spawn)'));
// A fact-specific acknowledgement waives exactly this capability.
const waivedVerdict = MrReport.gate(result, 'critical', { 'runner.js': { reason: 'runs git', facts: ['process_spawn'] } });
assert.equal(waivedVerdict.blocked, false);
// An acknowledgement of a different capability does not.
assert.equal(MrReport.gate(result, 'critical', { 'runner.js': { reason: 'imports only', facts: ['child_process'] } }).blocked, true);
And scenario 3, which pins a limitation on purpose. If someone "fixes" it by quietly lowering the threshold, this test fails and forces the conversation:
assert.equal(MrReport.gate(mr1, 'critical').blocked, false);
assert.equal(MrReport.gate(mr2, 'critical').blocked, false);
// The same creep is caught by a stricter threshold, which is the documented knob.
assert.equal(MrReport.gate(mr2, 'high').blocked, true);
assert.equal(MrReport.gate(mr1, 'medium').blocked, true);
tests/unit/mr-report.test.js — 13 tests, 13 pass
The original report and gate tests. The gate-specific ones:
ok 9 - gate: blocks only when a file is raised to the threshold or above
ok 10 - gate: a file that was already critical does not block later changes that add no dangerous fact
ok 11 - gate: an acknowledged path is waived with its reason instead of blocking
ok 12 - gate: removing the dangerous operation is never blocked
ok 13 - the report layer is pure: no network, no LLM, no filesystem, no git
Test 10 is the "candle on the table" test, the title of this article in assertion form:
const stillCritical = SPAWNS.replace('module.exports = { run };', 'module.exports = { run, VERSION: 2 };');
const result = MrReport.report([{ path: 'runner.js', before: SPAWNS, after: stillCritical }]);
assert.equal(result.highestRisk, 'critical');
assert.equal(result.riskRaised.length, 0);
assert.deepEqual(result.findings[0].dangerous.added, []);
assert.equal(MrReport.gate(result, 'critical').blocked, false);
The other eight cover the report itself: reverse diffs report removals and lowered risk, added and deleted files, a formatting-only change reports nothing, an unparsable revision reports nothing instead of a false absence, and the same input renders byte-identical output twice.
tests/unit/security-regression-gate.test.js — 6 tests, 6 pass
ok 1 - rejects dropping a crypto primitive from a security module
ok 2 - rejects removing input validation (fewer throws) on any file
ok 3 - rejects a verification-bypass cache on a keep_security target
ok 4 - allows a patch that ADDS validation (no regression)
ok 5 - allows an unrelated refactor that preserves throws and primitives
ok 6 - no baseline or no new code is never a regression
The whole suite
npm test (node --test tests/*.test.js tests/unit/*.test.js): 639 tests, 10 suites, 639 pass, 0 fail.
Closing
A gate that fires on presence gets muted within a week. A gate that fires on change can stay switched on, because when it fires it's telling you something new. Over Sentinel's whole history, that's 3 merge requests out of 118.
The interesting part of this gate isn't the four-rung ladder. It's how the holes are handled. A reviewer asked a hard question. The answer was written as tests that passed while documenting the failure, and only then did one small change turn two of those tests from "this is broken" into "this is fixed". The remaining holes (slow climbs, cooperating files, unreadable files, eval hidden behind a computed property) are still in the test file and in this article, because a gate whose blind spots are written down is one people can rely on.
Captured output
Test counts — 2026-10-12, Node 24.19.0:
$ node --test --test-reporter=tap tests/unit/mr-report-gradual.test.js | grep -E '^# (tests|pass|fail)'
# tests 7
# pass 7
# fail 0
$ node --test --test-reporter=tap tests/unit/mr-report.test.js | grep -E '^# (tests|pass|fail)'
# tests 13
# pass 13
# fail 0
$ node --test --test-reporter=tap tests/unit/security-regression-gate.test.js | grep -E '^# (tests|pass|fail)'
# tests 6
# pass 6
# fail 0
$ node --test --test-reporter=tap tests/*.test.js tests/unit/*.test.js | grep -E '^# (tests|suites|pass|fail)'
# tests 639
# suites 10
# pass 639
# fail 0
Full-history replay — 2026-10-12, master at fdbfa5ad0b0 (!123), ~30 s:
for m in $(git log --first-parent --merges --format=%H master); do node scripts/mr-report.js --base $m^1 --head $m^2 --gate critical --json 2>/dev/null; echo; done | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=s.split(/\n(?=\{\n "base")/).filter(x=>x.trim()).map(JSON.parse);const js=r.filter(x=>x.files>0),f=js.map(x=>x.factCount).sort((a,b)=>a-b);console.log({merges:r.length,withJs:js.length,withFacts:f.filter(Boolean).length,medianFacts:f[f.length>>1],blocked:r.filter(x=>x.gate.blocked).length,waived:r.filter(x=>x.gate.waived.length).length,raised:r.filter(x=>x.riskRaised.length).length})})'
{
merges: 118,
withJs: 93,
withFacts: 57,
medianFacts: 2,
blocked: 3,
waived: 2,
raised: 26
}
GET /analytics — 2026-10-12 10:05:23 UTC, instance 029d5693, excerpt:
json
{
"instanceId": "029d5693",
"quality": {"total": 143, "rejections": 10, "caughtBadChanges": 10, "evolveAttempts": 41},
"provenance": {"llmWakes": 42, "wastedLlmCalls": 10, "rejectionsByGate": {"unknown": 10}}
}

Top comments (0)