A 10-minute read for developers, tech leads and the person at your company
who signs the AI invoice.
- Source (GitLab, Apache-2.0): https://gitlab.com/jakubrojicek11/sentinel-ir
- npm: https://www.npmjs.com/package/sentinel-ir
- Playground (runs in your browser): https://sentinel-ir-playground-dc30c8.gitlab.io/
TL;DR for people who scroll
sentinel-ir turns one source file into a small JSON fact sheet built from a
real syntax tree: imports, exports, routes, env reads, network, filesystem,
process and crypto calls, dangerous syntax, a risk level and the line that
caused it. You send that to the model instead of the raw file.
0.5 adds Rust, compile-time risk for build.rs and procedural macros,
hardening against comments and hostile input, and is ~28% faster on a
35,000-file corpus. Same IR shape for every language, so the tools built on
top (an MCP server and a browser playground) didn't need a single new field.
Why bother: the "senior dev at standup" problem
Ask a senior developer what a file does and you won't get a recital of 1,200
lines. You'll get: "It's an Express handler, reads STRIPE_KEY, writes to
/tmp, and shells out to ImageMagick, which is the part that'll bite us."
That's the IR. An LLM reading the raw file is the intern reading all 1,200
lines out loud. The tokens cost money, the comments can contain "ignore
previous instructions", and the important line is somewhere around 940.
Measured on the library's own source through the MCP server (tokenizer
gpt-tokenizer, o200k_base):
| file | raw tokens | IR tokens |
|---|---|---|
src/ast-facts.js | 8,439 | 120 |
src/sentinel-ir.js | 2,084 | 133 |
src/tree-parser.js | 891 | 121 |
examples/basic.js | 150 | 120 |
The IR has a floor of about 120 tokens. On a tiny file it doesn't pay off,
and we say so. On anything you'd actually want reviewed, it's one or two
orders of magnitude.
What the output looks like
Three lines of JavaScript that call eval and child_process:
{"analysisStatus":"complete", "world":{"risk":"critical"}, "forbidden":["eval","child_process","process_spawn"], "facts":{"ast":true,"imports":["child_process"], "operations":["dynamicEval","processSpawn","childProcess"], "riskSignals":["child_process_import:require(child_process)@1", "dynamic_code:eval@2","process_spawn:cp.exec@2"]}} (Trimmed. The full IR also has mission, constraints, summary and the
call lists.) Every signal is kind:evidence@line, so a reviewer, human or
model, can jump straight to the proof. facts.ast: true means the file
parsed completely, so a missing category is a proven absence, not a shrug.
The rules are structural, not vibes: a route is a call on
app/router/server/api with a string path, exec is only process
spawning when it's bound to a spawning module, and a function called
evaluate is not eval. Regex-based scanners have been confusing those two
since before some of you were born.
New in 0.5: Rust
Rust is the language you pick when you'd rather argue with a compiler than
with production. It's also where a lot of new infrastructure, browsers and
crypto lives, so it's a good place to know what code can do.
The Rust analyzer produces the same IR and fact categories as JS/TS/Python.
Paths are resolved through the file's own use declarations, including
aliases, {...} lists, self, * globs and extern crate:
use std::process::Command as Cmd; fn deploy() { Cmd::new("sh").arg("-c").arg(script).status(); } // -> processSpawn, critical, process_spawn@2 crate::, self::, super:: and a local mod fs { ... } are not mistaken
for the standard library. A few of the things it recognizes:
- Processes:
std::process,tokio::process,nixexec and fork,libc::system,duct,xshell. - Native code (critical):
unsafe,externblocks,#[no_mangle],asm!,libloading, WindowsLoadLibrary. - Embedded interpreters:
rhai,mlua,deno_core,v8,pyo3run/eval. - Filesystem, network and env:
std::fs,tokio::fs,reqwest,hyper,TcpStream,std::env::var,dotenvy. - Routes: axum
.route("/p", get(h)), actix attributes, rocket, warp, poem, salvo.
Does it actually parse real Rust? The stock tree-sitter-rust 0.24.2
grammar rejects a surprising amount of real code: all of web-sys/js-sys
(wasm-bindgen pub type X;), safe fn, const impl, trait aliases, macro
2.0, f16/f128, and more. We ship a patched grammar that rebuilds byte
for byte from a script. Results:
| corpus | stock grammar | patched |
|---|---|---|
| 15,238 files, 424 crate versions (tokio, reqwest, axum, nix, libc, ring, web-sys…) | 13,693 parse | 15,238 |
| 2,874 files of the Rust standard library | 2,724 parse | 2,874 |
Is unsafe detection any good? We compared it with
syn, the parser behind cargo-geiger, on the
15,237 files syn parses:
| construct | syn | sentinel-ir |
|---|---|---|
unsafe impl | 2,839 | 2,839 |
unsafe trait | 160 | 160 |
extern blocks | 6,266 | 6,266 |
unsafe fn | 10,137 | 10,149 (12 inside wasm-bindgen extern blocks syn skips) |
unsafe blocks | 35,428 | 37,167 (the extras are in macro bodies syn doesn't look into) |
files with any unsafe | 4,857 | all 4,857 flagged |
Plus 49 labeled snippets, and 2,950 deliberately broken real files that never
crash the analyzer and never report a dangerous operation from a half-parsed
file.
New in 0.5: the code that runs before your code runs
Here's the part that should make a security team sit up. In Rust, two kinds
of code execute on the developer's machine during cargo build:
-
build.rsbuild scripts. - Procedural macros (
#[proc_macro],#[proc_macro_derive],#[proc_macro_attribute]).
This is the supply-chain attack path: you never ran the dependency, you just
compiled it. That's like being pickpocketed by the IKEA manual.
Since 0.5.1, in such a file, reading or writing files at runtime, using the
network or touching a secret-like env variable raises the risk to at least
high:
#[proc_macro_attribute] pub fn m(a: TokenStream, i: TokenStream) -> TokenStream { std::fs::read_to_string("/etc/passwd"); // low -> high i } What the compiler reads on purpose (include!, include_str!,
#[path]) and Cargo's own variables (OUT_DIR, CARGO_CFG_*) do not raise
it, so normal build scripts don't light up like a Christmas tree. Building or
linking C at build time (cc::Build, cmake, pkg_config, vcpkg,
cargo:rustc-link-lib) is nativeCode, i.e. critical.
On 18,112 real files (crates.io and std) 0.5.1 changed 19 risk levels, all of
them build scripts or C builds, and 17 of those went to critical. Zero
analysisStatus changes.
New in 0.5: harder to fool, harder to stall
An analyzer that feeds an LLM is itself an attack surface. The person who
wrote the file you're scanning gets to pick its contents. So in 0.5.4 and
0.5.5:
- Comments can't hide calls.
require(/*x*/"child_process").exec(c)waslowin 0.5.3. Ouch. Every extractor now skips comment nodes, in every language. A fuzz run inserting comments into 2,910 files: 0 fact differences. - Text from the code is capped and redacted. Every string that reaches the IR (routes, imports, env names, evidence) is one line and at most 128 characters. String literals in evidence become
"…", sotoken == os.environ["API_TOKEN"]shows up astoken == os.environ["…"]. Less attacker-controlled text reaches your prompt. It's less, not none, and the README says so. - Broken files keep their risk. When a Python or Rust file doesn't parse (one syntax error, absurd nesting), the text fallback now knows each language's dangerous names (
os.system,subprocess,Command::new,unsafe,std::fs::write…). Breaking your own syntax used to be a way to look harmless. Now it just makes you look broken. - 5-second parse budget. tree-sitter's error recovery is superlinear on some inputs: 16 KB of
->took about 43 s in JS. Now a parse stops at 5 s and the result isincompletewith the reason. The slowest real file in a 35,000-file corpus takes 0.2 s, so only hostile input ever hits this. 4,000 mutated files: 0 crashes, 0 risk levels lowered.
New in 0.5: faster, for free
0.5.3 stopped building a JS object for every syntax node and walking the tree
a second time for metrics nobody read. The IR stayed byte-identical:
| measurement | 0.5.2 | 0.5.3 |
|---|---|---|
| 35,000 files (Rust, JS, TS, Python) | 210 s | 151 s |
| memory, 910 KB Rust file | 381 MB | 222 MB |
| memory, 744 KB Python file | 188 MB | 123 MB |
| IR differences | — | 0 |
The best optimization is deleting code. Here, nobody was even reading it.
Supply chain: we eat our own cooking
A security-flavored package shipped from someone's laptop at 2 a.m. is a
genre of horror movie. sentinel-ir is published only from CI on protected
tags, with npm provenance. Since 0.5.2 the release fails if the
provenance attestation is missing, and CI checks npm audit signatures on
npm 10 and 11 after publishing. Tests run on Node 20, 22 and 24.
How companies can use it
- Cheaper code review bots. Triage a pull request by IR first, then send the raw source of only the files whose risk changed. The model reads 120 tokens instead of 8,000 for every file that just formats dates.
- Dependency gates. Flag new or updated crates whose
build.rsor proc macro suddenly touches the network. That's exactly the moment you want a human to look. - Agent guardrails. Give agents the IR through the MCP server so they reason about what code does without pasting it into the conversation.
- One shape for four languages. JS/TS, Python and Rust come out in the same JSON. Your pipeline handles one format, not four parsers with four opinions.
const { analyze, compress } = require("sentinel-ir"); const ir = analyze(source, { target: "build.rs" }); if (ir.world.risk === "critical" || ir.world.risk === "high") flagForReview(ir); sendToModel(compress(ir)); The honest part
We'd rather you trust the numbers than the marketing, so here's what it
doesn't do:
- One file at a time. Capabilities split across files are not combined.
- Syntax, not data flow.
readFileSync(req.query.f)is a file read, not "path traversal". Risk is about capabilities, not vulnerabilities, andmedium(network, writes, crypto) is normal backend code. - Macros are token trees. Calls inside a macro's arguments are found. What a macro expands to is not, because that needs
rustcandcargo expand, which a source-only analyzer doesn't run. - Not a scanner replacement. Code that builds module names at runtime can look harmless.
lowmeans "nothing found", not "safe". Use it next to your SAST and your reviewers, not instead of them.
What's next
Go is next on the list. Same IR, same categories, a fifth language. The goal
hasn't changed: one analyzer, one shared shape, reused everywhere (the npm
library, the MCP server and the playground), so a new language lands in
all three at once.
Try it without installing anything in the
playground, or:
npm install sentinel-ir Issues and merge requests are welcome on
GitLab. Bug reports that
start with "I tried to break it and…" are especially welcome.
Independent Engineering Projects
Built as independent engineering experiments across AI, software analysis, security, and autonomous systems.
Evaluated by reproducible evidence, not by the number of layers.
Explore the projects:
Sentinel Ecosystem: AutoDoc-Sentinel, Sentinel-IR, Sentinel-IR-MCP and related tools.
Agentix Lite: Independent security and system-observation project.
Find the projects: GitLab repositories | NPM packages
Support the work: ⭐ Star the repositories on GitLab.
Connect: Follow me here on DEV Community or explore the Playground.
Each project is distributed under its own license. Check the corresponding repository or NPM package for licensing terms.
Copyright © 2026 jackymenCZ. All rights reserved. Written for the community.

