DEV Community

jackymenCZ (jackymenCZ)
jackymenCZ (jackymenCZ)

Posted on

Sentinel-IR 0.1.2 Is Live: Better AST Detection, With Tests You Can Run on Android Termux

Description: Sentinel-IR 0.1.2 improves JavaScript AST detection for dynamic code execution, computed method calls, local variable shadowing, and static "child_process" imports. Here is what changed, explained for beginners first, then verified with real tests in Termux.

Release: "sentinel-ir@0.1.2 on npm" (https://www.npmjs.com/package/sentinel-ir/v/0.1.2)
Source: "GitLab repository" (https://gitlab.com/jakubrojicek11/sentinel-ir)
Release changes: "Compare v0.1.1 with commit adf875a1" (https://gitlab.com/jakubrojicek11/sentinel-ir/-/compare/v0.1.1...adf875a1502e3bcf7242bff85a32dcf62f023c07)


The first 30 seconds: what do I get from 0.1.2?

Imagine your AI coding agent needs to inspect a JavaScript file before changing it.

You could send the entire file to the model and hope it notices the important details. Or you could extract a compact structural description first.

That is the idea behind Sentinel-IR.

Version 0.1.2 makes that description more precise in several specific situations:

  • It recognizes more ways of writing dynamic code calls, including "globalThis"eval"" and "new globalThis"Function"".
  • It understands when a local variable or function parameter named "eval" or "Function" is not the global JavaScript function.
  • It recognizes certain computed method calls, such as "cp"execSync"", when the object can be linked to "child_process".
  • It records static ES module imports of "child_process" as a potentially dangerous capability without falsely claiming that importing the module automatically executes a system command.
  • It adds regression tests for these cases, including cases where the correct answer is not to flag something as dynamic code.

The release passed 69 tests out of 69, and the GitLab CI pipeline for the release commit passed all three jobs. A separate clean-consumer test of the packaged release also passed eight targeted cases.

This is not a claim that Sentinel-IR detects every dangerous JavaScript program. It is a focused improvement to the quality of its structural evidence.

If you are young and learning to build AI tools, the key lesson is simple:

Do not ask a model to guess what code does when you can give it better evidence first.

Now, let us unpack what that means without assuming you already know how a parser works.


Part 1: No programming degree required

Approximately 10 minutes

  1. Think of Sentinel-IR as a small technical inspector

Suppose you ask a friend to renovate a room.

You could send them every photograph, receipt, conversation, drawing, and message related to your entire house.

Technically, they would have a lot of information. Practically, the important detail might disappear inside the pile.

Or you could send them a floor plan with a few useful notes:

  • There is a water pipe behind this wall.
  • Electricity enters here.
  • This door leads outside.
  • This section needs closer inspection.

That floor plan does not replace the building. But it makes the first inspection more focused.

Sentinel-IR applies a similar idea to JavaScript source code.

It extracts structural facts such as:

  • Which routes a web server declares.
  • Which modules the file imports.
  • Which environment variables it reads.
  • Whether it performs network or filesystem operations.
  • Whether it uses dynamic code execution or process-spawning APIs.
  • Which lines provide evidence for detected risk signals.

Instead of immediately sending the original source file to an AI model, an application can send a compact representation of those facts.

The model receives a map of relevant details, not necessarily the entire territory.

There is an important limitation, though. The map is intentionally incomplete. Sentinel-IR is designed to help an agent reason about a file, not to replace the original source when the agent must edit or rewrite it.

  1. What was the problem with the previous version?

Version 0.1.1 already had the central idea in place: extract facts from JavaScript's abstract syntax tree, or AST.

The AST is a structured representation of code.

Consider these two statements:

eval(userInput);

parser.eval(userInput);

They look similar because they contain the word "eval".

But they do not necessarily mean the same thing.

The first calls the global "eval" function unless that name is shadowed by a local binding.

The second calls a method belonging to "parser". That method could simply validate a string or parse an expression. Its name alone does not make it JavaScript's global dynamic-code function.

A detector that treats every occurrence of the word "eval" as equivalent will eventually make mistakes.

This release works on several of those boundaries.

It improves the analyzer's ability to distinguish a dangerous operation from harmless code that happens to use a similar name.

That distinction matters when the output helps an AI agent decide which code deserves attention.

  1. A name is not the same thing as a capability

Take the following example:

function evaluate(value) {
return value * 2;
}

module.exports = { evaluate };

The function is called "evaluate". It performs an ordinary calculation.

It does not call "eval()" or construct executable code with "Function()".

A structural analyzer should not label it as dynamic code merely because the two names look alike.

Now consider:

const Function = value => value;

Function("ordinary text");

Here, "Function" refers to a local variable. It is not a call to the global "Function" constructor.

Version 0.1.2 adds more explicit scope-aware checks to help the analyzer make this distinction.

In plain English, it asks a better question:

Does this name refer to the global operation I am looking for, or has this code introduced another meaning for the same name?

That is the idea behind variable shadowing.

You do not need to memorize the terminology. Just remember that JavaScript allows local names to hide global names. A detector needs to account for that.

  1. Why does "globalThis" matter?

JavaScript provides "globalThis" as a way to access the global object.

For our examples, these are different ways to write a global operation:

eval(userInput);

globalThis.eval(userInput);

globalThis"eval";

The last version uses bracket notation instead of the familiar dot notation.

For a human, these forms are easy to recognize as related. For a structural analyzer, they can appear as different AST shapes.

The same applies to the "Function" constructor:

new Function("return 1");

new globalThis.Function("return 1");

new globalThis"Function";

Version 0.1.2 expands the detection logic and tests to cover these additional forms.

It also checks whether "globalThis" itself has been shadowed by a local binding. If a file declares its own variable named "globalThis", the analyzer should not automatically treat that variable as the actual global object.

This is the kind of detail that can look small in a patch but makes a difference to a code-analysis tool.

  1. The difference between importing something and executing it

Consider this ES module import:

import { execSync } from "node:child_process";

It imports an API capable of launching system commands.

But it does not, by itself, launch a command.

The actual operation would look more like:

execSync("some-command");

These are two different facts:

  1. The file has imported a potentially powerful capability.
  2. The file has a call that launches a process.

Version 0.1.2 explicitly records static ES module imports of "child_process" as a capability. It keeps that fact separate from "processSpawn", which represents detected process-spawning calls.

There is a subtle point here: the current risk policy still assigns "critical" risk when the "child_process" capability is present, even if an import alone did not prove that a process was spawned.

That is a conservative policy choice. A "critical" result is not proof that the file executed a command, and it is not proof that the code is malicious.

The useful improvement is that the underlying facts preserve the distinction. Downstream tools can inspect both fields rather than interpreting the overall risk label as a complete description of runtime behavior.

  1. What is actually different in 0.1.2?

Here is the practical comparison.

Area| What 0.1.2 improves
Dynamic code| Explicit detection of more global "eval" and "Function" forms
Bracket notation| Recognition of selected string-literal computed calls such as "cp"execSync""
Local variables| Better distinction between local "eval" or "Function" bindings and global functions
Function parameters| Tests for parameters that shadow global names
"globalThis"| Checks that distinguish the real global object from a local variable with the same name
Object and class methods| Method names are not incorrectly treated as lexical declarations
ES module imports| Static "child_process" imports now produce a capability signal
Risk evidence| Regression tests verify the expected flags, risk level, and signals
Release validation| 69/69 unit tests, a passing CI pipeline, and 8/8 packaged-consumer checks

The public package remains a compact JavaScript analysis tool. This is a patch release, not a redesign of the entire public API.

  1. Does this make an AI agent safe?

No. And this is an important distinction.

Sentinel-IR does not prevent an agent from making a bad decision. It does not sandbox code. It does not prove that a program is safe, and it does not eliminate prompt injection.

It reduces the amount of source text that needs to be included in a prompt and supplies structured facts that are easier to inspect.

That can make reasoning more focused and some claims easier to check.

But structural analysis has boundaries. It cannot automatically reconstruct every behavior hidden behind dynamic property names, values passed between modules, or runtime-generated code.

The design principle is not "the analyzer knows everything".

It is:

Give the next component useful evidence, and keep the limits of that evidence visible.

That is why the release tests matter. They let us demonstrate particular improvements instead of relying on a large security claim.


Part 2: What changed in the source code?

For developers and technically curious readers

The main implementation change is in ""src/ast-facts.js"" (https://gitlab.com/jakubrojicek11/sentinel-ir/-/blob/adf875a1502e3bcf7242bff85a32dcf62f023c07/src/ast-facts.js).

The patch extends how AST nodes are interpreted. It does not introduce an LLM into this detection path. The checks are deterministic JavaScript logic over the parser's syntax tree.

  1. Computed calls: looking beyond the dot

Earlier detection logic could split a regular member call into an object and a property:

cp.execSync("example");

In AST terms, the object is "cp", and the property is "execSync".

But JavaScript also supports:

cp"execSync";

That is a "subscript_expression", not the same AST node type as an ordinary "member_expression".

The updated "callTarget()" helper in "src/ast-facts.js" now handles "subscript_expression" when its key is a recognizable string literal.

Conceptually, the result can still be normalized to:

object: cp
property: execSync

That lets the existing process-spawning checks inspect the method name and its receiver rather than silently missing this syntax form.

The implementation deliberately does not treat every bracket expression as known. For example:

cpmethodName;

Here, "methodName" could be computed at runtime. The analyzer does not automatically know its value. Supporting that correctly would require additional resolution, not a guess.

The improvement is support for recognizable, statically written keys, not universal resolution of computed properties.

  1. Explicit detection for "eval" and "Function"

The updated call analysis covers additional forms of dynamic code execution.

Examples include:

eval(input);

Function("return 1");

globalThis.eval(input);

globalThis"eval";

It also adds handling for constructor expressions:

new Function("return 1");

new globalThis.Function("return 1");

new globalThis"Function";

Why do we need separate logic for "new Function()"?

Because it appears in a "new_expression" node rather than an ordinary "call_expression". Detection code that only examines regular calls will not automatically cover constructor expressions.

The patch therefore adds explicit handling for these constructor forms and tests that isolate them.

The aim is to set the "dynamicEval" operation, collect the corresponding evidence in the dynamic-call category, and emit the "dynamic_code" risk signal when the syntax is recognized.

The risk classification then derives a risk level from the extracted operations.

This remains syntax-oriented detection. It does not determine whether the supplied string is malicious, whether a user controls it, or what the resulting code would do at runtime.

  1. Scope-aware resolution for local bindings

This is the largest conceptual addition.

A naive improvement might say:

«Whenever the code calls "Function", flag dynamic execution.»

That would be wrong for code such as:

const Function = value => value;

Function("ordinary text");

The analyzer needs to distinguish the global function from local bindings.

The patch adds a group of helpers for this purpose:

  • "bindingPatternHasName()"
  • "lexicalDeclarationHasName()"
  • "importStatementHasBinding()"
  • "lexicalScopeHasBinding()"
  • "varScopeHasBinding()"
  • "functionScopeHasBinding()"
  • "isNameShadowed()"
  • "isUnshadowedGlobalThis()"

Together, they implement a conservative AST-based check for whether a name is shadowed in the relevant scope.

The logic examines declaration and binding patterns, function parameters, relevant variable declarations and imports, as well as lexical and function scopes.

For example:

function run(Function) {
return Function("ordinary text");
}

The parameter is called "Function", but inside that function it is a local binding.

Likewise:

import Function from "safe-module";

Function("ordinary text");

The imported binding takes the name "Function". The detector should not automatically interpret the call as the global constructor.

The new regression suite tests default imports and aliased named imports, in addition to variables and parameters.

The implementation also distinguishes lexical bindings from property names.

For example:

const obj = {
Function() {
Function("return 1");
}
};

The name "Function" on the method declaration is a property name. It does not, on its own, create a lexical binding named "Function" inside the method body. The bare call inside the method should therefore still be recognized as a call to the global "Function" constructor when no local binding shadows it.

This is a useful example of why scope handling needs more than a search for matching names.

The current implementation is explicitly a conservative AST-based scope check, not a complete JavaScript name resolver. That distinction should remain visible in any system that depends on its results.

  1. Static ES module imports and the capability signal

The extraction walk now also handles "import_statement" nodes.

When a static import refers to either:

import { execSync } from "child_process";

or:

import * as cp from "node:child_process";

the analyzer records the module as an import and marks the "childProcess" capability.

It adds a "child_process_import" signal with evidence from the import statement.

This is intentionally separate from the detection of a process-spawning call.

For a minimal import-only fixture, the expected facts are:

childProcess: true
processSpawn: false

Under the current risk policy, the overall risk is still "critical" because importing "child_process" is classified as a critical capability. A consumer should not confuse that conservative policy with evidence that a process was actually launched.

This separation is useful because it makes the output more informative: the downstream decision-maker can see what capability was declared and whether a matching operation was detected.

  1. What changed in the tests?

The release adds targeted regression coverage in two places.

"test/ast-facts.test.js"

The expanded suite includes:

  • Static ES imports using namespace, named, renamed, side-effect-only, and unprefixed "child_process" forms.
  • Direct and global "eval" calls.
  • "Function()" and "globalThis.Function" constructor forms.
  • Computed global calls such as "globalThis"eval"".
  • Computed "child_process" method calls such as "cp"execSync"".
  • Shadowing cases involving variables, parameters, and explicit global access.
  • Assertions that the analyzer reports the expected risk and risk signals.

"test/shadowing-imports.test.js"

This new file tests twelve regression cases covering local or imported bindings, local "globalThis" objects, object and class method names, and genuine global dynamic-code calls.

Each case checks more than whether parsing succeeded. It verifies the resulting "dynamicEval" flag and expected risk level.

That is important because the desired result is not always "flag it".

A useful detector must also preserve cases where the correct answer is "this is not the dangerous operation we were looking for".

What the tests do not prove

These tests analyze source strings. They do not execute the supplied JavaScript and do not prove that a real application is secure.

A passing regression test demonstrates that a particular fixture produced the expected structural classification. It does not establish complete semantic coverage for all JavaScript programs.

That is exactly why we want multiple kinds of evidence: unit tests, a CI run, and a test of the packaged library.


Part 3: Reproduce the release checks in Termux

Approximately 20 minutes for developers

You do not need a desktop computer to try Sentinel-IR. The package can use its bundled WebAssembly parser through the asynchronous API.

The following steps test the actual npm package installed into a new consumer directory. They do not require access to my working directory.

  1. Step one: check the published version

Open Termux and run:

node --version
npm --version
npm view sentinel-ir@0.1.2 version
npm view sentinel-ir dist-tags --json

The package requires Node.js 20 or newer.

The version query should return:

0.1.2

And the latest distribution tag should include:

{
"latest": "0.1.2"
}

The registry check is important. A version number in a local "package.json" does not prove that a public registry contains that version.

  1. Step two: install the exact package into a clean directory

Create a separate test project:

mkdir -p ~/sentinel-ir-v012-check
cd ~/sentinel-ir-v012-check

npm init -y
npm install --save-exact sentinel-ir@0.1.2
npm ls sentinel-ir

The dependency tree should show:

sentinel-ir@0.1.2

Using an exact version keeps the experiment reproducible. It also avoids accidentally testing a different release later.

  1. Step three: create a small regression smoke test

Create a new JavaScript file:

cat > check-v012.js <<'NODE'
const assert = require("node:assert/strict");
const { analyzeAsync } = require("sentinel-ir");

const cases = [
{
name: "local Function is not the global constructor",
source: 'const Function = value => value; Function("ordinary text");',
risk: "low",
operations: { dynamicEval: false }
},
{
name: "global Function is dynamic code",
source: 'Function("return 1");',
risk: "critical",
operations: { dynamicEval: true }
},
{
name: "computed globalThis eval is detected",
source: 'globalThis"eval";',
risk: "critical",
operations: { dynamicEval: true }
},
{
name: "computed child_process method is detected",
source: 'const cp = require("child_process"); cp"execSync";',
risk: "critical",
operations: {
childProcess: true,
processSpawn: true
}
},
{
name: "ordinary object eval method is not global eval",
source: 'const parser = { eval(input) { return input; } }; parser.eval("text");',
risk: "low",
operations: { dynamicEval: false }
},
{
name: "static child_process import records capability, not a spawn",
source: 'import { execSync } from "node:child_process";',
risk: "critical",
operations: {
childProcess: true,
processSpawn: false
}
}
];

async function main() {
for (const item of cases) {
const result = await analyzeAsync(item.source, {
target: "smoke-test.js"
});

assert.equal(
  result.analysisStatus,
  "complete",
  `${item.name}: parsing should complete`
);

assert.equal(
  result.world.risk,
  item.risk,
  `${item.name}: unexpected risk`
);

for (const [operation, expected] of Object.entries(item.operations)) {
  assert.equal(
    result.facts.operations[operation],
    expected,
    `${item.name}: operations.${operation}`
  );
}

console.log(`PASS: ${item.name} (${result.world.risk})`);
Enter fullscreen mode Exit fullscreen mode

}

console.log(\n${cases.length}/${cases.length} smoke tests passed.);
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
NODE

These source strings are analyzed, not executed. In particular, the "execSync" example is never used to run a system command. It is just input to the static analyzer.

Now run:

node check-v012.js

What should happen?

Each case should print "PASS", followed by a final line reporting six out of six smoke tests passed.

Here is what we are checking:

Example| Expected result
Local variable called "Function"| No dynamic-code finding, low risk
Genuine global "Function()"| Dynamic-code finding, critical risk
"globalThis"eval""| Dynamic-code finding, critical risk
"cp"execSync"" on a "child_process" binding| Process-spawning finding, critical risk
Ordinary "parser.eval()" method| No dynamic-code finding, low risk
Static "child_process" import alone| Capability detected, spawn not proven, critical policy rating

The last row deserves attention: the test expects "processSpawn" to be false even though the overall risk is "critical". That is intentional under the current risk policy.

A result is only meaningful if we preserve the difference between the facts and the policy built on top of them.

  1. Step four: inspect the actual output

If you would like to inspect the complete IR for one example rather than only its risk and operation flags, create another file:

cat > inspect-v012.js <<'NODE'
const { analyzeAsync, compress } = require("sentinel-ir");

const code =
const cp = require("child_process");
cp["execSync"]("example");
;

async function main() {
const result = await analyzeAsync(code, {
target: "example.js"
});

console.log(JSON.stringify(compress(result), null, 2));
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
NODE

node inspect-v012.js

The compressed output is designed to show useful structural facts without reproducing the full source file.

Inspect the fields under "facts", the operation list, and the risk signals. A computed "execSync" call linked to "child_process" should be represented as a detected process-spawning operation.

Exact line numbers depend on the source string. The important part is that the result includes evidence derived from the parsed code, not merely a keyword match in a comment.

  1. Step five: run the full development test suite

The npm package intentionally contains the runtime files and bundled WebAssembly grammar, not the development test files. To run the project's full suite, clone the source repository separately.

cd ~

git clone https://gitlab.com/jakubrojicek11/sentinel-ir.git
cd sentinel-ir

git checkout adf875a1502e3bcf7242bff85a32dcf62f023c07

npm ci
npm test

This checks the source at the exact commit used for version 0.1.2 rather than assuming that the current branch will never change.

On the release build used here, the result was:

69 tests passed
0 tests failed

The repository includes the scope and import regressions discussed above.

On Android, optional native Tree-sitter dependencies may behave differently from those on a desktop Linux system. Sentinel-IR also includes a WebAssembly parser path, which is why the published-package smoke test uses "analyzeAsync()".

The project targets Node.js 20 or newer; the WebAssembly path is the one to use when the optional native parser cannot be initialized.

  1. What was independently checked for this release?

There were several different checks. They answer different questions.

First: local development tests.

The full Node.js test suite passed 69 out of 69 tests.

Second: GitLab CI.

Pipeline "#2964050242" (https://gitlab.com/jakubrojicek11/sentinel-ir/-/pipelines/2964050242) passed for commit "adf875a1", with all three jobs successful.

Third: the packaged consumer test.

The generated "sentinel-ir-0.1.2.tgz" archive was installed into a separate consumer directory. Eight targeted cases passed, covering local shadowing, imported bindings, method names, genuine global dynamic code, and "globalThis" access.

Fourth: the public npm registry.

After the package finished processing, these commands returned the expected release information:

npm view sentinel-ir@0.1.2 version
npm view sentinel-ir dist-tags --json

The registry returned "0.1.2" and "latest: 0.1.2".

These checks should not be treated as interchangeable. Unit tests show that the current source passes its tests. CI shows that the configured pipeline passed. A clean-consumer test checks the packaged artifact. The registry query verifies that consumers can obtain the public release.

Together, they provide better evidence than a successful command alone.


Part 4: The limits that still matter

It would be misleading to finish this article by calling Sentinel-IR a complete JavaScript security analyzer.

It is not.

The release improves recognition of selected syntactic forms, but the tool still has several boundaries:

  • JavaScript only. TypeScript, JSX-heavy code, and other languages are not supported claims.
  • One file at a time. The analyzer does not combine capabilities across an entire application.
  • Incomplete semantic resolution. Runtime-computed keys, complicated aliases, and values passed between modules are not fully resolved.
  • Syntax is not runtime behavior. Detecting a call does not establish what every possible input will make it do.
  • Risk is a policy estimate. "critical" is not synonymous with malicious, and "low" is not a proof of safety.
  • Fallback analysis has weaker guarantees. When the AST extraction is unavailable or parsing fails, results must be treated differently from complete structural extraction.

The compact IR also still contains some source-derived information, such as module names, identifiers, route paths, and environment variable names. It reduces how much untrusted text is sent to a model, but it does not remove prompt-injection risk.

Applications that make automated decisions should pay attention to "analysisStatus". An incomplete result must not be treated as proof that nothing dangerous exists.

Final thoughts

Sentinel-IR 0.1.2 is a focused improvement rather than a grand claim about autonomous AI.

It teaches a useful engineering lesson: sometimes the most valuable change is not a new feature that looks impressive in a demo. Sometimes it is making an existing feature distinguish two situations it previously could have confused.

A local variable called "Function" is not automatically the global constructor. An object method called "eval" is not automatically global "eval". Importing "child_process" is not the same fact as launching a process. And a bracket expression with a known string key is not the same as an arbitrary expression that can only be resolved at runtime.

Those distinctions are small enough to test and important enough to matter.

The release is available here:

If you build coding agents or static-analysis tools, I would be especially interested in one question:

Which JavaScript syntax patterns cause your current tooling to confuse a name with the capability behind it?

A small, reproducible test case is the best place to start. That is how a claim about an improvement becomes evidence that other people can inspect, reproduce, and challenge.

Top comments (0)