feat(cpg): add a transaction-owned engine session and route it opt-in (M2 11.1, 11.3)

JVM startup, not analysis, is what a push scan spends its time on: each disposable
script invocation costs 13 to 23 seconds while the work takes under a second.

The session owns one Joern server per transaction on loopback with an OS-chosen
port and no published port. Three properties are load-bearing, and the probe that
settled the design demonstrated why each is needed.

An HTTP status is not an answer. The server returns success even for a compile
error, with the diagnostic in its body, so every request redirects the evaluated
code's console output into a fresh private file behind a nonce, and the nonce is
verified before the answer is accepted. The engine's body is retained for exactly
the case where the receipt is missing, without which that failure explains nothing.

A poisoned session answers nothing afterwards, and cancellation kills the process
group rather than the launcher, which this project has already run a machine out of
memory by neglecting.

What the probe established about the engine, after three iterations that each
failed loudly: runScript is absent from the server REPL; a shipped @main script
cannot be nested in a block expression, so definitions go to the top level
unwrapped; and the REPL shadows Console with io.joern.console.Console, which has no
withOut, so capture must qualify scala.Console. With that fixed, both mechanisms
answer identically to the disposable invocation, 0.8 s against 9.8 s.

The backend routes batch and single queries through the session only when
session_transport is enabled, which it is not by default. Any failure falls back to
the disposable invocation rather than reporting a zero, and each script is defined
under its own name because every script declares exec.

Equivalence for taint, dominance and configuration is task 11.2 and gates any
measured run. 1,391 tests passed, nine skipped. Rollout stays NO-GO.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-16 20:24:34 +02:00
co-authored by Claude Opus 5
parent cec1c4a428
commit 7e3f12bc00
10 changed files with 3314 additions and 33 deletions
+187
View File
@@ -0,0 +1,187 @@
"""Probe: can a shipped query script run inside a Joern session and answer identically?
This settles the one design unknown for release milestone M2 task 11.1. The shipped scripts are
`@main def exec(...)` files invoked as `joern --script X.sc --param k=v`, and a session has no
`--param`. Before routing production work through a session we need a mechanism that runs the
same script with the same parameters and returns the same payload.
It drives the production `EngineSession` transport, so the receipt protocol under test is the real
one. Each candidate defines the script in the session and then calls it, capturing the script's
own console output through the session's private receipt file. A candidate counts as working only
when its payload matches the disposable invocation's payload for the same graph.
The first version of this probe sent two candidates as Scala string literals rather than as code
and reported no working mechanism. That was an instrument defect, recorded here because a probe
that lies about the engine is worse than no probe. It did establish that `runScript` is absent
from the server REPL, and it failed loudly, returning the script's own source as its payload
rather than quietly reporting a zero.
Isolated experiment: no production path is changed, and no detection or latency claim is made.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
from openultrasast.cpg.backend import extract_payload
from openultrasast.cpg.session import EngineSession
from openultrasast.model.contracts import ExecutionBudget
QUERIES = Path("/app/src/openultrasast/cpg/queries")
def strip_main(source: str, name: str) -> str:
"""Turn `@main def exec(...)` into an ordinary definition the REPL can hold."""
return source.replace("@main def exec(", f"def {name}(", 1)
def main() -> int:
out = Path(sys.argv[1])
out.mkdir(parents=True, exist_ok=False)
root = Path(sys.argv[2])
record: dict[str, Any] = {
"schema_version": 1,
"experiment": "session-script-execution-v2",
"purpose": "Which in-session mechanism runs a shipped @main script with parameters",
"scope": "One JavaScript graph, census script; no security-query equivalence and no latency claim",
"instrument_note": (
"v1 of this probe sent two candidates as Scala string literals instead of code and reported "
"NO_MECHANISM_FOUND. It did establish that runScript is absent from the server REPL."
),
"attempts": [],
"verdict": {},
}
def save() -> None:
(out / "result.json").write_text(json.dumps(record, indent=2, default=str) + "\n")
files = sorted(p for p in root.rglob("*.js") if p.is_file())
opened = [(str(p.relative_to(root)), p.read_bytes()) for p in files]
if not opened or not all(raw for _, raw in opened):
raise SystemExit("no readable JavaScript source under the given root")
record["source"] = {
"root": str(root),
"files": len(opened),
"bytes": sum(len(raw) for _, raw in opened),
"sha256": hashlib.sha256(b"".join(raw for _, raw in opened)).hexdigest(),
}
save()
env = dict(os.environ, JAVA_TOOL_OPTIONS="-Xmx1536m")
with tempfile.TemporaryDirectory(prefix="session-script-probe-") as scratch_name:
scratch = Path(scratch_name)
graph = scratch / "cpg.bin"
build = subprocess.run( # noqa: S603
["joern-parse", "--output", str(graph), str(root)],
cwd=scratch,
env=env,
capture_output=True,
text=True,
timeout=900,
)
graph_bytes = graph.stat().st_size if graph.is_file() else 0
record["build"] = {"exit_code": build.returncode, "graph_bytes": graph_bytes, "stderr": build.stderr[-400:]}
save()
if build.returncode != 0 or not graph_bytes:
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "graph build failed"}
save()
return 2
started = time.monotonic()
disposable = subprocess.run( # noqa: S603
["joern", "--script", str(QUERIES / "census.sc"), "--param", f"cpgFile={graph}"],
cwd=scratch,
env=env,
capture_output=True,
text=True,
timeout=900,
)
expected = extract_payload(disposable.stdout or "")
record["disposable"] = {"seconds": time.monotonic() - started, "exit_code": disposable.returncode, "payload": expected}
save()
if not isinstance(expected, dict):
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "disposable census produced no payload"}
save()
return 2
source = (QUERIES / "census.sc").read_text()
candidates: list[tuple[str, str, str]] = [
("main_annotation_kept", source, f"exec({json.dumps(str(graph))})"),
("main_annotation_stripped", strip_main(source, "ousastCensus"), f"ousastCensus({json.dumps(str(graph))})"),
]
def fresh(tag: str) -> EngineSession:
live = EngineSession(scratch / f"session-{tag}", ExecutionBudget(time.monotonic() + 1500, 5.0), env)
live.start()
return live
session = fresh("initial")
if not session.alive:
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": session.failure}
save()
session.close()
return 2
record["session_startup_seconds"] = session.startup_seconds
save()
try:
for index, (name, definition, call) in enumerate(candidates):
attempt: dict[str, Any] = {"mechanism": name}
started = time.monotonic()
# Definitions go to the top level unwrapped: `@main` and top-level declarations
# are not block expressions, so the receipt wrapper cannot hold them. The call
# that follows is the only proof the definition took.
defined = session.define(definition, timeout=300)
attempt["definition_sent"] = defined is not None
attempt["definition_output"] = (defined or session.failure)[-800:]
if defined is not None:
answer = session.evaluate(call, timeout=600)
if answer is None:
attempt["error"] = session.failure
# The engine's own diagnostic, without which a missing receipt says nothing.
attempt["engine_body"] = session.last_body[-1500:]
else:
parsed = extract_payload(answer.stdout)
attempt["call_seconds"] = answer.seconds
attempt["payload"] = parsed
attempt["stdout_tail"] = answer.stdout[-300:]
attempt["payload_matches_disposable"] = bool(
isinstance(parsed, dict)
and all(parsed.get(key) == expected.get(key) for key in ("files", "methods", "calls"))
and sorted(parsed.get("file_names") or []) == sorted(expected.get("file_names") or [])
)
attempt["seconds"] = time.monotonic() - started
record["attempts"].append(attempt)
save()
# A poisoned session cannot serve the next candidate honestly.
if not session.alive and index + 1 < len(candidates):
session.close()
session = fresh(name)
if not session.alive:
break
working = [a["mechanism"] for a in record["attempts"] if a.get("payload_matches_disposable")]
record["verdict"] = {
"status": "PASS" if working else "NO_MECHANISM_FOUND",
"working": working,
"warm_call_seconds": [a.get("call_seconds") for a in record["attempts"] if a.get("payload_matches_disposable")],
"disposable_seconds": record["disposable"]["seconds"],
"note": (
"Census only. A working mechanism must still be proven equivalent for taint, dominance and "
"configuration before any production switch, which is task 11.2."
),
}
save()
return 0 if working else 1
finally:
session.close()
if __name__ == "__main__":
raise SystemExit(main())